Expose backend identity and capacity errors

This commit is contained in:
2026-08-29 14:26:04 +00:00
parent 06b37c2bae
commit 157209f097
11 changed files with 194 additions and 6 deletions

View File

@@ -11,6 +11,7 @@ import (
"path/filepath"
"reflect"
"strings"
"sync/atomic"
"testing"
"time"
@@ -69,6 +70,34 @@ func (handlerLLMClient) Generate(ctx context.Context, req promptkit.GenerateRequ
return &promptkit.GenerateResponse{Content: "ok"}, nil
}
type blockingLLMClient struct {
started chan struct{}
release chan struct{}
current int32
peak int32
}
func (c *blockingLLMClient) Generate(ctx context.Context, req promptkit.GenerateRequest) (*promptkit.GenerateResponse, error) {
current := atomic.AddInt32(&c.current, 1)
defer atomic.AddInt32(&c.current, -1)
for {
peak := atomic.LoadInt32(&c.peak)
if current <= peak || atomic.CompareAndSwapInt32(&c.peak, peak, current) {
break
}
}
select {
case c.started <- struct{}{}:
default:
}
select {
case <-c.release:
return &promptkit.GenerateResponse{Content: "ok"}, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
func TestHandlerPostRunsSuccessWithExplicitProfileID(t *testing.T) {
start := time.Now().UTC()
end := start.Add(2 * time.Second)
@@ -90,9 +119,11 @@ func TestHandlerPostRunsSuccessWithExplicitProfileID(t *testing.T) {
PromptHash: "phash",
RenderedPromptHash: "rhash",
SelectedProfileID: "exec-default",
SelectedBackendID: "local",
ModelName: "m1",
Endpoint: "http://llm/v1",
EffectiveModelParams: promptkit.ExecutionTarget{
BackendID: "local",
Endpoint: "http://llm/v1",
Model: "m1",
Temperature: 0.2,
@@ -151,6 +182,9 @@ func TestHandlerPostRunsSuccessWithExplicitProfileID(t *testing.T) {
if metadata["selected_profile_id"] != "exec-default" {
t.Fatalf("unexpected metadata.selected_profile_id: %#v", metadata["selected_profile_id"])
}
if metadata["selected_backend_id"] != "local" {
t.Fatalf("unexpected metadata.selected_backend_id: %#v", metadata["selected_backend_id"])
}
if metadata["model_name"] != "m1" || metadata["endpoint"] != "http://llm/v1" {
t.Fatalf("unexpected model metadata: name=%#v endpoint=%#v", metadata["model_name"], metadata["endpoint"])
}
@@ -162,6 +196,9 @@ func TestHandlerPostRunsSuccessWithExplicitProfileID(t *testing.T) {
t.Fatalf("unexpected cache usage metadata: %#v", usage)
}
modelParams := metadata["model_params"].(map[string]any)
if modelParams["backend_id"] != "local" {
t.Fatalf("expected model_params.backend_id=local, got %#v", modelParams["backend_id"])
}
if modelParams["api_key_env"] != envName {
t.Fatalf("expected model_params.api_key_env=%q, got %#v", envName, modelParams["api_key_env"])
}
@@ -664,6 +701,79 @@ output:
}
}
func TestHandlerSharesBackendCapacityAcrossConcurrentRequests(t *testing.T) {
promptDir := t.TempDir()
profileDir := t.TempDir()
if err := os.WriteFile(filepath.Join(promptDir, "prompt.yaml"), []byte(`id: p
version: "1"
default_profile: limited
messages:
- role: user
content: "hi"
output:
format: text
validation_mode: none
`), 0o644); err != nil {
t.Fatalf("write prompt fixture: %v", err)
}
if err := os.WriteFile(filepath.Join(profileDir, "profile.yaml"), []byte(`id: limited
backend: limited
model: test
`), 0o644); err != nil {
t.Fatalf("write profile fixture: %v", err)
}
queueCapacity := 0
client := &blockingLLMClient{started: make(chan struct{}, 1), release: make(chan struct{})}
engine, err := promptkit.NewEngine(
promptkit.Config{PromptDir: promptDir, ProfileDir: profileDir},
promptkit.WithBackend(promptkit.Backend{
ID: "limited",
Endpoint: "http://127.0.0.1:1/v1",
ConcurrencyLimit: 1,
QueueCapacity: &queueCapacity,
}),
promptkit.WithLLMClient(client),
)
if err != nil {
t.Fatalf("new engine: %v", err)
}
h := NewHandler(engine)
first := httptest.NewRecorder()
firstDone := make(chan struct{})
go func() {
defer close(firstDone)
h.ServeHTTP(first, httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{"prompt_id":"p"}`)))
}()
select {
case <-client.started:
case <-time.After(time.Second):
t.Fatal("first request did not reach generation")
}
second := httptest.NewRecorder()
h.ServeHTTP(second, httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{"prompt_id":"p"}`)))
assertHTTPErrorCode(t, second, http.StatusServiceUnavailable, "capacity_exceeded")
if second.Header().Get("Retry-After") != "" {
t.Fatalf("expected no Retry-After header, got %q", second.Header().Get("Retry-After"))
}
if strings.Contains(second.Body.String(), "limited") {
t.Fatalf("capacity response leaked backend details: %s", second.Body.String())
}
close(client.release)
select {
case <-firstDone:
case <-time.After(time.Second):
t.Fatal("first request did not complete")
}
if first.Code != http.StatusOK {
t.Fatalf("expected first request to succeed, got %d body=%s", first.Code, first.Body.String())
}
if atomic.LoadInt32(&client.peak) != 1 {
t.Fatalf("expected peak generation concurrency of one, got %d", atomic.LoadInt32(&client.peak))
}
}
func TestHandlerRejectsOverlongSessionIDAndNonStringReasoningEffort(t *testing.T) {
engine := newHandlerEngine(t)
for _, body := range []string{
@@ -1014,6 +1124,7 @@ func TestHandlerPublicErrorMapping(t *testing.T) {
{name: "file too large", err: ErrFileTooLarge, status: http.StatusRequestEntityTooLarge, code: "artifact_too_large", message: "file input artifact is too large"},
{name: "artifact", err: wrap(promptkit.ErrArtifactLoad, fmt.Errorf("read failed")), status: http.StatusBadRequest, code: "artifact_read_failed", message: "failed to read input artifact", avoidCause: "read failed"},
{name: "prompt render", err: wrap(promptkit.ErrPromptRender, fmt.Errorf("render failed")), status: http.StatusBadRequest, code: "prompt_render_failed", message: "failed to render prompt", avoidCause: "render failed"},
{name: "capacity", err: &promptkit.CapacityError{BackendID: "private-backend"}, status: http.StatusServiceUnavailable, code: "capacity_exceeded", message: "model backend capacity is exhausted", avoidCause: "private-backend"},
{name: "llm", err: wrap(promptkit.ErrLLMGenerate, fmt.Errorf("llm failed")), status: http.StatusBadGateway, code: "llm_failed", message: "model generation request failed", avoidCause: "llm failed"},
{name: "validation runtime", err: wrap(promptkit.ErrValidation, fmt.Errorf("validator broke")), status: http.StatusInternalServerError, code: "validation_runtime_failed", message: "validation runtime failed", avoidCause: "validator broke"},
}