Implement layered timeout enforcement
This commit is contained in:
147
engine_test.go
147
engine_test.go
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user