Implement layered timeout enforcement
This commit is contained in:
@@ -55,7 +55,7 @@ func NewOpenAICompatibleClient(cfg OpenAICompatibleConfig) (*OpenAICompatibleCli
|
||||
var client *http.Client
|
||||
if cfg.HTTPClient != nil {
|
||||
cloned := *cfg.HTTPClient
|
||||
if cloned.Timeout == 0 {
|
||||
if cloned.Timeout <= 0 {
|
||||
cloned.Timeout = timeout
|
||||
}
|
||||
client = &cloned
|
||||
@@ -99,7 +99,17 @@ func (c *OpenAICompatibleClient) Generate(ctx context.Context, req domain.Genera
|
||||
return nil, fmt.Errorf("%w: failed to encode request: %v", ErrRequestFailed, err)
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload))
|
||||
requestContext := ctx
|
||||
if req.Target.TimeoutSeconds > 0 {
|
||||
var cancel context.CancelFunc
|
||||
requestContext, cancel = context.WithTimeout(
|
||||
ctx,
|
||||
time.Duration(req.Target.TimeoutSeconds)*time.Second,
|
||||
)
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(requestContext, http.MethodPost, endpoint, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: failed to create request: %v", ErrRequestFailed, err)
|
||||
}
|
||||
@@ -118,18 +128,6 @@ func (c *OpenAICompatibleClient) Generate(ctx context.Context, req domain.Genera
|
||||
if httpClient == nil {
|
||||
httpClient = &http.Client{Timeout: defaults.LLMRequestTimeoutDefault}
|
||||
}
|
||||
if req.Target.TimeoutSeconds > 0 {
|
||||
effectiveTimeout := time.Duration(req.Target.TimeoutSeconds) * time.Second
|
||||
if httpClient.Timeout != effectiveTimeout {
|
||||
cloned := *httpClient
|
||||
cloned.Timeout = effectiveTimeout
|
||||
httpClient = &cloned
|
||||
}
|
||||
} else if req.TargetPresence.TimeoutSeconds && httpClient.Timeout != 0 {
|
||||
cloned := *httpClient
|
||||
cloned.Timeout = 0
|
||||
httpClient = &cloned
|
||||
}
|
||||
|
||||
httpResp, err := httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
|
||||
@@ -69,6 +69,36 @@ func TestNewOpenAICompatibleClientDoesNotMutateSuppliedNonzeroTimeoutClient(t *t
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewOpenAICompatibleClientTreatsSuppliedNegativeTimeoutAsUnset(t *testing.T) {
|
||||
transport := http.DefaultTransport
|
||||
supplied := &http.Client{
|
||||
Timeout: -time.Second,
|
||||
Transport: transport,
|
||||
}
|
||||
configuredTimeout := 23 * time.Second
|
||||
|
||||
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
|
||||
Timeout: configuredTimeout,
|
||||
HTTPClient: supplied,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected constructor error: %v", err)
|
||||
}
|
||||
|
||||
if supplied.Timeout != -time.Second {
|
||||
t.Fatalf("expected supplied client timeout to remain negative, got %v", supplied.Timeout)
|
||||
}
|
||||
if client.httpClient == supplied {
|
||||
t.Fatal("expected constructed client to use a cloned HTTP client")
|
||||
}
|
||||
if client.httpClient.Timeout != configuredTimeout {
|
||||
t.Fatalf("expected cloned client timeout %v, got %v", configuredTimeout, client.httpClient.Timeout)
|
||||
}
|
||||
if client.httpClient.Transport != transport {
|
||||
t.Fatal("expected cloned client to preserve the supplied transport")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAICompatibleClientGenerateSuccess(t *testing.T) {
|
||||
type observedRequest struct {
|
||||
Authorization string
|
||||
@@ -663,32 +693,6 @@ func TestOpenAICompatibleClientOmitsImplicitZeroNumericFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAICompatibleClientExplicitZeroTimeoutDisablesClientTimeout(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"}}]}`))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
|
||||
BaseURL: ts.URL + "/v1",
|
||||
Timeout: time.Nanosecond,
|
||||
HTTPClient: &http.Client{Timeout: time.Nanosecond},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err = client.Generate(context.Background(), domain.GenerateRequest{
|
||||
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}},
|
||||
Target: domain.ExecutionTarget{Model: "model", TimeoutSeconds: 0},
|
||||
TargetPresence: domain.ExecutionTargetPresence{TimeoutSeconds: true},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected explicit zero timeout to disable client timeout, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAICompatibleClientOmittedTimeoutUsesClientTimeout(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
@@ -716,34 +720,6 @@ func TestOpenAICompatibleClientOmittedTimeoutUsesClientTimeout(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAICompatibleClientSuppliedHTTPClientTimeoutOverridesConfigTimeout(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(25 * time.Millisecond)
|
||||
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"}}]}`))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
|
||||
BaseURL: ts.URL + "/v1",
|
||||
Model: "model",
|
||||
Timeout: 5 * time.Millisecond,
|
||||
HTTPClient: &http.Client{Timeout: 100 * time.Millisecond},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resp, err := client.Generate(context.Background(), domain.GenerateRequest{
|
||||
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected supplied client timeout to allow the request, got %v", err)
|
||||
}
|
||||
if resp.Content != "ok" {
|
||||
t.Fatalf("unexpected response content: %q", resp.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAICompatibleClientRejectsInvalidExtraParamsBeforeProviderCall(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -1029,35 +1005,6 @@ func TestOpenAICompatibleClientTimeout(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAICompatibleClientRequestTimeoutOverride(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"}}]}`))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
|
||||
BaseURL: ts.URL + "/v1",
|
||||
Model: "m",
|
||||
Timeout: 5 * time.Millisecond,
|
||||
HTTPClient: &http.Client{Timeout: 50 * time.Millisecond},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resp, err := client.Generate(context.Background(), domain.GenerateRequest{
|
||||
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}},
|
||||
Target: domain.ExecutionTarget{TimeoutSeconds: 1},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected request-level timeout override to succeed, got %v", err)
|
||||
}
|
||||
if resp.Content != "ok" {
|
||||
t.Fatalf("expected response content ok, got %q", resp.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAICompatibleClientNegativeTimeoutRejected(t *testing.T) {
|
||||
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
|
||||
BaseURL: "http://example.com/v1",
|
||||
|
||||
Reference in New Issue
Block a user