From cd373ee46b826e23d41a0dbc5e5bd4ff1cb77c87 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Thu, 5 Nov 2026 13:45:00 +0000 Subject: [PATCH] Preserve owning categories in public errors --- docs/roadmap/implementation.md | 5 +++++ errors.go | 25 ++++++++++++++++++++++++ errors_internal_test.go | 9 +++++++++ nested_error_contract_test.go | 35 ++++++++++++++++++++++++++++++++++ 4 files changed, 74 insertions(+) create mode 100644 nested_error_contract_test.go diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index efa3318..f637d12 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -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 diff --git a/errors.go b/errors.go index baa7e9b..77ffa6e 100644 --- a/errors.go +++ b/errors.go @@ -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) != "" { diff --git a/errors_internal_test.go b/errors_internal_test.go index c515ada..e417844 100644 --- a/errors_internal_test.go +++ b/errors_internal_test.go @@ -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) + } +} diff --git a/nested_error_contract_test.go b/nested_error_contract_test.go new file mode 100644 index 0000000..7690064 --- /dev/null +++ b/nested_error_contract_test.go @@ -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) + } +}