Add internal capacity error identity

This commit is contained in:
2026-07-30 23:21:27 +00:00
parent 25a7052a3d
commit 63c67a4520
9 changed files with 681 additions and 565 deletions

View File

@@ -0,0 +1,262 @@
# Structured Capacity Errors
**Status:** Accepted.
## Purpose
Allow consumers to identify which registered backend rejected engine
admission without parsing an error string, while preserving the existing
`ErrCapacityExceeded` classification and Promptkit's provider-neutral error
boundary.
This provides safe operational context requested by
[Notarius](notarius-promptkit-wishlist.md#priority-4-structured-capacity-errors)
and
[Weatherreporter](weatherreporter-promptkit-wishlist.md#structured-capacity-errors).
## Motivation
Promptkit currently returns an error matching `ErrCapacityExceeded` when a
limited backend's active and waiting admission capacity is full. That sentinel
lets consumers classify the failure reliably, but the selected backend ID is
available only in diagnostic text.
Applications using multiple backends need the stable backend ID for
operator-facing diagnostics, metrics labels, failure receipts, and their own
retry decisions. Parsing `Error()` text would turn non-contractual wording
into an accidental API and risks retaining more diagnostic context than the
application needs.
Promptkit already owns backend selection and the admission boundary. It should
attach the selected backend's safe identity at that boundary and translate it
into a small public typed error.
## Consumer Workflow
The target public workflow is:
```go
result, err := engine.Run(ctx, request)
if err != nil {
var capacityErr *promptkit.CapacityError
if errors.As(err, &capacityErr) {
// Record capacityErr.BackendID or apply application retry policy.
}
}
```
Existing classification remains valid:
```go
if errors.Is(err, promptkit.ErrCapacityExceeded) {
// Handle engine admission rejection without structured context.
}
```
The target public surface is:
```go
type CapacityError struct {
BackendID string
}
func (e *CapacityError) Error() string
func (e *CapacityError) Unwrap() error
```
`Unwrap` returns `ErrCapacityExceeded`. Engine-produced capacity errors
therefore support both `errors.As` to `*CapacityError` and
`errors.Is(err, ErrCapacityExceeded)`, including through additional wrapping.
The exported declaration and GoDoc will own the exact implemented contract.
`CapacityError` does not need a stable JSON representation, an exported
constructor, or fields beyond `BackendID`.
## Error Boundary
A `CapacityError` represents one specific condition: `Run` or `RunPrepared`
was rejected at Promptkit's engine-local bounded admission boundary because
the selected limited backend had no remaining admitted slot.
It does not represent:
- waiting for an active-generation permit after admission;
- caller cancellation or deadline expiration while acquiring or holding
capacity;
- provider rate limiting, quota exhaustion, HTTP 429 responses, or another
model-client failure;
- validation, output repair, or application-level queue rejection; or
- an application's decision not to submit or retry work.
Provider and injected-client errors retain their existing generation error
classification even when a provider describes its failure as capacity or rate
limiting. Promptkit must not infer an engine admission error from provider
status codes or error text.
Admission continues to check caller cancellation before reporting a full
pool. A cancellation that wins that decision remains a context-related
execution error and does not become a `CapacityError`.
## Backend Identity
`BackendID` is the normalized stable ID of the registered backend whose pool
rejected admission. It is the same effective backend identity used for
capacity routing and exposed by prepared and run metadata.
For ordinary `Run`, the ID comes from the backend selected by the resolved
prompt, profile, and execution target. For `RunPrepared`, it comes from the
backend identity frozen in the prepared execution.
An endpoint override does not change backend identity and therefore does not
change the reported `BackendID`. Endpoint-only profiles and unlimited
backends have no bounded admission pool and cannot produce an engine-generated
`CapacityError`.
Every engine-produced `CapacityError` has a nonblank registered backend ID.
The type does not report a profile ID, prompt ID, model, endpoint, run ID, or
prepared-handle identity.
## Compatibility
`ErrCapacityExceeded` remains the stable sentinel for broad classification.
This feature refines the error value returned by capacity-rejected `Run` and
`RunPrepared` calls without replacing or removing that sentinel.
The following behaviors remain unchanged:
- `errors.Is(err, ErrCapacityExceeded)` succeeds for engine admission
rejection;
- capacity rejection remains distinct from `ErrInvalidRequest`,
`ErrLLMGenerate`, and other public categories;
- `Run` returns no partial result after admission rejection;
- `RunPrepared` retains its existing one-attempt lifecycle, including that a
claimed handle is consumed when admission is rejected;
- admission ordering, limits, queue capacity, leases, release behavior, and
backend independence are unchanged; and
- `Prepare`, `PrepareExecution`, `InspectPrompt`, and `InspectProfile` perform
no admission and cannot return this error.
Direct equality with the sentinel is not introduced as a contract. Consumers
continue to use `errors.Is` for category checks and `errors.As` for structured
context.
## Sensitive Data And Diagnostics
The typed error contains only `BackendID`. It must not expose or retain:
- backend endpoints;
- credential environment-variable names or credential values;
- model, profile, prompt, session, or request identifiers;
- prompt, input, rendered, generated, or validation content;
- concurrency limits, queue capacities, current counts, or queue depth;
- request position or identities of other admitted work; or
- retry timing, provider health, or speculative availability.
`Error()` may include the quoted backend ID and a concise admission-rejection
description. Its wording is diagnostic and not a stable parsing contract.
Default formatting, wrapping, and Go string formatting must not reveal
anything beyond the public field and generic capacity classification.
Concurrency and queue configuration remain available where consumers define
their backends; repeating them on each error would add stale or unnecessary
operational detail. Current counts are race-prone observations rather than a
durable explanation of when capacity will become available.
## Retry And Application Policy
Promptkit reports that admission was rejected but does not retry, wait for a
new admission slot, calculate backoff, or declare an HTTP status.
A full in-process admission pool does not provide a meaningful retry delay:
lease duration depends on consumer inputs, model latency, validation, repair,
cancellation, and provider behavior. Consumers own whether to retry, which
backend to use, how to schedule or back off, what to expose to operators, and
how to translate the error into CLI or transport behavior.
The absence of retry metadata is deliberate. A future retry facility would
require its own roadmap and application-neutral policy rather than extending
this error speculatively.
## Ownership And Concurrency
Each rejected operation returns its own caller-owned `*CapacityError`. The
error does not reference mutable pool state or retain the request. Reading it
requires no engine access and remains safe after the rejected call returns.
Mutating the exported `BackendID` on a returned error affects only that error
value. It cannot change the backend registry, capacity manager, another
operation's error, or later routing and admission.
Constructing or returning the typed error requires no new process-global
state, cache, goroutine, or synchronization beyond existing capacity
management.
## Architectural Boundaries
The root `promptkit` package owns the exported error type, sentinel
compatibility, and public translation. The engine's internal capacity and
use-case components remain responsible for admission and for carrying the
selected backend identity to the facade.
Internal error types or capacity-manager state must not cross the public
boundary. The public facade must obtain structured identity through an
internal typed or otherwise non-textual error contract; it must not parse the
runner's diagnostic string.
The feature does not introduce a public capacity manager, backend registry,
queue, scheduler, metrics collector, or transport-specific error. It remains a
small refinement of the existing root error boundary.
## Documentation
When implemented, documentation should retain these ownership boundaries:
- the exported declaration and GoDoc own the exact type, field, `Error`,
`Unwrap`, `errors.Is`, `errors.As`, and formatting contracts;
- the consumer guide demonstrates broad classification and optional backend
extraction without duplicating every error guarantee;
- internal capacity and runner documentation describe where backend identity
is attached and translated;
- the backend API continues to own capacity configuration; and
- the architecture and documentation policies continue to own library,
application, and documentation boundaries.
## Non-Goals
This work does not include:
- changing concurrency limits, queue capacities, FIFO scheduling, or default
capacity policy;
- exposing configured limits, current counts, queue depth, or waiter
positions;
- reporting a retry delay, retryable boolean, HTTP status, CLI exit code, or
backoff policy;
- automatically retrying or rerouting rejected work;
- unifying provider rate limits or quota failures with engine admission;
- adding structured context to generation, validation, prompt, profile, or
other error categories;
- returning partial preparation or run results with the error;
- changing prepared-execution handle lifecycle;
- adding stable JSON for errors;
- exposing internal capacity or use-case types; or
- adding application logging, metrics emission, persistence, or failure
receipts.
## Target End State
After this work:
- every engine admission rejection from `Run` or `RunPrepared` returns an
error discoverable as `*CapacityError`;
- the typed error contains the normalized selected backend ID and no other
structured field;
- existing `errors.Is(err, ErrCapacityExceeded)` handling continues to work;
- `errors.As` obtains backend identity without parsing diagnostic text;
- capacity rejection remains distinct from cancellation, model-client
failures, provider throttling, and application scheduling;
- endpoints, credentials, request content, capacity metrics, and speculative
retry information remain absent;
- retry, backoff, rerouting, metrics, persistence, and transport mapping remain
consumer responsibilities; and
- the existing capacity architecture and runtime behavior are otherwise
unchanged.

View File

@@ -33,18 +33,9 @@ consumers.
## Ideas
### Structured capacity errors
Add safe structured context to backend admission rejection, as requested by
[Notarius](notarius-promptkit-wishlist.md#priority-4-structured-capacity-errors)
and
[Weatherreporter](weatherreporter-promptkit-wishlist.md#structured-capacity-errors).
- Preserve compatibility with `errors.Is(err, ErrCapacityExceeded)`.
- Support `errors.As` to obtain the stable backend ID.
- Do not expose endpoints, credential configuration or values, request
content, or speculative retry timing.
- Keep retry and backoff policy with downstream consumers.
Structured capacity errors have been selected for active planning in the
[focused feature roadmap](capacity-errors.md). No other ideas currently await
selection.
## Entry Format

File diff suppressed because it is too large Load Diff

View File

@@ -206,7 +206,7 @@ resolution.
## Priority 4: Structured Capacity Errors
**Disposition:** Accepted into the
[future catalog](future.md#structured-capacity-errors).
[structured capacity errors](capacity-errors.md) feature roadmap.
### Downstream need

View File

@@ -283,7 +283,7 @@ the existing sentinel remain available.
### Structured Capacity Errors
**Disposition:** Accepted into the
[future catalog](future.md#structured-capacity-errors).
[structured capacity errors](capacity-errors.md) feature roadmap.
The typed capacity error proposed by the
[Notarius wishlist](notarius-promptkit-wishlist.md#priority-4-structured-capacity-errors)

View File

@@ -124,6 +124,11 @@ func TestManagerAdmissionHonorsContextAndUnlimitedBackends(t *testing.T) {
if err != nil {
t.Fatalf("construct manager: %v", err)
}
release, err := manager.Admit(context.Background(), "limited")
if err != nil {
t.Fatalf("fill limited pool: %v", err)
}
defer release()
ctx, cancel := context.WithCancel(context.Background())
cancel()

View File

@@ -0,0 +1,24 @@
package usecase
import (
"fmt"
"strings"
"gitea.maximumdirect.net/eric/promptkit/internal/capacity"
)
// CapacityError identifies bounded admission rejected for one selected backend.
type CapacityError struct {
BackendID string
}
func (e *CapacityError) Error() string {
if e == nil || strings.TrimSpace(e.BackendID) == "" {
return capacity.ErrCapacityExceeded.Error()
}
return fmt.Sprintf("backend %q admission: %v", e.BackendID, capacity.ErrCapacityExceeded)
}
func (e *CapacityError) Unwrap() error {
return capacity.ErrCapacityExceeded
}

View File

@@ -390,8 +390,8 @@ func (r *Runner) admitRun(ctx context.Context, backendID string) (func(), error)
}
release, err := r.admitter.Admit(ctx, backendID)
if err != nil {
if errors.Is(err, capacity.ErrCapacityExceeded) {
return nil, fmt.Errorf("backend %q admission: %w", backendID, err)
if errors.Is(err, capacity.ErrCapacityExceeded) && strings.TrimSpace(backendID) != "" {
return nil, &CapacityError{BackendID: backendID}
}
return nil, err
}

View File

@@ -1357,14 +1357,14 @@ func TestRunnerAdmissionUsesResolvedBackendIdentity(t *testing.T) {
func TestRunnerAdmissionFailureSkipsCompletionCollaborators(t *testing.T) {
tests := []struct {
name string
admissionError error
wantBackendContext bool
name string
admissionError error
wantCapacityType bool
}{
{
name: "capacity exhausted",
admissionError: capacity.ErrCapacityExceeded,
wantBackendContext: true,
name: "capacity exhausted",
admissionError: capacity.ErrCapacityExceeded,
wantCapacityType: true,
},
{
name: "context canceled",
@@ -1414,8 +1414,16 @@ func TestRunnerAdmissionFailureSkipsCompletionCollaborators(t *testing.T) {
if errors.Is(err, ErrInvalidRequest) || errors.Is(err, ErrLLMGenerate) {
t.Fatalf("admission error was recategorized: %v", err)
}
if tc.wantBackendContext && !strings.Contains(err.Error(), "custom") {
t.Fatalf("capacity error lacks backend context: %v", err)
var capacityErr *CapacityError
if tc.wantCapacityType {
if !errors.As(err, &capacityErr) {
t.Fatalf("capacity error=%v, want internal typed identity", err)
}
if capacityErr.BackendID != "custom" {
t.Fatalf("capacity backend ID=%q, want custom", capacityErr.BackendID)
}
} else if errors.As(err, &capacityErr) {
t.Fatalf("non-capacity admission error exposed typed capacity identity: %v", err)
}
if !reflect.DeepEqual(admitter.backendIDs, []string{"custom"}) {
t.Fatalf("admitted backend IDs=%#v, want custom", admitter.backendIDs)