Add internal capacity error identity
This commit is contained in:
262
docs/roadmap/capacity-errors.md
Normal file
262
docs/roadmap/capacity-errors.md
Normal 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.
|
||||||
@@ -33,18 +33,9 @@ consumers.
|
|||||||
|
|
||||||
## Ideas
|
## Ideas
|
||||||
|
|
||||||
### Structured capacity errors
|
Structured capacity errors have been selected for active planning in the
|
||||||
|
[focused feature roadmap](capacity-errors.md). No other ideas currently await
|
||||||
Add safe structured context to backend admission rejection, as requested by
|
selection.
|
||||||
[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.
|
|
||||||
|
|
||||||
## Entry Format
|
## Entry Format
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -206,7 +206,7 @@ resolution.
|
|||||||
## Priority 4: Structured Capacity Errors
|
## Priority 4: Structured Capacity Errors
|
||||||
|
|
||||||
**Disposition:** Accepted into the
|
**Disposition:** Accepted into the
|
||||||
[future catalog](future.md#structured-capacity-errors).
|
[structured capacity errors](capacity-errors.md) feature roadmap.
|
||||||
|
|
||||||
### Downstream need
|
### Downstream need
|
||||||
|
|
||||||
|
|||||||
@@ -283,7 +283,7 @@ the existing sentinel remain available.
|
|||||||
### Structured Capacity Errors
|
### Structured Capacity Errors
|
||||||
|
|
||||||
**Disposition:** Accepted into the
|
**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
|
The typed capacity error proposed by the
|
||||||
[Notarius wishlist](notarius-promptkit-wishlist.md#priority-4-structured-capacity-errors)
|
[Notarius wishlist](notarius-promptkit-wishlist.md#priority-4-structured-capacity-errors)
|
||||||
|
|||||||
@@ -124,6 +124,11 @@ func TestManagerAdmissionHonorsContextAndUnlimitedBackends(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("construct manager: %v", err)
|
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())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
cancel()
|
cancel()
|
||||||
|
|||||||
24
internal/usecase/capacity_error.go
Normal file
24
internal/usecase/capacity_error.go
Normal 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
|
||||||
|
}
|
||||||
@@ -390,8 +390,8 @@ func (r *Runner) admitRun(ctx context.Context, backendID string) (func(), error)
|
|||||||
}
|
}
|
||||||
release, err := r.admitter.Admit(ctx, backendID)
|
release, err := r.admitter.Admit(ctx, backendID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, capacity.ErrCapacityExceeded) {
|
if errors.Is(err, capacity.ErrCapacityExceeded) && strings.TrimSpace(backendID) != "" {
|
||||||
return nil, fmt.Errorf("backend %q admission: %w", backendID, err)
|
return nil, &CapacityError{BackendID: backendID}
|
||||||
}
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1357,14 +1357,14 @@ func TestRunnerAdmissionUsesResolvedBackendIdentity(t *testing.T) {
|
|||||||
|
|
||||||
func TestRunnerAdmissionFailureSkipsCompletionCollaborators(t *testing.T) {
|
func TestRunnerAdmissionFailureSkipsCompletionCollaborators(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
admissionError error
|
admissionError error
|
||||||
wantBackendContext bool
|
wantCapacityType bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "capacity exhausted",
|
name: "capacity exhausted",
|
||||||
admissionError: capacity.ErrCapacityExceeded,
|
admissionError: capacity.ErrCapacityExceeded,
|
||||||
wantBackendContext: true,
|
wantCapacityType: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "context canceled",
|
name: "context canceled",
|
||||||
@@ -1414,8 +1414,16 @@ func TestRunnerAdmissionFailureSkipsCompletionCollaborators(t *testing.T) {
|
|||||||
if errors.Is(err, ErrInvalidRequest) || errors.Is(err, ErrLLMGenerate) {
|
if errors.Is(err, ErrInvalidRequest) || errors.Is(err, ErrLLMGenerate) {
|
||||||
t.Fatalf("admission error was recategorized: %v", err)
|
t.Fatalf("admission error was recategorized: %v", err)
|
||||||
}
|
}
|
||||||
if tc.wantBackendContext && !strings.Contains(err.Error(), "custom") {
|
var capacityErr *CapacityError
|
||||||
t.Fatalf("capacity error lacks backend context: %v", err)
|
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"}) {
|
if !reflect.DeepEqual(admitter.backendIDs, []string{"custom"}) {
|
||||||
t.Fatalf("admitted backend IDs=%#v, want custom", admitter.backendIDs)
|
t.Fatalf("admitted backend IDs=%#v, want custom", admitter.backendIDs)
|
||||||
|
|||||||
Reference in New Issue
Block a user