diff --git a/capacity_contract_test.go b/capacity_contract_test.go index 2a57370..ef80032 100644 --- a/capacity_contract_test.go +++ b/capacity_contract_test.go @@ -60,7 +60,18 @@ func TestEngineRejectsRunBeforeCompletionWhenAdmissionIsFull(t *testing.T) { awaitCapacitySignal(t, reader.entered, "first artifact read") - result, err := engine.Run(context.Background(), capacityInputRequest("http://second.example/v1")) + canceledContext, cancel := context.WithCancel(context.Background()) + cancel() + result, err := engine.Run(canceledContext, capacityInputRequest("http://canceled.example/v1")) + if result != nil || !errors.Is(err, context.Canceled) { + t.Fatalf("canceled capacity admission=(%+v, %v), want context cancellation", result, err) + } + var canceledCapacityErr *promptkit.CapacityError + if errors.Is(err, promptkit.ErrCapacityExceeded) || errors.As(err, &canceledCapacityErr) { + t.Fatalf("canceled admission exposed capacity rejection: %v", err) + } + + result, err = engine.Run(context.Background(), capacityInputRequest("http://second.example/v1")) if result != nil { t.Fatalf("capacity rejection returned partial result: %+v", result) } @@ -70,6 +81,21 @@ func TestEngineRejectsRunBeforeCompletionWhenAdmissionIsFull(t *testing.T) { if errors.Is(err, promptkit.ErrInvalidRequest) || errors.Is(err, promptkit.ErrLLMGenerate) { t.Fatalf("capacity rejection had an unrelated category: %v", err) } + var capacityErr *promptkit.CapacityError + if !errors.As(err, &capacityErr) || capacityErr == nil { + t.Fatalf("capacity rejection=%v, want CapacityError", err) + } + if capacityErr.BackendID != "limited" { + t.Fatalf("capacity backend ID=%q, want limited", capacityErr.BackendID) + } + capacityErr.BackendID = "changed" + + result, err = engine.Run(context.Background(), capacityInputRequest("http://third.example/v1")) + var subsequentCapacityErr *promptkit.CapacityError + if result != nil || !errors.As(err, &subsequentCapacityErr) || + subsequentCapacityErr == nil || subsequentCapacityErr.BackendID != "limited" { + t.Fatalf("subsequent capacity rejection=(%+v, %v), want independent limited CapacityError", result, err) + } if calls := reader.callCount(); calls != 1 { t.Fatalf("artifact calls=%d, want only the admitted run", calls) } @@ -174,6 +200,19 @@ func TestCapacityExceededSentinelContract(t *testing.T) { if promptkit.ErrCapacityExceeded == nil { t.Fatal("ErrCapacityExceeded is nil") } + var nilCapacityErr *promptkit.CapacityError + zeroCapacityErr := &promptkit.CapacityError{} + populatedCapacityErr := &promptkit.CapacityError{BackendID: "limited"} + for _, capacityErr := range []error{nilCapacityErr, zeroCapacityErr} { + if !errors.Is(capacityErr, promptkit.ErrCapacityExceeded) { + t.Fatalf("capacity error=%v, want ErrCapacityExceeded", capacityErr) + } + } + var discoveredCapacityErr *promptkit.CapacityError + if !errors.As(populatedCapacityErr, &discoveredCapacityErr) || discoveredCapacityErr != populatedCapacityErr { + t.Fatalf("populated capacity error is not discoverable: %v", populatedCapacityErr) + } + for _, unrelated := range []error{ promptkit.ErrInvalidConfig, promptkit.ErrInvalidRequest, @@ -181,7 +220,8 @@ func TestCapacityExceededSentinelContract(t *testing.T) { promptkit.ErrValidation, } { if errors.Is(promptkit.ErrCapacityExceeded, unrelated) || - errors.Is(unrelated, promptkit.ErrCapacityExceeded) { + errors.Is(unrelated, promptkit.ErrCapacityExceeded) || + errors.Is(populatedCapacityErr, unrelated) { t.Fatalf("ErrCapacityExceeded aliases unrelated sentinel %v", unrelated) } } diff --git a/capacity_error.go b/capacity_error.go new file mode 100644 index 0000000..d7bbad1 --- /dev/null +++ b/capacity_error.go @@ -0,0 +1,40 @@ +package promptkit + +import ( + "fmt" + "strings" +) + +// CapacityError reports bounded admission rejected for a selected backend. +// +// Engine-produced values identify only rejection at Promptkit's bounded +// [Engine.Run] or [Engine.RunPrepared] admission boundary. BackendID is the +// normalized registered backend ID used for routing and capacity; endpoint +// overrides do not change it. Every engine-produced value is nonnil and has a +// nonblank BackendID. Provider errors, active-generation waiting, and caller +// cancellation are not represented by this type. +// +// Callers own returned values and may mutate BackendID without affecting engine +// state or another error. CapacityError and its default Go encoding have no +// stable JSON contract. Consumer-constructed values do not establish that an +// engine rejected work. +type CapacityError struct { + // BackendID is the normalized registered backend ID whose admission was + // rejected. + BackendID string +} + +// Error returns diagnostic wording that is not a parsing contract. It is safe +// to call on a nil receiver or a value with a blank BackendID. +func (e *CapacityError) Error() string { + if e == nil || strings.TrimSpace(e.BackendID) == "" { + return ErrCapacityExceeded.Error() + } + return fmt.Sprintf("backend %q admission: %v", e.BackendID, ErrCapacityExceeded) +} + +// Unwrap returns ErrCapacityExceeded so errors.Is and errors.As can be used +// together. It is safe to call on a nil receiver or a zero value. +func (e *CapacityError) Unwrap() error { + return ErrCapacityExceeded +} diff --git a/doc.go b/doc.go index a7206a0..0fbd334 100644 --- a/doc.go +++ b/doc.go @@ -23,7 +23,8 @@ // InspectProfile return copied inspection values. Returned values and values // passed to extension interfaces are likewise isolated from engine state. // Callers own those copies and may mutate them after the call that supplied or -// returned them. +// returned them. Returned structured errors are likewise caller-owned and may +// be mutated without affecting engine state or another error. // // # Security and sensitive data // @@ -49,12 +50,12 @@ // [GenerateResponse], [ExecutionTargetPresence], and the string value types // used by those values. // -// Construction, inspection, and handle values, including [Config], [Backend], -// [RunRequest], [ArtifactRef], [ExecutionTargetOverride], [Profile], +// Construction, inspection, handle, and error values, including [Config], +// [Backend], [RunRequest], [ArtifactRef], [ExecutionTargetOverride], [Profile], // [OpenAICompatibleProfileConfig], [ProfileInspection], -// [PromptInputDefinition], [PromptInspection], and [PreparedExecution], do -// not have stable JSON representations. Direct API keys are nevertheless -// excluded from JSON for every public value. +// [PromptInputDefinition], [PromptInspection], [PreparedExecution], and +// [CapacityError], do not have stable JSON representations. Direct API keys +// are nevertheless excluded from JSON for every public value. // // JSON timestamps use time.Time's RFC 3339 encoding and are omitted when zero. // PreparedRun and RunResult durations are encoded as integer milliseconds in diff --git a/engine.go b/engine.go index 6614f3f..dc899b4 100644 --- a/engine.go +++ b/engine.go @@ -65,8 +65,8 @@ var ( ErrPromptRender = errors.New("failed to render prompt") // ErrCapacityExceeded identifies a Run or RunPrepared rejected because the // selected backend already admitted ConcurrencyLimit + QueueCapacity calls. - // It is not an invalid request, an LLM or provider rate-limit response, or - // ErrLLMGenerate. + // A [CapacityError] reports the selected backend ID. It is not an invalid + // request, an LLM or provider rate-limit response, or ErrLLMGenerate. ErrCapacityExceeded = errors.New("backend capacity exceeded") // ErrLLMGenerate identifies a model-client failure or a nil successful // response. Errors returned by an injected LLMClient remain available @@ -592,9 +592,10 @@ func (e *Engine) PrepareExecution(ctx context.Context, req RunRequest) (*Prepare // single-pass even when OutputContract.RepairAttempts is positive. // // Run can return every error category documented by [Engine.Prepare], plus -// ErrCapacityExceeded and ErrLLMGenerate. ErrCapacityExceeded identifies -// rejection before artifacts, schemas, rendering, or model generation because -// the selected backend's admission capacity is full; it does not match +// ErrCapacityExceeded and ErrLLMGenerate. An engine admission rejection is +// discoverable as [CapacityError] and still matches ErrCapacityExceeded. It +// occurs before artifacts, schemas, rendering, or model generation because the +// selected backend's admission capacity is full; it does not match // ErrInvalidRequest or ErrLLMGenerate. Errors from injected clients remain // available through errors.Is. Cancellation while waiting for model-generation // capacity matches both ErrLLMGenerate and the context error. Cancellation @@ -635,9 +636,11 @@ func (e *Engine) Run(ctx context.Context, req RunRequest) (*RunResult, error) { // // RunPrepared can return ErrInvalidRequest, ErrAPIKeyEnvMissing, // ErrCapacityExceeded, ErrLLMGenerate, or ErrValidation as applicable while -// preserving documented collaborator and context identities. A completed -// content-validation rejection is returned in RunResult, not as an -// operational error. An operational error returns no partial RunResult. +// preserving documented collaborator and context identities. An engine +// admission rejection is discoverable as [CapacityError] and still matches +// ErrCapacityExceeded. A completed content-validation rejection is returned +// in RunResult, not as an operational error. An operational error returns no +// partial RunResult. func (e *Engine) RunPrepared(ctx context.Context, prepared *PreparedExecution) (*RunResult, error) { if e == nil || e.runner == nil { return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig) diff --git a/errors.go b/errors.go index 9957eed..0844431 100644 --- a/errors.go +++ b/errors.go @@ -3,6 +3,7 @@ package promptkit import ( "errors" "fmt" + "strings" "gitea.maximumdirect.net/eric/promptkit/internal/capacity" "gitea.maximumdirect.net/eric/promptkit/internal/profile" @@ -14,6 +15,11 @@ func mapPublicError(err error) error { if err == nil { return nil } + var internalCapacityError *usecase.CapacityError + if errors.As(err, &internalCapacityError) && internalCapacityError != nil && + strings.TrimSpace(internalCapacityError.BackendID) != "" { + return &CapacityError{BackendID: internalCapacityError.BackendID} + } publicErr := publicErrorFor(err) if publicErr == nil { return err diff --git a/errors_internal_test.go b/errors_internal_test.go index e3e1be6..7015890 100644 --- a/errors_internal_test.go +++ b/errors_internal_test.go @@ -20,3 +20,31 @@ func TestMapPublicErrorPreservesGenerationCancellation(t *testing.T) { t.Fatalf("mapped error=%v, want context.Canceled", err) } } + +func TestMapPublicErrorTranslatesCapacityError(t *testing.T) { + internalErr := &usecase.CapacityError{BackendID: "limited"} + + err := mapPublicError(internalErr) + var publicErr *CapacityError + if !errors.As(err, &publicErr) || publicErr == nil { + t.Fatalf("mapped error=%v, want public CapacityError", err) + } + if publicErr.BackendID != "limited" { + t.Fatalf("mapped backend ID=%q, want limited", publicErr.BackendID) + } + if !errors.Is(err, ErrCapacityExceeded) { + t.Fatalf("mapped error=%v, want ErrCapacityExceeded", err) + } + if errors.Is(err, ErrInvalidRequest) || errors.Is(err, ErrLLMGenerate) { + t.Fatalf("mapped capacity error has an unrelated category: %v", err) + } + var leakedInternalErr *usecase.CapacityError + if errors.As(err, &leakedInternalErr) { + t.Fatalf("mapped error exposes internal CapacityError: %v", err) + } + + internalErr.BackendID = "changed" + if publicErr.BackendID != "limited" { + t.Fatalf("mapped backend ID changed with source error: %q", publicErr.BackendID) + } +} diff --git a/prepared_execution_contract_test.go b/prepared_execution_contract_test.go index 0130c53..cbd3955 100644 --- a/prepared_execution_contract_test.go +++ b/prepared_execution_contract_test.go @@ -560,6 +560,11 @@ func TestPreparedExecutionCredentialCapacityAndTimingBoundaries(t *testing.T) { if result, err := engine.RunPrepared(context.Background(), prepared); result != nil || !errors.Is(err, promptkit.ErrCapacityExceeded) { t.Fatalf("capacity execution=(%+v, %v), want ErrCapacityExceeded", result, err) + } else { + var capacityErr *promptkit.CapacityError + if !errors.As(err, &capacityErr) || capacityErr == nil || capacityErr.BackendID != "limited" { + t.Fatalf("capacity execution=%v, want limited CapacityError", err) + } } if result, err := engine.RunPrepared(context.Background(), prepared); result != nil || !errors.Is(err, promptkit.ErrInvalidRequest) {