Expose structured capacity errors
This commit is contained in:
@@ -60,7 +60,18 @@ func TestEngineRejectsRunBeforeCompletionWhenAdmissionIsFull(t *testing.T) {
|
|||||||
|
|
||||||
awaitCapacitySignal(t, reader.entered, "first artifact read")
|
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 {
|
if result != nil {
|
||||||
t.Fatalf("capacity rejection returned partial result: %+v", result)
|
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) {
|
if errors.Is(err, promptkit.ErrInvalidRequest) || errors.Is(err, promptkit.ErrLLMGenerate) {
|
||||||
t.Fatalf("capacity rejection had an unrelated category: %v", err)
|
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 {
|
if calls := reader.callCount(); calls != 1 {
|
||||||
t.Fatalf("artifact calls=%d, want only the admitted run", calls)
|
t.Fatalf("artifact calls=%d, want only the admitted run", calls)
|
||||||
}
|
}
|
||||||
@@ -174,6 +200,19 @@ func TestCapacityExceededSentinelContract(t *testing.T) {
|
|||||||
if promptkit.ErrCapacityExceeded == nil {
|
if promptkit.ErrCapacityExceeded == nil {
|
||||||
t.Fatal("ErrCapacityExceeded is 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{
|
for _, unrelated := range []error{
|
||||||
promptkit.ErrInvalidConfig,
|
promptkit.ErrInvalidConfig,
|
||||||
promptkit.ErrInvalidRequest,
|
promptkit.ErrInvalidRequest,
|
||||||
@@ -181,7 +220,8 @@ func TestCapacityExceededSentinelContract(t *testing.T) {
|
|||||||
promptkit.ErrValidation,
|
promptkit.ErrValidation,
|
||||||
} {
|
} {
|
||||||
if errors.Is(promptkit.ErrCapacityExceeded, unrelated) ||
|
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)
|
t.Fatalf("ErrCapacityExceeded aliases unrelated sentinel %v", unrelated)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
40
capacity_error.go
Normal file
40
capacity_error.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
13
doc.go
13
doc.go
@@ -23,7 +23,8 @@
|
|||||||
// InspectProfile return copied inspection values. Returned values and values
|
// InspectProfile return copied inspection values. Returned values and values
|
||||||
// passed to extension interfaces are likewise isolated from engine state.
|
// passed to extension interfaces are likewise isolated from engine state.
|
||||||
// Callers own those copies and may mutate them after the call that supplied or
|
// 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
|
// # Security and sensitive data
|
||||||
//
|
//
|
||||||
@@ -49,12 +50,12 @@
|
|||||||
// [GenerateResponse], [ExecutionTargetPresence], and the string value types
|
// [GenerateResponse], [ExecutionTargetPresence], and the string value types
|
||||||
// used by those values.
|
// used by those values.
|
||||||
//
|
//
|
||||||
// Construction, inspection, and handle values, including [Config], [Backend],
|
// Construction, inspection, handle, and error values, including [Config],
|
||||||
// [RunRequest], [ArtifactRef], [ExecutionTargetOverride], [Profile],
|
// [Backend], [RunRequest], [ArtifactRef], [ExecutionTargetOverride], [Profile],
|
||||||
// [OpenAICompatibleProfileConfig], [ProfileInspection],
|
// [OpenAICompatibleProfileConfig], [ProfileInspection],
|
||||||
// [PromptInputDefinition], [PromptInspection], and [PreparedExecution], do
|
// [PromptInputDefinition], [PromptInspection], [PreparedExecution], and
|
||||||
// not have stable JSON representations. Direct API keys are nevertheless
|
// [CapacityError], do not have stable JSON representations. Direct API keys
|
||||||
// excluded from JSON for every public value.
|
// are nevertheless excluded from JSON for every public value.
|
||||||
//
|
//
|
||||||
// JSON timestamps use time.Time's RFC 3339 encoding and are omitted when zero.
|
// JSON timestamps use time.Time's RFC 3339 encoding and are omitted when zero.
|
||||||
// PreparedRun and RunResult durations are encoded as integer milliseconds in
|
// PreparedRun and RunResult durations are encoded as integer milliseconds in
|
||||||
|
|||||||
19
engine.go
19
engine.go
@@ -65,8 +65,8 @@ var (
|
|||||||
ErrPromptRender = errors.New("failed to render prompt")
|
ErrPromptRender = errors.New("failed to render prompt")
|
||||||
// ErrCapacityExceeded identifies a Run or RunPrepared rejected because the
|
// ErrCapacityExceeded identifies a Run or RunPrepared rejected because the
|
||||||
// selected backend already admitted ConcurrencyLimit + QueueCapacity calls.
|
// selected backend already admitted ConcurrencyLimit + QueueCapacity calls.
|
||||||
// It is not an invalid request, an LLM or provider rate-limit response, or
|
// A [CapacityError] reports the selected backend ID. It is not an invalid
|
||||||
// ErrLLMGenerate.
|
// request, an LLM or provider rate-limit response, or ErrLLMGenerate.
|
||||||
ErrCapacityExceeded = errors.New("backend capacity exceeded")
|
ErrCapacityExceeded = errors.New("backend capacity exceeded")
|
||||||
// ErrLLMGenerate identifies a model-client failure or a nil successful
|
// ErrLLMGenerate identifies a model-client failure or a nil successful
|
||||||
// response. Errors returned by an injected LLMClient remain available
|
// 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.
|
// single-pass even when OutputContract.RepairAttempts is positive.
|
||||||
//
|
//
|
||||||
// Run can return every error category documented by [Engine.Prepare], plus
|
// Run can return every error category documented by [Engine.Prepare], plus
|
||||||
// ErrCapacityExceeded and ErrLLMGenerate. ErrCapacityExceeded identifies
|
// ErrCapacityExceeded and ErrLLMGenerate. An engine admission rejection is
|
||||||
// rejection before artifacts, schemas, rendering, or model generation because
|
// discoverable as [CapacityError] and still matches ErrCapacityExceeded. It
|
||||||
// the selected backend's admission capacity is full; it does not match
|
// 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
|
// ErrInvalidRequest or ErrLLMGenerate. Errors from injected clients remain
|
||||||
// available through errors.Is. Cancellation while waiting for model-generation
|
// available through errors.Is. Cancellation while waiting for model-generation
|
||||||
// capacity matches both ErrLLMGenerate and the context error. Cancellation
|
// 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,
|
// RunPrepared can return ErrInvalidRequest, ErrAPIKeyEnvMissing,
|
||||||
// ErrCapacityExceeded, ErrLLMGenerate, or ErrValidation as applicable while
|
// ErrCapacityExceeded, ErrLLMGenerate, or ErrValidation as applicable while
|
||||||
// preserving documented collaborator and context identities. A completed
|
// preserving documented collaborator and context identities. An engine
|
||||||
// content-validation rejection is returned in RunResult, not as an
|
// admission rejection is discoverable as [CapacityError] and still matches
|
||||||
// operational error. An operational error returns no partial RunResult.
|
// 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) {
|
func (e *Engine) RunPrepared(ctx context.Context, prepared *PreparedExecution) (*RunResult, error) {
|
||||||
if e == nil || e.runner == nil {
|
if e == nil || e.runner == nil {
|
||||||
return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig)
|
return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package promptkit
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/capacity"
|
"gitea.maximumdirect.net/eric/promptkit/internal/capacity"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/profile"
|
"gitea.maximumdirect.net/eric/promptkit/internal/profile"
|
||||||
@@ -14,6 +15,11 @@ func mapPublicError(err error) error {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
return 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)
|
publicErr := publicErrorFor(err)
|
||||||
if publicErr == nil {
|
if publicErr == nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -20,3 +20,31 @@ func TestMapPublicErrorPreservesGenerationCancellation(t *testing.T) {
|
|||||||
t.Fatalf("mapped error=%v, want context.Canceled", err)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -560,6 +560,11 @@ func TestPreparedExecutionCredentialCapacityAndTimingBoundaries(t *testing.T) {
|
|||||||
if result, err := engine.RunPrepared(context.Background(), prepared); result != nil ||
|
if result, err := engine.RunPrepared(context.Background(), prepared); result != nil ||
|
||||||
!errors.Is(err, promptkit.ErrCapacityExceeded) {
|
!errors.Is(err, promptkit.ErrCapacityExceeded) {
|
||||||
t.Fatalf("capacity execution=(%+v, %v), want ErrCapacityExceeded", result, err)
|
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 ||
|
if result, err := engine.RunPrepared(context.Background(), prepared); result != nil ||
|
||||||
!errors.Is(err, promptkit.ErrInvalidRequest) {
|
!errors.Is(err, promptkit.ErrInvalidRequest) {
|
||||||
|
|||||||
Reference in New Issue
Block a user