Implement layered timeout enforcement

This commit is contained in:
2026-07-26 18:31:06 -05:00
parent a0e905ce46
commit ed0c9f6370
12 changed files with 262 additions and 137 deletions

View File

@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"math"
"net/http"
"net/http/httptest"
@@ -14,6 +15,7 @@ import (
"strings"
"testing"
"testing/fstest"
"time"
"gitea.maximumdirect.net/eric/scriptorium"
)
@@ -1154,6 +1156,145 @@ func TestOpenAICompatibleProfileRunsThroughNormalProfilePath(t *testing.T) {
}
}
func TestEngineRunLayersTransportAndGenerationTimeouts(t *testing.T) {
intPointer := func(value int) *int {
return &value
}
tests := []struct {
name string
configTimeout time.Duration
suppliedClientTimeout time.Duration
profileTimeoutSeconds int
requestTimeoutSeconds *int
callerTimeout time.Duration
wantRemainingAtRequest time.Duration
}{
{
name: "positive supplied client cap takes precedence over config",
configTimeout: 2 * time.Second,
suppliedClientTimeout: 6 * time.Second,
wantRemainingAtRequest: 6 * time.Second,
},
{
name: "zero supplied client timeout inherits config cap",
configTimeout: 5 * time.Second,
wantRemainingAtRequest: 5 * time.Second,
},
{
name: "profile deadline is shorter than transport cap",
suppliedClientTimeout: 6 * time.Second,
profileTimeoutSeconds: 4,
wantRemainingAtRequest: 4 * time.Second,
},
{
name: "request deadline is shorter than profile and transport limits",
suppliedClientTimeout: 6 * time.Second,
profileTimeoutSeconds: 4,
requestTimeoutSeconds: intPointer(2),
wantRemainingAtRequest: 2 * time.Second,
},
{
name: "explicit zero removes generation deadline but retains transport cap",
suppliedClientTimeout: 5 * time.Second,
profileTimeoutSeconds: 2,
requestTimeoutSeconds: intPointer(0),
wantRemainingAtRequest: 5 * time.Second,
},
{
name: "framework default remains layered with shorter transport cap",
configTimeout: 7 * time.Second,
suppliedClientTimeout: 3 * time.Second,
wantRemainingAtRequest: 3 * time.Second,
},
{
name: "caller deadline remains layered with other limits",
suppliedClientTimeout: 6 * time.Second,
profileTimeoutSeconds: 4,
callerTimeout: 2 * time.Second,
wantRemainingAtRequest: 2 * time.Second,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var (
sawDeadline bool
remaining time.Duration
)
transport := roundTripFunc(func(req *http.Request) (*http.Response, error) {
deadline, ok := req.Context().Deadline()
sawDeadline = ok
if ok {
remaining = time.Until(deadline)
}
return &http.Response{
StatusCode: http.StatusOK,
Status: "200 OK",
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(
`{"choices":[{"message":{"content":"ok"}}]}`,
)),
Request: req,
}, nil
})
httpClient := &http.Client{
Timeout: tc.suppliedClientTimeout,
Transport: transport,
}
engine, err := scriptorium.NewEngine(scriptorium.Config{
PromptDir: "./examples/prompts",
SchemaDir: "./examples/schemas",
Timeout: tc.configTimeout,
HTTPClient: httpClient,
}, scriptorium.WithProfiles(scriptorium.Profile{
ID: "layered-timeout",
Endpoint: "http://timeout.test/v1",
Model: "timeout-model",
TimeoutSeconds: tc.profileTimeoutSeconds,
}))
if err != nil {
t.Fatalf("expected engine construction to succeed, got %v", err)
}
ctx := context.Background()
cancel := func() {}
if tc.callerTimeout > 0 {
ctx, cancel = context.WithTimeout(ctx, tc.callerTimeout)
}
defer cancel()
_, err = engine.Run(ctx, scriptorium.RunRequest{
PromptID: "generic.markdown_summary",
ProfileID: "layered-timeout",
Inputs: map[string]scriptorium.ArtifactRef{
"transcript": scriptorium.Inline("Rin opens the gate."),
"glossary": scriptorium.Inline("gate: A guarded passage."),
},
Execution: &scriptorium.ExecutionTargetOverride{
TimeoutSeconds: tc.requestTimeoutSeconds,
},
})
if err != nil {
t.Fatalf("expected run to succeed, got %v", err)
}
if !sawDeadline {
t.Fatal("expected outbound request context to have a deadline")
}
const deadlineTolerance = 750 * time.Millisecond
if remaining < tc.wantRemainingAtRequest-deadlineTolerance ||
remaining > tc.wantRemainingAtRequest+50*time.Millisecond {
t.Fatalf(
"unexpected request deadline: remaining=%v want approximately %v",
remaining,
tc.wantRemainingAtRequest,
)
}
})
}
}
func TestOpenAICompatibleProfileDefersExtraParamsValidation(t *testing.T) {
cyclic := map[string]any{}
cyclic["self"] = cyclic
@@ -1790,6 +1931,12 @@ type fakeLLMClient struct {
requests []scriptorium.GenerateRequest
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func (f *fakeLLMClient) Generate(_ context.Context, req scriptorium.GenerateRequest) (*scriptorium.GenerateResponse, error) {
f.requests = append(f.requests, req)
if f.err != nil {