Preserve owning categories in public errors

This commit is contained in:
2026-11-05 13:45:00 +00:00
parent e881bbdcf4
commit cd373ee46b
4 changed files with 74 additions and 0 deletions

View File

@@ -147,6 +147,11 @@ for their respective later units.
preserve `errors.Is`/`errors.As`, existing capacity metadata, exhaustion and
provider projections, and cancellation identity.
Completion: public projection preserves the owning operation alongside nested
causes and specialized projections. Root and generation tests pass on Go 1.27.3;
two use-case preparation-classification tests await the direct preparation
marker specified below.
## Stage 5: Retain Successful-Response Read Failures
**Catalog:** 8. **Files:** `internal/llm/openai_compatible_client.go` and focused

View File

@@ -17,6 +17,31 @@ func mapPublicError(err error) error {
if err == nil {
return nil
}
var operation *usecase.OperationError
if errors.As(err, &operation) && operation != nil {
owner := publicErrorFor(operation.Category)
if owner != nil {
// Project the underlying specialized error separately, retaining both
// the outer operation and the original cause for Is/As callers.
mapped := mapPublicError(operation.Cause)
if errors.Is(operation.Cause, llm.ErrTransport) {
mapped = errors.Join(ErrTransport, mapped)
}
if errors.Is(operation.Cause, llm.ErrMalformedResponse) {
mapped = errors.Join(ErrMalformedResponse, mapped)
}
var internalCapacity *usecase.CapacityError
var internalProvider *llm.ProviderHTTPError
if errors.As(operation.Cause, &internalCapacity) || errors.As(operation.Cause, &internalProvider) {
return errors.Join(owner, mapped)
}
return errors.Join(owner, mapped, operation.Cause)
}
}
return mapUnownedPublicError(err)
}
func mapUnownedPublicError(err error) error {
var internalCapacityError *usecase.CapacityError
if errors.As(err, &internalCapacityError) && internalCapacityError != nil &&
strings.TrimSpace(internalCapacityError.BackendID) != "" {

View File

@@ -80,3 +80,12 @@ func TestMapPublicErrorDoesNotTreatRequestEncodingAsTransport(t *testing.T) {
t.Fatalf("request encoding category = %v", err)
}
}
func TestMapPublicErrorRetainsOwnerWithSpecializedCapacity(t *testing.T) {
internal := &usecase.CapacityError{BackendID: "limited"}
err := mapPublicError(&usecase.OperationError{Category: usecase.ErrLLMGenerate, Cause: internal})
var capacity *CapacityError
if !errors.Is(err, ErrLLMGenerate) || !errors.As(err, &capacity) || capacity.BackendID != "limited" || !errors.Is(err, ErrCapacityExceeded) {
t.Fatalf("mapped owned capacity = %v", err)
}
}

View File

@@ -0,0 +1,35 @@
package promptkit_test
import (
"context"
"errors"
"testing"
"go.maximumdirect.net/promptkit"
)
type nestedErrorReader struct{ cause error }
func (r nestedErrorReader) Read(context.Context, promptkit.ArtifactRef) (*promptkit.Artifact, error) {
return nil, r.cause
}
func TestNestedEngineErrorKeepsOwningOperation(t *testing.T) {
child := newSimpleEngine(t, "child")
_, childErr := child.Run(context.Background(), promptkit.RunRequest{PromptID: "missing"})
if !errors.Is(childErr, promptkit.ErrPromptNotFound) {
t.Fatalf("child error = %v", childErr)
}
client := &fakeLLMClient{err: childErr}
parent := newSimpleEngine(t, "hello", promptkit.WithLLMClient(client))
_, err := parent.Run(context.Background(), promptkit.RunRequest{PromptID: "prompt"})
if !errors.Is(err, promptkit.ErrLLMGenerate) || !errors.Is(err, childErr) || !errors.Is(err, promptkit.ErrPromptNotFound) {
t.Fatalf("generation error = %v", err)
}
readerParent := newSimpleEngine(t, "hello", promptkit.WithArtifactReader(nestedErrorReader{cause: childErr}))
_, err = readerParent.Prepare(context.Background(), promptkit.RunRequest{PromptID: "prompt", Inputs: map[string]promptkit.ArtifactRef{"extra": promptkit.Inline("data")}})
if !errors.Is(err, promptkit.ErrArtifactLoad) || !errors.Is(err, childErr) || !errors.Is(err, promptkit.ErrPromptNotFound) {
t.Fatalf("artifact error = %v", err)
}
}