mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-20 05:56:56 +00:00
feat(providers): add progress-based upstream request timeout
Adapters previously issued upstream requests with no timeout at all. A provider that accepted a connection and then stalled hung the gateway request forever. newAdapterHTTPClient now sets Transport.ResponseHeaderTimeout, and every response body read (providers.go's upstreamBytes, routes_passthrough.go's proxyUpstream) runs through a watchdogBody that resets on every successful read and cancels the request once timeout passes with no progress. The idle-progress design lets a long stream that keeps emitting tokens run indefinitely. Only silence, never total duration, ends a request. Config.RequestTimeout sets the global default (five minutes when unset). ProviderConfig.RequestTimeout overrides it per provider. Both are Go duration strings, validated at construction; a zero or negative value is rejected rather than treated as unlimited, since that would reintroduce the hang this feature fixes. Native passthrough (routes_passthrough.go) and the MCP/A2A target proxy (mcp_a2a.go) share the identical watchdog mechanism through proxyUpstream's new timeout parameter. A watchdog-fired timeout wraps errProviderTimeout and names the provider. It never wraps context.Canceled, so it stays distinguishable from a genuine client disconnect in every existing log classification.
This commit is contained in:
+67
-21
@@ -11,6 +11,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"runtime"
|
"runtime"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
telemetry "github.com/lukaszraczylo/oss-telemetry"
|
telemetry "github.com/lukaszraczylo/oss-telemetry"
|
||||||
)
|
)
|
||||||
@@ -24,26 +25,6 @@ import (
|
|||||||
// original doc comment) — see providerState's own doc comment (registry.go)
|
// original doc comment) — see providerState's own doc comment (registry.go)
|
||||||
// for the general pointer-first/scalar-last convention this follows.
|
// for the general pointer-first/scalar-last convention this follows.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Redis *RedisConfig `json:"redis,omitempty"`
|
|
||||||
// Admin gates the read-only admin dashboard (spec §4, v0.2): nil or
|
|
||||||
// Admin.Enabled false means the /admin* routes are not registered at
|
|
||||||
// all — ServeHTTP falls through to its existing 404/passthroughUnknown
|
|
||||||
// handling for those paths, preserving v0.1 behavior exactly.
|
|
||||||
Admin *AdminConfig `json:"admin,omitempty"`
|
|
||||||
Pricing map[string]*ModelPricing `json:"pricing,omitempty"`
|
|
||||||
MCPServers map[string]*TargetConfig `json:"mcpServers,omitempty"`
|
|
||||||
Agents map[string]*AgentConfig `json:"agents,omitempty"`
|
|
||||||
Users *UsersConfig `json:"users,omitempty"`
|
|
||||||
Groups map[string]*GroupConfig `json:"groups,omitempty"`
|
|
||||||
// ModelAliases maps an operator-defined alias id to a target model id
|
|
||||||
// (spec §5, v0.2) — e.g. {"aliased/coding": "anthropic/claude-sonnet-4-5"}.
|
|
||||||
// An exact alias match wins resolution before any other rule
|
|
||||||
// (modelRegistry.resolve, registry.go); the target then resolves
|
|
||||||
// through the normal rules. nil/omitted preserves v0.1 behavior
|
|
||||||
// exactly: resolve never consults an empty alias map, so no existing
|
|
||||||
// model id's resolution changes. Validated at construction
|
|
||||||
// (validateModelAliases, registry.go).
|
|
||||||
ModelAliases map[string]string `json:"modelAliases,omitempty"`
|
|
||||||
Providers map[string]*ProviderConfig `json:"providers,omitempty"`
|
Providers map[string]*ProviderConfig `json:"providers,omitempty"`
|
||||||
// ModelMeta declares per-model metadata overrides (feature v0.23):
|
// ModelMeta declares per-model metadata overrides (feature v0.23):
|
||||||
// context window size and per-token cost, keyed by an exact
|
// context window size and per-token cost, keyed by an exact
|
||||||
@@ -59,6 +40,42 @@ type Config struct {
|
|||||||
// discovery/builtin/absent, same as before this feature existed.
|
// discovery/builtin/absent, same as before this feature existed.
|
||||||
// Validated at construction (validateModelMeta, modelmeta.go).
|
// Validated at construction (validateModelMeta, modelmeta.go).
|
||||||
ModelMeta map[string]*ModelMetaConfig `json:"modelMeta,omitempty"`
|
ModelMeta map[string]*ModelMetaConfig `json:"modelMeta,omitempty"`
|
||||||
|
Pricing map[string]*ModelPricing `json:"pricing,omitempty"`
|
||||||
|
MCPServers map[string]*TargetConfig `json:"mcpServers,omitempty"`
|
||||||
|
Agents map[string]*AgentConfig `json:"agents,omitempty"`
|
||||||
|
Users *UsersConfig `json:"users,omitempty"`
|
||||||
|
// Admin gates the read-only admin dashboard (spec §4, v0.2): nil or
|
||||||
|
// Admin.Enabled false means the /admin* routes are not registered at
|
||||||
|
// all — ServeHTTP falls through to its existing 404/passthroughUnknown
|
||||||
|
// handling for those paths, preserving v0.1 behavior exactly.
|
||||||
|
Admin *AdminConfig `json:"admin,omitempty"`
|
||||||
|
Groups map[string]*GroupConfig `json:"groups,omitempty"`
|
||||||
|
Redis *RedisConfig `json:"redis,omitempty"`
|
||||||
|
// ModelAliases maps an operator-defined alias id to a target model id
|
||||||
|
// (spec §5, v0.2) — e.g. {"aliased/coding": "anthropic/claude-sonnet-4-5"}.
|
||||||
|
// An exact alias match wins resolution before any other rule
|
||||||
|
// (modelRegistry.resolve, registry.go); the target then resolves
|
||||||
|
// through the normal rules. nil/omitted preserves v0.1 behavior
|
||||||
|
// exactly: resolve never consults an empty alias map, so no existing
|
||||||
|
// model id's resolution changes. Validated at construction
|
||||||
|
// (validateModelAliases, registry.go).
|
||||||
|
ModelAliases map[string]string `json:"modelAliases,omitempty"`
|
||||||
|
// RequestTimeout is the progress-based upstream request timeout
|
||||||
|
// (feature: request timeout) applied to every provider adapter call
|
||||||
|
// and to the MCP/A2A target proxy, unless a provider sets its own
|
||||||
|
// ProviderConfig.RequestTimeout override. A Go duration string (e.g.
|
||||||
|
// "5m", "90s"), parsed and validated by resolveRequestTimeout
|
||||||
|
// (timeout.go). Empty (the default) resolves to defaultRequestTimeout,
|
||||||
|
// five minutes — a deliberate behavior change for every deployment,
|
||||||
|
// not an invisible bug fix: before this field existed, an upstream
|
||||||
|
// provider that accepted a connection and then hung could hang the
|
||||||
|
// gateway request forever (see defaultRequestTimeout's own doc
|
||||||
|
// comment, and README.md's "Request timeout" section). A non-empty
|
||||||
|
// value that fails to parse, or parses to a duration <= 0, is a
|
||||||
|
// construction error — zero or negative never means "no timeout" here,
|
||||||
|
// since that would silently reintroduce the exact hang this feature
|
||||||
|
// fixes.
|
||||||
|
RequestTimeout string `json:"requestTimeout,omitempty"`
|
||||||
// Breaker configures the discovery circuit breaker (feat/provider-
|
// Breaker configures the discovery circuit breaker (feat/provider-
|
||||||
// health): how many consecutive discovery-refresh failures open a
|
// health): how many consecutive discovery-refresh failures open a
|
||||||
// provider's breaker, and how long it then backs off before probing
|
// provider's breaker, and how long it then backs off before probing
|
||||||
@@ -132,6 +149,14 @@ type ProviderConfig struct {
|
|||||||
BaseURL string `json:"baseUrl,omitempty"`
|
BaseURL string `json:"baseUrl,omitempty"`
|
||||||
APIKey string `json:"apiKey"`
|
APIKey string `json:"apiKey"`
|
||||||
DiscoveryInterval string `json:"discoveryInterval,omitempty"`
|
DiscoveryInterval string `json:"discoveryInterval,omitempty"`
|
||||||
|
// RequestTimeout overrides the global Config.RequestTimeout for this
|
||||||
|
// provider alone — an explicit per-provider override always wins over
|
||||||
|
// the global default (house rule). Same Go-duration-string form,
|
||||||
|
// same validation (resolveRequestTimeout, timeout.go): empty inherits
|
||||||
|
// the global (or its own default of five minutes when the global is
|
||||||
|
// also unset); a non-empty value that fails to parse, or parses to a
|
||||||
|
// duration <= 0, is a construction error naming this provider.
|
||||||
|
RequestTimeout string `json:"requestTimeout,omitempty"`
|
||||||
// MetadataPath is an optional second discovery endpoint (feature
|
// MetadataPath is an optional second discovery endpoint (feature
|
||||||
// v0.23), fetched alongside the provider's normal listModels call
|
// v0.23), fetched alongside the provider's normal listModels call
|
||||||
// when set: a path such as "/api/v0/models" (LM Studio's own,
|
// when set: a path such as "/api/v0/models" (LM Studio's own,
|
||||||
@@ -526,6 +551,13 @@ type Gateway struct {
|
|||||||
// contract.
|
// contract.
|
||||||
bodyAdmission chan struct{}
|
bodyAdmission chan struct{}
|
||||||
name string
|
name string
|
||||||
|
// targetTimeout is targetClient's own resolved request timeout —
|
||||||
|
// Config.RequestTimeout only; an MCP/A2A target has no per-target
|
||||||
|
// override the way a provider does. proxyUpstream's own callers
|
||||||
|
// (mcp_a2a.go's handleTargetProxy) pass this through so its
|
||||||
|
// idle-progress body watchdog (timeout.go) matches the
|
||||||
|
// ResponseHeaderTimeout already set on targetClient's Transport.
|
||||||
|
targetTimeout time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
// telemetryStartupOnce keeps the anonymous "plugin loaded" ping to one per
|
// telemetryStartupOnce keeps the anonymous "plugin loaded" ping to one per
|
||||||
@@ -685,7 +717,21 @@ func newGateway(ctx context.Context, next http.Handler, config *Config, name str
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
g.adapters = adapters
|
g.adapters = adapters
|
||||||
g.targetClient = newAdapterHTTPClient()
|
|
||||||
|
// targetTimeout: the global default only (resolveRequestTimeout,
|
||||||
|
// timeout.go) — an MCP/A2A target has no per-target override the way a
|
||||||
|
// provider does (ProviderConfig.RequestTimeout). Resolved again here,
|
||||||
|
// separately from buildAdapters' own identical call, rather than
|
||||||
|
// widening buildAdapters' return signature to hand the value back:
|
||||||
|
// buildAdapters(cfg *Config) (map[string]providerAdapter, error) is
|
||||||
|
// called directly by several existing tests, and parsing the same
|
||||||
|
// short duration string twice at construction time is cheap.
|
||||||
|
targetTimeout, err := resolveRequestTimeout(config.RequestTimeout, "requestTimeout")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
g.targetTimeout = targetTimeout
|
||||||
|
g.targetClient = newAdapterHTTPClient(targetTimeout)
|
||||||
|
|
||||||
registry, err := newModelRegistry(adapters, config, g.errorf)
|
registry, err := newModelRegistry(adapters, config, g.errorf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+1
-1
@@ -216,7 +216,7 @@ func (g *Gateway) handleTargetProxy(w http.ResponseWriter, r *http.Request, u *u
|
|||||||
upstreamURL += "?" + r.URL.RawQuery
|
upstreamURL += "?" + r.URL.RawQuery
|
||||||
}
|
}
|
||||||
|
|
||||||
g.proxyUpstream(w, r, upstreamURL, g.targetClient, nil, nil, false, kind+" target (name "+name+")")
|
g.proxyUpstream(w, r, upstreamURL, g.targetClient, nil, nil, false, kind+" target (name "+name+")", g.targetTimeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateTargetURLs checks every configured MCP-server and agent entry at
|
// validateTargetURLs checks every configured MCP-server and agent entry at
|
||||||
|
|||||||
+13
-3
@@ -29,6 +29,12 @@ type anthropicAdapter struct {
|
|||||||
adapterName string
|
adapterName string
|
||||||
baseURL string
|
baseURL string
|
||||||
apiKey string
|
apiKey string
|
||||||
|
// timeout is this adapter's resolved per-request timeout —
|
||||||
|
// resolveProviderTimeout, timeout.go; see openaiAdapter.timeout's own
|
||||||
|
// doc comment (provider_openai.go) for why this is set to
|
||||||
|
// defaultRequestTimeout at construction rather than left at its zero
|
||||||
|
// value the way retry is.
|
||||||
|
timeout time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
// newAnthropicAdapter returns an anthropicAdapter for provider name, with
|
// newAnthropicAdapter returns an anthropicAdapter for provider name, with
|
||||||
@@ -45,7 +51,8 @@ func newAnthropicAdapter(name, base, apiKey string) (*anthropicAdapter, error) {
|
|||||||
adapterName: name,
|
adapterName: name,
|
||||||
baseURL: base,
|
baseURL: base,
|
||||||
apiKey: apiKey,
|
apiKey: apiKey,
|
||||||
client: newAdapterHTTPClient(),
|
client: newAdapterHTTPClient(defaultRequestTimeout),
|
||||||
|
timeout: defaultRequestTimeout,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,6 +68,9 @@ func (a *anthropicAdapter) base() string { return a.baseURL }
|
|||||||
// httpClient implements providerAdapter.
|
// httpClient implements providerAdapter.
|
||||||
func (a *anthropicAdapter) httpClient() *http.Client { return a.client }
|
func (a *anthropicAdapter) httpClient() *http.Client { return a.client }
|
||||||
|
|
||||||
|
// requestTimeout implements providerAdapter.
|
||||||
|
func (a *anthropicAdapter) requestTimeout() time.Duration { return a.timeout }
|
||||||
|
|
||||||
// injectAuth implements providerAdapter: Anthropic's x-api-key scheme,
|
// injectAuth implements providerAdapter: Anthropic's x-api-key scheme,
|
||||||
// plus the anthropic-version header every request must carry. Unlike
|
// plus the anthropic-version header every request must carry. Unlike
|
||||||
// openai-type's injectAuth, this is never a no-op — the constructor
|
// openai-type's injectAuth, this is never a no-op — the constructor
|
||||||
@@ -112,7 +122,7 @@ func (a *anthropicAdapter) chatCompletion(ctx context.Context, w http.ResponseWr
|
|||||||
hdr.Set("Accept", "text/event-stream")
|
hdr.Set("Accept", "text/event-stream")
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := upstreamJSON(ctx, a.client, http.MethodPost, a.baseURL+anthropicMessagesPath, hdr, body, a.retry)
|
resp, err := upstreamJSON(ctx, a.client, http.MethodPost, a.baseURL+anthropicMessagesPath, hdr, body, a.retry, a.timeout, a.adapterName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return usage{}, err
|
return usage{}, err
|
||||||
}
|
}
|
||||||
@@ -226,7 +236,7 @@ func (a *anthropicAdapter) audioTranscription(_ context.Context, _ http.Response
|
|||||||
// falls back to a provider's explicitly configured models on error rather
|
// falls back to a provider's explicitly configured models on error rather
|
||||||
// than this adapter guessing at a fallback itself.
|
// than this adapter guessing at a fallback itself.
|
||||||
func (a *anthropicAdapter) listModels(ctx context.Context) ([]string, error) {
|
func (a *anthropicAdapter) listModels(ctx context.Context) ([]string, error) {
|
||||||
resp, err := upstreamJSON(ctx, a.client, http.MethodGet, a.baseURL+anthropicModelsPath, a.requestHeaders(false), nil, a.retry)
|
resp, err := upstreamJSON(ctx, a.client, http.MethodGet, a.baseURL+anthropicModelsPath, a.requestHeaders(false), nil, a.retry, a.timeout, a.adapterName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-5
@@ -23,6 +23,12 @@ type geminiAdapter struct {
|
|||||||
adapterName string
|
adapterName string
|
||||||
baseURL string
|
baseURL string
|
||||||
apiKey string
|
apiKey string
|
||||||
|
// timeout is this adapter's resolved per-request timeout —
|
||||||
|
// resolveProviderTimeout, timeout.go; see openaiAdapter.timeout's own
|
||||||
|
// doc comment (provider_openai.go) for why this is set to
|
||||||
|
// defaultRequestTimeout at construction rather than left at its zero
|
||||||
|
// value the way retry is.
|
||||||
|
timeout time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
// newGeminiAdapter returns a geminiAdapter for provider name, with base as
|
// newGeminiAdapter returns a geminiAdapter for provider name, with base as
|
||||||
@@ -38,7 +44,8 @@ func newGeminiAdapter(name, base, apiKey string) (*geminiAdapter, error) {
|
|||||||
adapterName: name,
|
adapterName: name,
|
||||||
baseURL: base,
|
baseURL: base,
|
||||||
apiKey: apiKey,
|
apiKey: apiKey,
|
||||||
client: newAdapterHTTPClient(),
|
client: newAdapterHTTPClient(defaultRequestTimeout),
|
||||||
|
timeout: defaultRequestTimeout,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,6 +61,9 @@ func (a *geminiAdapter) base() string { return a.baseURL }
|
|||||||
// httpClient implements providerAdapter.
|
// httpClient implements providerAdapter.
|
||||||
func (a *geminiAdapter) httpClient() *http.Client { return a.client }
|
func (a *geminiAdapter) httpClient() *http.Client { return a.client }
|
||||||
|
|
||||||
|
// requestTimeout implements providerAdapter.
|
||||||
|
func (a *geminiAdapter) requestTimeout() time.Duration { return a.timeout }
|
||||||
|
|
||||||
// injectAuth implements providerAdapter: Gemini's x-goog-api-key header
|
// injectAuth implements providerAdapter: Gemini's x-goog-api-key header
|
||||||
// scheme. Never a no-op — the constructor already rejected an empty apiKey.
|
// scheme. Never a no-op — the constructor already rejected an empty apiKey.
|
||||||
func (a *geminiAdapter) injectAuth(r *http.Request) {
|
func (a *geminiAdapter) injectAuth(r *http.Request) {
|
||||||
@@ -125,7 +135,7 @@ func (a *geminiAdapter) chatCompletion(ctx context.Context, w http.ResponseWrite
|
|||||||
endpoint = a.baseURL + geminiAPIPrefix + escapedModel + ":streamGenerateContent?alt=sse"
|
endpoint = a.baseURL + geminiAPIPrefix + escapedModel + ":streamGenerateContent?alt=sse"
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := upstreamJSON(ctx, a.client, http.MethodPost, endpoint, hdr, body, a.retry)
|
resp, err := upstreamJSON(ctx, a.client, http.MethodPost, endpoint, hdr, body, a.retry, a.timeout, a.adapterName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return usage{}, err
|
return usage{}, err
|
||||||
}
|
}
|
||||||
@@ -234,7 +244,7 @@ func (a *geminiAdapter) embeddings(ctx context.Context, w http.ResponseWriter, r
|
|||||||
endpoint = a.baseURL + geminiAPIPrefix + escapedModel + ":batchEmbedContents"
|
endpoint = a.baseURL + geminiAPIPrefix + escapedModel + ":batchEmbedContents"
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := upstreamJSON(ctx, a.client, http.MethodPost, endpoint, a.requestHeaders(true), body, a.retry)
|
resp, err := upstreamJSON(ctx, a.client, http.MethodPost, endpoint, a.requestHeaders(true), body, a.retry, a.timeout, a.adapterName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return usage{}, err
|
return usage{}, err
|
||||||
}
|
}
|
||||||
@@ -286,7 +296,7 @@ func (a *geminiAdapter) imagesGeneration(ctx context.Context, w http.ResponseWri
|
|||||||
escapedModel := url.PathEscape(model)
|
escapedModel := url.PathEscape(model)
|
||||||
endpoint := a.baseURL + geminiAPIPrefix + escapedModel + ":predict"
|
endpoint := a.baseURL + geminiAPIPrefix + escapedModel + ":predict"
|
||||||
|
|
||||||
resp, err := upstreamJSON(ctx, a.client, http.MethodPost, endpoint, a.requestHeaders(true), body, a.retry)
|
resp, err := upstreamJSON(ctx, a.client, http.MethodPost, endpoint, a.requestHeaders(true), body, a.retry, a.timeout, a.adapterName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return usage{}, err
|
return usage{}, err
|
||||||
}
|
}
|
||||||
@@ -345,7 +355,7 @@ type geminiModelsListPayload struct {
|
|||||||
// model ids, matching the shape openai-type and anthropic-type adapters
|
// model ids, matching the shape openai-type and anthropic-type adapters
|
||||||
// both return.
|
// both return.
|
||||||
func (a *geminiAdapter) listModels(ctx context.Context) ([]string, error) {
|
func (a *geminiAdapter) listModels(ctx context.Context) ([]string, error) {
|
||||||
resp, err := upstreamJSON(ctx, a.client, http.MethodGet, a.baseURL+"/v1beta/models", a.requestHeaders(false), nil, a.retry)
|
resp, err := upstreamJSON(ctx, a.client, http.MethodGet, a.baseURL+"/v1beta/models", a.requestHeaders(false), nil, a.retry, a.timeout, a.adapterName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-9
@@ -7,6 +7,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// openaiAdapter is the providerAdapter for provider type "openai": OpenAI
|
// openaiAdapter is the providerAdapter for provider type "openai": OpenAI
|
||||||
@@ -29,18 +30,32 @@ type openaiAdapter struct {
|
|||||||
// newOpenAIAdapter call site (every test, and any caller that never
|
// newOpenAIAdapter call site (every test, and any caller that never
|
||||||
// sets this) keeps behaving exactly as before this feature existed.
|
// sets this) keeps behaving exactly as before this feature existed.
|
||||||
metadataPath string
|
metadataPath string
|
||||||
|
// timeout is this adapter's resolved per-request timeout
|
||||||
|
// (resolveProviderTimeout, timeout.go) — UNLIKE retry above, it is NOT
|
||||||
|
// left at its zero value by a bare newOpenAIAdapter call: a
|
||||||
|
// zero-duration timeout would make watchdogBody's watchdog fire
|
||||||
|
// immediately (time.AfterFunc(0, ...) runs at the next scheduler
|
||||||
|
// tick), the opposite of "no timeout configured". newOpenAIAdapter
|
||||||
|
// sets it to defaultRequestTimeout; buildAdapters (providers.go)
|
||||||
|
// overwrites it with the real, config-resolved value for production
|
||||||
|
// use, the same way it overwrites retry/metadataPath above.
|
||||||
|
timeout time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
// newOpenAIAdapter returns an openaiAdapter for provider name, with base as
|
// newOpenAIAdapter returns an openaiAdapter for provider name, with base as
|
||||||
// its already-defaulted, trailing-slash-trimmed base URL and apiKey as
|
// its already-defaulted, trailing-slash-trimmed base URL and apiKey as
|
||||||
// already resolved by resolveSecret (empty means keyless). It builds one
|
// already resolved by resolveSecret (empty means keyless). It builds one
|
||||||
// shared *http.Client, reused for every request this adapter makes.
|
// shared *http.Client, reused for every request this adapter makes, with
|
||||||
|
// its Transport.ResponseHeaderTimeout set to defaultRequestTimeout — see
|
||||||
|
// the timeout field's own doc comment for why this constructor cannot
|
||||||
|
// leave timeout at its zero value the way it leaves retry at nil.
|
||||||
func newOpenAIAdapter(name, base, apiKey string) *openaiAdapter {
|
func newOpenAIAdapter(name, base, apiKey string) *openaiAdapter {
|
||||||
return &openaiAdapter{
|
return &openaiAdapter{
|
||||||
adapterName: name,
|
adapterName: name,
|
||||||
baseURL: base,
|
baseURL: base,
|
||||||
apiKey: apiKey,
|
apiKey: apiKey,
|
||||||
client: newAdapterHTTPClient(),
|
client: newAdapterHTTPClient(defaultRequestTimeout),
|
||||||
|
timeout: defaultRequestTimeout,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,6 +71,9 @@ func (a *openaiAdapter) base() string { return a.baseURL }
|
|||||||
// httpClient implements providerAdapter.
|
// httpClient implements providerAdapter.
|
||||||
func (a *openaiAdapter) httpClient() *http.Client { return a.client }
|
func (a *openaiAdapter) httpClient() *http.Client { return a.client }
|
||||||
|
|
||||||
|
// requestTimeout implements providerAdapter.
|
||||||
|
func (a *openaiAdapter) requestTimeout() time.Duration { return a.timeout }
|
||||||
|
|
||||||
// injectAuth implements providerAdapter: OpenAI's bearer-token scheme. A
|
// injectAuth implements providerAdapter: OpenAI's bearer-token scheme. A
|
||||||
// no-op when a.apiKey is empty, per the keyless-upstream ruling — a request
|
// no-op when a.apiKey is empty, per the keyless-upstream ruling — a request
|
||||||
// against a keyless upstream must carry no Authorization header at all,
|
// against a keyless upstream must carry no Authorization header at all,
|
||||||
@@ -143,7 +161,7 @@ func (a *openaiAdapter) chatCompletion(ctx context.Context, w http.ResponseWrite
|
|||||||
hdr.Set("Accept", "text/event-stream")
|
hdr.Set("Accept", "text/event-stream")
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := upstreamJSON(ctx, a.client, http.MethodPost, a.baseURL+"/v1/chat/completions", hdr, req, a.retry)
|
resp, err := upstreamJSON(ctx, a.client, http.MethodPost, a.baseURL+"/v1/chat/completions", hdr, req, a.retry, a.timeout, a.adapterName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return usage{}, err
|
return usage{}, err
|
||||||
}
|
}
|
||||||
@@ -168,7 +186,7 @@ func (a *openaiAdapter) chatCompletion(ctx context.Context, w http.ResponseWrite
|
|||||||
// non-streaming path.
|
// non-streaming path.
|
||||||
func (a *openaiAdapter) embeddings(ctx context.Context, w http.ResponseWriter, req map[string]any) (usage, error) {
|
func (a *openaiAdapter) embeddings(ctx context.Context, w http.ResponseWriter, req map[string]any) (usage, error) {
|
||||||
delete(req, gatewayAliasKey) // see chatCompletion's identical delete for why
|
delete(req, gatewayAliasKey) // see chatCompletion's identical delete for why
|
||||||
resp, err := upstreamJSON(ctx, a.client, http.MethodPost, a.baseURL+"/v1/embeddings", a.requestHeaders(true), req, a.retry)
|
resp, err := upstreamJSON(ctx, a.client, http.MethodPost, a.baseURL+"/v1/embeddings", a.requestHeaders(true), req, a.retry, a.timeout, a.adapterName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return usage{}, err
|
return usage{}, err
|
||||||
}
|
}
|
||||||
@@ -277,7 +295,7 @@ func (a *openaiAdapter) imagesGeneration(ctx context.Context, w http.ResponseWri
|
|||||||
// it — the same defensive posture chatCompletion/embeddings already
|
// it — the same defensive posture chatCompletion/embeddings already
|
||||||
// take.
|
// take.
|
||||||
delete(req, gatewayAliasKey)
|
delete(req, gatewayAliasKey)
|
||||||
resp, err := upstreamJSON(ctx, a.client, http.MethodPost, a.baseURL+"/v1/images/generations", a.requestHeaders(true), req, a.retry)
|
resp, err := upstreamJSON(ctx, a.client, http.MethodPost, a.baseURL+"/v1/images/generations", a.requestHeaders(true), req, a.retry, a.timeout, a.adapterName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return usage{}, err
|
return usage{}, err
|
||||||
}
|
}
|
||||||
@@ -303,7 +321,7 @@ func (a *openaiAdapter) audioSpeech(ctx context.Context, w http.ResponseWriter,
|
|||||||
if contentType != "" {
|
if contentType != "" {
|
||||||
hdr.Set("Content-Type", contentType)
|
hdr.Set("Content-Type", contentType)
|
||||||
}
|
}
|
||||||
resp, err := upstreamRawBytes(ctx, a.client, http.MethodPost, a.baseURL+"/v1/audio/speech", hdr, body, a.retry)
|
resp, err := upstreamRawBytes(ctx, a.client, http.MethodPost, a.baseURL+"/v1/audio/speech", hdr, body, a.retry, a.timeout, a.adapterName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return usage{}, err
|
return usage{}, err
|
||||||
}
|
}
|
||||||
@@ -340,7 +358,7 @@ func (a *openaiAdapter) audioTranscription(ctx context.Context, w http.ResponseW
|
|||||||
if contentType != "" {
|
if contentType != "" {
|
||||||
hdr.Set("Content-Type", contentType)
|
hdr.Set("Content-Type", contentType)
|
||||||
}
|
}
|
||||||
resp, err := upstreamRawBytes(ctx, a.client, http.MethodPost, a.baseURL+"/v1/audio/transcriptions", hdr, body, a.retry)
|
resp, err := upstreamRawBytes(ctx, a.client, http.MethodPost, a.baseURL+"/v1/audio/transcriptions", hdr, body, a.retry, a.timeout, a.adapterName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return usage{}, err
|
return usage{}, err
|
||||||
}
|
}
|
||||||
@@ -381,7 +399,7 @@ type modelsPayload struct {
|
|||||||
|
|
||||||
// listModels implements providerAdapter.
|
// listModels implements providerAdapter.
|
||||||
func (a *openaiAdapter) listModels(ctx context.Context) ([]string, error) {
|
func (a *openaiAdapter) listModels(ctx context.Context) ([]string, error) {
|
||||||
resp, err := upstreamJSON(ctx, a.client, http.MethodGet, a.baseURL+"/v1/models", a.requestHeaders(false), nil, a.retry)
|
resp, err := upstreamJSON(ctx, a.client, http.MethodGet, a.baseURL+"/v1/models", a.requestHeaders(false), nil, a.retry, a.timeout, a.adapterName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -444,7 +462,7 @@ func (a *openaiAdapter) fetchModelMetadata(ctx context.Context) (map[string]int,
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := upstreamJSON(ctx, a.client, http.MethodGet, a.baseURL+a.metadataPath, a.requestHeaders(false), nil, a.retry)
|
resp, err := upstreamJSON(ctx, a.client, http.MethodGet, a.baseURL+a.metadataPath, a.requestHeaders(false), nil, a.retry, a.timeout, a.adapterName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
+89
-13
@@ -10,6 +10,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Provider type identifiers, matched against ProviderConfig.Type.
|
// Provider type identifiers, matched against ProviderConfig.Type.
|
||||||
@@ -159,16 +160,35 @@ type providerAdapter interface {
|
|||||||
// request. Named httpClient rather than client to avoid colliding with
|
// request. Named httpClient rather than client to avoid colliding with
|
||||||
// every implementation's own "client" struct field of the same name.
|
// every implementation's own "client" struct field of the same name.
|
||||||
httpClient() *http.Client
|
httpClient() *http.Client
|
||||||
|
// requestTimeout returns this adapter's resolved per-request timeout
|
||||||
|
// (Config.RequestTimeout or ProviderConfig.RequestTimeout,
|
||||||
|
// resolveProviderTimeout in timeout.go) — the idle-progress bound every
|
||||||
|
// upstream call through this adapter's shared client is held to.
|
||||||
|
// Exposed on the interface so a caller outside this file (native
|
||||||
|
// passthrough, routes_passthrough.go's handlePassthrough) can apply the
|
||||||
|
// identical watchdog proxyUpstream needs to its own resp.Body read,
|
||||||
|
// instead of the timeout only ever protecting upstreamJSON/
|
||||||
|
// upstreamRawBytes' own callers.
|
||||||
|
requestTimeout() time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
// newAdapterHTTPClient returns an *http.Client for a provider adapter to
|
// newAdapterHTTPClient returns an *http.Client for a provider adapter to
|
||||||
// hold and reuse across every request it makes: no client-level timeout,
|
// hold and reuse across every request it makes: no CLIENT-level timeout
|
||||||
// since a streaming chat completion can legitimately run for minutes and
|
// (Client.Timeout is deliberately left unset), since that would bound the
|
||||||
// callers bound requests with a context deadline instead, and a Transport
|
// whole request including body read — killing exactly the long streams
|
||||||
// cloned from http.DefaultTransport with a larger per-host idle connection
|
// this gateway must preserve (a 20-minute generation that keeps producing
|
||||||
// pool, since every request from one adapter targets the same upstream
|
// tokens must complete) — and a Transport cloned from http.DefaultTransport
|
||||||
// host.
|
// with a larger per-host idle connection pool, since every request from
|
||||||
func newAdapterHTTPClient() *http.Client {
|
// one adapter targets the same upstream host.
|
||||||
|
//
|
||||||
|
// timeout sets Transport.ResponseHeaderTimeout: the first of this
|
||||||
|
// feature's two mechanisms — it bounds only the wait for a response's
|
||||||
|
// headers, never the body, so it is safe for streaming. The second
|
||||||
|
// mechanism, bounding idle time WITHIN the body (a provider that sends
|
||||||
|
// headers and then stalls), is watchdogBody (timeout.go), applied by each
|
||||||
|
// caller of upstreamBytes/proxyUpstream to the *http.Response.Body this
|
||||||
|
// client's Do returns — Transport has no equivalent field for that half.
|
||||||
|
func newAdapterHTTPClient(timeout time.Duration) *http.Client {
|
||||||
tr, ok := http.DefaultTransport.(*http.Transport)
|
tr, ok := http.DefaultTransport.(*http.Transport)
|
||||||
if ok {
|
if ok {
|
||||||
tr = tr.Clone()
|
tr = tr.Clone()
|
||||||
@@ -181,6 +201,7 @@ func newAdapterHTTPClient() *http.Client {
|
|||||||
tr = &http.Transport{}
|
tr = &http.Transport{}
|
||||||
}
|
}
|
||||||
tr.MaxIdleConnsPerHost = adapterIdleConnsPerHost
|
tr.MaxIdleConnsPerHost = adapterIdleConnsPerHost
|
||||||
|
tr.ResponseHeaderTimeout = timeout
|
||||||
return &http.Client{Transport: tr}
|
return &http.Client{Transport: tr}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,6 +260,12 @@ func attemptRecorderFromContext(ctx context.Context) attemptRecorder {
|
|||||||
// listModels) have nothing to write through and every current caller
|
// listModels) have nothing to write through and every current caller
|
||||||
// checks resp.StatusCode itself.
|
// checks resp.StatusCode itself.
|
||||||
//
|
//
|
||||||
|
// timeout and providerName (an adapter's own resolved requestTimeout()/
|
||||||
|
// name()) arm the idle-progress watchdog upstreamBytes wraps resp.Body
|
||||||
|
// in, once a response is actually obtained — see watchdogBody's own doc
|
||||||
|
// comment, timeout.go, for the mechanism and why it, not
|
||||||
|
// http.Client.Timeout, is what bounds a stalled body.
|
||||||
|
//
|
||||||
// policy (retry.go) wraps the whole request-and-receive-status exchange:
|
// policy (retry.go) wraps the whole request-and-receive-status exchange:
|
||||||
// a nil policy, or a disabled one, makes exactly one attempt (v0.1
|
// a nil policy, or a disabled one, makes exactly one attempt (v0.1
|
||||||
// behavior, byte-identical). Every caller of upstreamJSON — chatCompletion,
|
// behavior, byte-identical). Every caller of upstreamJSON — chatCompletion,
|
||||||
@@ -250,7 +277,7 @@ func attemptRecorderFromContext(ctx context.Context) attemptRecorder {
|
|||||||
// already returned its final, retried-or-not response, so a stream that
|
// already returned its final, retried-or-not response, so a stream that
|
||||||
// dies while forwardStream is mid-flight never re-enters this function
|
// dies while forwardStream is mid-flight never re-enters this function
|
||||||
// and is therefore never retried.
|
// and is therefore never retried.
|
||||||
func upstreamJSON(ctx context.Context, client *http.Client, method, url string, hdr http.Header, body any, policy *retryPolicy) (*http.Response, error) {
|
func upstreamJSON(ctx context.Context, client *http.Client, method, url string, hdr http.Header, body any, policy *retryPolicy, timeout time.Duration, providerName string) (*http.Response, error) {
|
||||||
var bodyBytes []byte
|
var bodyBytes []byte
|
||||||
if body != nil {
|
if body != nil {
|
||||||
b, err := json.Marshal(body)
|
b, err := json.Marshal(body)
|
||||||
@@ -259,7 +286,7 @@ func upstreamJSON(ctx context.Context, client *http.Client, method, url string,
|
|||||||
}
|
}
|
||||||
bodyBytes = b
|
bodyBytes = b
|
||||||
}
|
}
|
||||||
return upstreamBytes(ctx, client, method, url, hdr, bodyBytes, policy)
|
return upstreamBytes(ctx, client, method, url, hdr, bodyBytes, policy, timeout, providerName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// upstreamRawBytes is upstreamJSON's counterpart for a body the caller has
|
// upstreamRawBytes is upstreamJSON's counterpart for a body the caller has
|
||||||
@@ -271,8 +298,8 @@ func upstreamJSON(ctx context.Context, client *http.Client, method, url string,
|
|||||||
// multipart payload upstreamJSON's json.Marshal would corrupt outright.
|
// multipart payload upstreamJSON's json.Marshal would corrupt outright.
|
||||||
// Shares upstreamBytes with upstreamJSON, so both get the identical
|
// Shares upstreamBytes with upstreamJSON, so both get the identical
|
||||||
// retry/zero-bytes-reached invariant documented on upstreamJSON.
|
// retry/zero-bytes-reached invariant documented on upstreamJSON.
|
||||||
func upstreamRawBytes(ctx context.Context, client *http.Client, method, url string, hdr http.Header, body []byte, policy *retryPolicy) (*http.Response, error) {
|
func upstreamRawBytes(ctx context.Context, client *http.Client, method, url string, hdr http.Header, body []byte, policy *retryPolicy, timeout time.Duration, providerName string) (*http.Response, error) {
|
||||||
return upstreamBytes(ctx, client, method, url, hdr, body, policy)
|
return upstreamBytes(ctx, client, method, url, hdr, body, policy, timeout, providerName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// upstreamBytes is the shared request-build-and-send core behind both
|
// upstreamBytes is the shared request-build-and-send core behind both
|
||||||
@@ -282,12 +309,22 @@ func upstreamRawBytes(ctx context.Context, client *http.Client, method, url stri
|
|||||||
// request body unchanged, and Content-Type defaults to
|
// request body unchanged, and Content-Type defaults to
|
||||||
// "application/json" only when bodyBytes is non-nil and hdr set none of
|
// "application/json" only when bodyBytes is non-nil and hdr set none of
|
||||||
// its own.
|
// its own.
|
||||||
func upstreamBytes(ctx context.Context, client *http.Client, method, url string, hdr http.Header, bodyBytes []byte, policy *retryPolicy) (*http.Response, error) {
|
func upstreamBytes(ctx context.Context, client *http.Client, method, url string, hdr http.Header, bodyBytes []byte, policy *retryPolicy, timeout time.Duration, providerName string) (*http.Response, error) {
|
||||||
call := func() (*http.Response, error) {
|
call := func() (*http.Response, error) {
|
||||||
var r io.Reader
|
var r io.Reader
|
||||||
if bodyBytes != nil {
|
if bodyBytes != nil {
|
||||||
r = bytes.NewReader(bodyBytes)
|
r = bytes.NewReader(bodyBytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// reqCtx/cancel bound this attempt's response body to timeout via
|
||||||
|
// watchdogBody (timeout.go), derived fresh per attempt so a retried
|
||||||
|
// request gets its own full timeout window rather than inheriting
|
||||||
|
// an already-ticking one. cancel is released either by
|
||||||
|
// watchdogBody (once resp.Body exists — see its own Close/fire) or
|
||||||
|
// directly below, on a build/send failure that never produced a
|
||||||
|
// body to wrap.
|
||||||
|
reqCtx, cancel := context.WithCancel(ctx)
|
||||||
|
|
||||||
// gosec G704 (SSRF via taint analysis) flags url below: every call
|
// gosec G704 (SSRF via taint analysis) flags url below: every call
|
||||||
// site builds it from an operator-configured value —
|
// site builds it from an operator-configured value —
|
||||||
// adapter.baseURL (ProviderConfig.BaseURL, or defaultBaseURLByType
|
// adapter.baseURL (ProviderConfig.BaseURL, or defaultBaseURLByType
|
||||||
@@ -299,8 +336,9 @@ func upstreamBytes(ctx context.Context, client *http.Client, method, url string,
|
|||||||
// proxyUpstream for the one adapter call path where a request-
|
// proxyUpstream for the one adapter call path where a request-
|
||||||
// derived path segment genuinely reaches the outgoing URL, and its
|
// derived path segment genuinely reaches the outgoing URL, and its
|
||||||
// own comment for why that case is safe.
|
// own comment for why that case is safe.
|
||||||
req, err := http.NewRequestWithContext(ctx, method, url, r) //nolint:gosec // operator-configured host; see comment above
|
req, err := http.NewRequestWithContext(reqCtx, method, url, r) //nolint:gosec // operator-configured host; see comment above
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
cancel()
|
||||||
return nil, fmt.Errorf("%w: %w: build request: %w", errUpstream, errRequestBuildFailed, err)
|
return nil, fmt.Errorf("%w: %w: build request: %w", errUpstream, errRequestBuildFailed, err)
|
||||||
}
|
}
|
||||||
for k, vs := range hdr {
|
for k, vs := range hdr {
|
||||||
@@ -314,8 +352,10 @@ func upstreamBytes(ctx context.Context, client *http.Client, method, url string,
|
|||||||
|
|
||||||
resp, err := client.Do(req) //nolint:bodyclose,gosec // caller closes resp.Body; upstreamBytes hands the response, not its lifecycle, back — same operator-configured URL as above
|
resp, err := client.Do(req) //nolint:bodyclose,gosec // caller closes resp.Body; upstreamBytes hands the response, not its lifecycle, back — same operator-configured URL as above
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
cancel()
|
||||||
return nil, fmt.Errorf("%w: %w", errUpstream, err)
|
return nil, fmt.Errorf("%w: %w", errUpstream, err)
|
||||||
}
|
}
|
||||||
|
resp.Body = newWatchdogBody(resp.Body, cancel, timeout, providerName)
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -432,6 +472,18 @@ func buildAdapters(cfg *Config) (map[string]providerAdapter, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// globalTimeout is resolved once, up front, so a malformed
|
||||||
|
// Config.RequestTimeout fails construction even for a deployment where
|
||||||
|
// every provider happens to set its own ProviderConfig.RequestTimeout
|
||||||
|
// override — resolveProviderTimeout's own doc comment (timeout.go).
|
||||||
|
// timeoutErr, not err (govet shadow): a second err reused across this
|
||||||
|
// function's top-level scope made every err re-declared inside the
|
||||||
|
// provider loop below read as a suspicious shadow.
|
||||||
|
globalTimeout, timeoutErr := resolveRequestTimeout(cfg.RequestTimeout, "requestTimeout")
|
||||||
|
if timeoutErr != nil {
|
||||||
|
return nil, timeoutErr
|
||||||
|
}
|
||||||
|
|
||||||
adapters := make(map[string]providerAdapter, len(cfg.Providers))
|
adapters := make(map[string]providerAdapter, len(cfg.Providers))
|
||||||
for name, pc := range cfg.Providers {
|
for name, pc := range cfg.Providers {
|
||||||
if pc == nil {
|
if pc == nil {
|
||||||
@@ -454,11 +506,31 @@ func buildAdapters(cfg *Config) (map[string]providerAdapter, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// timeoutErr, not err (govet shadow — see globalTimeout's identical
|
||||||
|
// note above): this loop already declares its own err via
|
||||||
|
// apiKey, err := resolveSecret above; reusing it here for a second,
|
||||||
|
// later purpose is what made validateMetadataPath's unrelated
|
||||||
|
// if err := ...; err != nil read as a suspicious shadow of it.
|
||||||
|
timeout, timeoutErr := resolveProviderTimeout(name, pc.RequestTimeout, globalTimeout)
|
||||||
|
if timeoutErr != nil {
|
||||||
|
return nil, timeoutErr
|
||||||
|
}
|
||||||
|
|
||||||
switch pc.Type {
|
switch pc.Type {
|
||||||
case providerTypeOpenAI:
|
case providerTypeOpenAI:
|
||||||
a := newOpenAIAdapter(name, base, apiKey)
|
a := newOpenAIAdapter(name, base, apiKey)
|
||||||
a.retry = policy
|
a.retry = policy
|
||||||
a.metadataPath = pc.MetadataPath
|
a.metadataPath = pc.MetadataPath
|
||||||
|
// timeout/client are re-resolved here rather than left at the
|
||||||
|
// bare constructor's defaultRequestTimeout default (mirroring
|
||||||
|
// how a.retry/a.metadataPath are also only ever set to their
|
||||||
|
// real, config-derived values here, not by newOpenAIAdapter
|
||||||
|
// itself) — client is rebuilt, not mutated in place, since
|
||||||
|
// Transport.ResponseHeaderTimeout is set once at construction
|
||||||
|
// (newAdapterHTTPClient, above) and every adapter's Transport
|
||||||
|
// is otherwise identical.
|
||||||
|
a.timeout = timeout
|
||||||
|
a.client = newAdapterHTTPClient(timeout)
|
||||||
adapters[name] = a
|
adapters[name] = a
|
||||||
case providerTypeAnthropic:
|
case providerTypeAnthropic:
|
||||||
a, err := newAnthropicAdapter(name, base, apiKey)
|
a, err := newAnthropicAdapter(name, base, apiKey)
|
||||||
@@ -466,6 +538,8 @@ func buildAdapters(cfg *Config) (map[string]providerAdapter, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
a.retry = policy
|
a.retry = policy
|
||||||
|
a.timeout = timeout
|
||||||
|
a.client = newAdapterHTTPClient(timeout)
|
||||||
adapters[name] = a
|
adapters[name] = a
|
||||||
case providerTypeGemini:
|
case providerTypeGemini:
|
||||||
g, err := newGeminiAdapter(name, base, apiKey)
|
g, err := newGeminiAdapter(name, base, apiKey)
|
||||||
@@ -473,6 +547,8 @@ func buildAdapters(cfg *Config) (map[string]providerAdapter, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
g.retry = policy
|
g.retry = policy
|
||||||
|
g.timeout = timeout
|
||||||
|
g.client = newAdapterHTTPClient(timeout)
|
||||||
adapters[name] = g
|
adapters[name] = g
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("llmgateway: provider %q: unknown type %q", name, pc.Type)
|
return nil, fmt.Errorf("llmgateway: provider %q: unknown type %q", name, pc.Type)
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ func (f *fakeAdapter) listModels(ctx context.Context) ([]string, error) {
|
|||||||
return f.listModelsFn(ctx)
|
return f.listModelsFn(ctx)
|
||||||
}
|
}
|
||||||
func (f *fakeAdapter) httpClient() *http.Client { return nil }
|
func (f *fakeAdapter) httpClient() *http.Client { return nil }
|
||||||
|
func (f *fakeAdapter) requestTimeout() time.Duration { return defaultRequestTimeout }
|
||||||
|
|
||||||
// recordingLog is a thread-safe log func for tests, capturing formatted
|
// recordingLog is a thread-safe log func for tests, capturing formatted
|
||||||
// lines instead of writing to stderr.
|
// lines instead of writing to stderr.
|
||||||
|
|||||||
+1
-1
@@ -616,7 +616,7 @@ func TestUpstreamJSON_RequestBuildFailure_NotRetried(t *testing.T) {
|
|||||||
return true
|
return true
|
||||||
}}
|
}}
|
||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
_, err := upstreamJSON(context.Background(), client, "IN VALID", "http://127.0.0.1:1/x", nil, nil, p)
|
_, err := upstreamJSON(context.Background(), client, "IN VALID", "http://127.0.0.1:1/x", nil, nil, p, defaultRequestTimeout, "p1")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("upstreamJSON: want an error for an invalid method, got nil")
|
t.Fatal("upstreamJSON: want an error for an invalid method, got nil")
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -222,7 +222,7 @@ func (g *Gateway) callAnthropicMessagesPassthrough(ctx context.Context, w http.R
|
|||||||
// documented, not incidental.
|
// documented, not incidental.
|
||||||
adapter.injectAuth(&http.Request{Header: hdr})
|
adapter.injectAuth(&http.Request{Header: hdr})
|
||||||
|
|
||||||
resp, err := upstreamJSON(ctx, adapter.httpClient(), http.MethodPost, adapter.base()+anthropicMessagesPath, hdr, req, retry)
|
resp, err := upstreamJSON(ctx, adapter.httpClient(), http.MethodPost, adapter.base()+anthropicMessagesPath, hdr, req, retry, adapter.requestTimeout(), adapter.name())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return usage{}, err
|
return usage{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-3
@@ -9,6 +9,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// maxPassthroughBytes caps a client passthrough request body: 32MiB,
|
// maxPassthroughBytes caps a client passthrough request body: 32MiB,
|
||||||
@@ -862,7 +863,7 @@ func (g *Gateway) handlePassthrough(w http.ResponseWriter, r *http.Request, u *u
|
|||||||
g.limiter.recordProviderAttempt(providerName, "", resp, attemptErr)
|
g.limiter.recordProviderAttempt(providerName, "", resp, attemptErr)
|
||||||
}))
|
}))
|
||||||
|
|
||||||
result, ok := g.proxyUpstream(w, r, upstreamURL, adapter.httpClient(), adapter.injectAuth, providerCredentialRetargetHeaders, true, "passthrough (provider "+providerName+")")
|
result, ok := g.proxyUpstream(w, r, upstreamURL, adapter.httpClient(), adapter.injectAuth, providerCredentialRetargetHeaders, true, "passthrough (provider "+providerName+")", adapter.requestTimeout())
|
||||||
if !ok || !result.isJSON {
|
if !ok || !result.isJSON {
|
||||||
// A build/connection/copy failure already wrote its own response
|
// A build/connection/copy failure already wrote its own response
|
||||||
// (or, for a canceled client context, wrote nothing at all — see
|
// (or, for a canceled client context, wrote nothing at all — see
|
||||||
@@ -952,7 +953,16 @@ type proxyResult struct {
|
|||||||
// passes false, since target-proxy accounting never goes past the
|
// passes false, since target-proxy accounting never goes past the
|
||||||
// request-count checkAndCount already ran before calling in, and teeing a
|
// request-count checkAndCount already ran before calling in, and teeing a
|
||||||
// response nobody will ever read back would only cost memory for nothing.
|
// response nobody will ever read back would only cost memory for nothing.
|
||||||
func (g *Gateway) proxyUpstream(w http.ResponseWriter, r *http.Request, upstreamURL string, client *http.Client, injectAuth func(*http.Request), extraStrip map[string]bool, accountJSON bool, logPrefix string) (result proxyResult, ok bool) {
|
//
|
||||||
|
// timeout (feature: request timeout) arms an idle-progress watchdog
|
||||||
|
// (watchdogBody, timeout.go) around resp.Body: handlePassthrough passes
|
||||||
|
// its adapter's own requestTimeout(), handleTargetProxy passes
|
||||||
|
// g.targetTimeout (mcp_a2a.go) — both share the SAME defect this feature
|
||||||
|
// fixes (client, above, built via newAdapterHTTPClient, previously had no
|
||||||
|
// timeout at all), so both are covered the same way. The Transport-level
|
||||||
|
// half (ResponseHeaderTimeout) is already set on client itself and needs
|
||||||
|
// no separate wiring here.
|
||||||
|
func (g *Gateway) proxyUpstream(w http.ResponseWriter, r *http.Request, upstreamURL string, client *http.Client, injectAuth func(*http.Request), extraStrip map[string]bool, accountJSON bool, logPrefix string, timeout time.Duration) (result proxyResult, ok bool) {
|
||||||
bodyReader := io.LimitReader(r.Body, maxPassthroughBytes)
|
bodyReader := io.LimitReader(r.Body, maxPassthroughBytes)
|
||||||
// gosec G704 (SSRF via taint analysis) flags upstreamURL as
|
// gosec G704 (SSRF via taint analysis) flags upstreamURL as
|
||||||
// request-derived: it is, by design — this is a reverse proxy, and its
|
// request-derived: it is, by design — this is a reverse proxy, and its
|
||||||
@@ -971,8 +981,15 @@ func (g *Gateway) proxyUpstream(w http.ResponseWriter, r *http.Request, upstream
|
|||||||
// a client cannot smuggle an encoded "/" (%2f) past this gateway's own
|
// a client cannot smuggle an encoded "/" (%2f) past this gateway's own
|
||||||
// routing only to have a permissive upstream reinterpret it as a real
|
// routing only to have a permissive upstream reinterpret it as a real
|
||||||
// separator.
|
// separator.
|
||||||
upstreamReq, err := http.NewRequestWithContext(r.Context(), r.Method, upstreamURL, bodyReader) //nolint:gosec // operator-fixed host, rest is traversal-checked and forwarded escaped; see comment above
|
// reqCtx/cancel bound the response body to timeout via watchdogBody
|
||||||
|
// (timeout.go), the same idle-progress mechanism upstreamBytes
|
||||||
|
// (providers.go) applies to every provider-adapter call — cancel is
|
||||||
|
// released either by watchdogBody (once resp.Body exists) or directly
|
||||||
|
// below, on a build/send failure that never produced a body to wrap.
|
||||||
|
reqCtx, cancel := context.WithCancel(r.Context())
|
||||||
|
upstreamReq, err := http.NewRequestWithContext(reqCtx, r.Method, upstreamURL, bodyReader) //nolint:gosec // operator-fixed host, rest is traversal-checked and forwarded escaped; see comment above
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
cancel()
|
||||||
g.errorf("%s: build upstream request: %v", logPrefix, err)
|
g.errorf("%s: build upstream request: %v", logPrefix, err)
|
||||||
writeOAIError(w, http.StatusBadGateway, "server_error", "upstream connection error")
|
writeOAIError(w, http.StatusBadGateway, "server_error", "upstream connection error")
|
||||||
return proxyResult{}, false
|
return proxyResult{}, false
|
||||||
@@ -1002,6 +1019,7 @@ func (g *Gateway) proxyUpstream(w http.ResponseWriter, r *http.Request, upstream
|
|||||||
rec(resp, err)
|
rec(resp, err)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
cancel()
|
||||||
if errors.Is(err, context.Canceled) {
|
if errors.Is(err, context.Canceled) {
|
||||||
g.logf("%s: client canceled request: %v", logPrefix, err)
|
g.logf("%s: client canceled request: %v", logPrefix, err)
|
||||||
return proxyResult{}, false
|
return proxyResult{}, false
|
||||||
@@ -1010,6 +1028,7 @@ func (g *Gateway) proxyUpstream(w http.ResponseWriter, r *http.Request, upstream
|
|||||||
writeOAIError(w, http.StatusBadGateway, "server_error", "upstream connection error")
|
writeOAIError(w, http.StatusBadGateway, "server_error", "upstream connection error")
|
||||||
return proxyResult{}, false
|
return proxyResult{}, false
|
||||||
}
|
}
|
||||||
|
resp.Body = newWatchdogBody(resp.Body, cancel, timeout, logPrefix)
|
||||||
defer resp.Body.Close() //nolint:errcheck // read-side close; nothing actionable on failure
|
defer resp.Body.Close() //nolint:errcheck // read-side close; nothing actionable on failure
|
||||||
|
|
||||||
copyHeadersExcept(w.Header(), resp.Header, hopByHopHeaders, dangerousResponseHeaders)
|
copyHeadersExcept(w.Header(), resp.Header, hopByHopHeaders, dangerousResponseHeaders)
|
||||||
@@ -1031,6 +1050,17 @@ func (g *Gateway) proxyUpstream(w http.ResponseWriter, r *http.Request, upstream
|
|||||||
}
|
}
|
||||||
|
|
||||||
if _, err := io.Copy(fw, reader); err != nil {
|
if _, err := io.Copy(fw, reader); err != nil {
|
||||||
|
// errors.Is(err, context.Canceled) here means the CLIENT went away
|
||||||
|
// mid-copy — reader wraps resp.Body, which is now a watchdogBody
|
||||||
|
// (above): its own Read never lets a timeout error wrap
|
||||||
|
// context.Canceled (watchdogBody's own doc comment, timeout.go),
|
||||||
|
// so this check still correctly separates a genuine client
|
||||||
|
// disconnect from this gateway's own idle-progress watchdog firing
|
||||||
|
// — the latter falls through to the errorf below, unchanged.
|
||||||
|
if errors.Is(err, context.Canceled) {
|
||||||
|
g.logf("%s: client canceled request: %v", logPrefix, err)
|
||||||
|
return proxyResult{isJSON: isJSON, tee: tee}, false
|
||||||
|
}
|
||||||
g.errorf("%s: stream response body: %v", logPrefix, err)
|
g.errorf("%s: stream response body: %v", logPrefix, err)
|
||||||
return proxyResult{isJSON: isJSON, tee: tee}, false
|
return proxyResult{isJSON: isJSON, tee: tee}, false
|
||||||
}
|
}
|
||||||
|
|||||||
+185
@@ -0,0 +1,185 @@
|
|||||||
|
package traefikllmgateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// defaultRequestTimeout is the progress-based upstream request timeout
|
||||||
|
// applied when neither Config.RequestTimeout nor a provider's own
|
||||||
|
// ProviderConfig.RequestTimeout is set: five minutes.
|
||||||
|
//
|
||||||
|
// Before this feature, newAdapterHTTPClient (providers.go) set no timeout
|
||||||
|
// at all — Client.Timeout was left unset, and the Transport cloned from
|
||||||
|
// http.DefaultTransport set no ResponseHeaderTimeout either. Only the dial
|
||||||
|
// (30s) and TLS handshake (10s) were bounded. A provider that accepted the
|
||||||
|
// connection and then hung — sent no headers, or sent headers and then
|
||||||
|
// stalled mid-body — hung the gateway request forever; nothing recovered
|
||||||
|
// it. This constant is a deliberate behavior change for every deployment,
|
||||||
|
// not an invisible bug fix — see README.md's "Request timeout" section.
|
||||||
|
const defaultRequestTimeout = 5 * time.Minute
|
||||||
|
|
||||||
|
// errProviderTimeout is the sentinel wrapped into the error a watchdogBody
|
||||||
|
// (below) returns once its idle-progress watchdog fires: a period of the
|
||||||
|
// resolved timeout passed with no successful read from the upstream
|
||||||
|
// response body. Distinct from context.Canceled — a genuine client
|
||||||
|
// disconnect, classified separately by handleAdapterErrorEnvelope
|
||||||
|
// (routes_unified.go) and proxyUpstream (routes_passthrough.go), both of
|
||||||
|
// which check errors.Is(err, context.Canceled) ahead of their generic
|
||||||
|
// upstream-error fallback — a watchdog firing is always this gateway
|
||||||
|
// giving up on an upstream that stopped making progress: a provider
|
||||||
|
// failure to attribute to that provider, never the client's own doing.
|
||||||
|
// watchdogBody.Read never lets a timeout error wrap context.Canceled, so
|
||||||
|
// the two stay mutually exclusive in every caller's classification.
|
||||||
|
var errProviderTimeout = errors.New("llmgateway: provider request timed out")
|
||||||
|
|
||||||
|
// resolveRequestTimeout parses raw (a Go duration string, e.g. "5m" or
|
||||||
|
// "90s" — matching RetryConfig.Backoff/CacheConfig.TTL's own convention)
|
||||||
|
// and returns defaultRequestTimeout when raw is empty: Config.
|
||||||
|
// RequestTimeout and ProviderConfig.RequestTimeout's own zero value.
|
||||||
|
//
|
||||||
|
// A non-empty raw that fails to parse, or parses to a duration <= 0, is a
|
||||||
|
// construction error. This is stricter than CacheConfig.TTL (validated by
|
||||||
|
// validateCacheConfig, cache.go), which only rejects a malformed string,
|
||||||
|
// never a non-positive one: a request timeout of zero or less is never a
|
||||||
|
// meaningful value for THIS feature specifically, because it exists only
|
||||||
|
// to fix a hang bug (newAdapterHTTPClient's pre-feature shape, described
|
||||||
|
// on defaultRequestTimeout above) — an operator writing "0s" hoping to
|
||||||
|
// restore "wait forever" must fail loudly at construction, not silently
|
||||||
|
// reintroduce the exact hang this feature exists to close. label names
|
||||||
|
// the config field in the resulting error message (e.g. "requestTimeout"
|
||||||
|
// or `provider "openai": requestTimeout`) so a bad value is attributable
|
||||||
|
// to its source.
|
||||||
|
func resolveRequestTimeout(raw, label string) (time.Duration, error) {
|
||||||
|
if raw == "" {
|
||||||
|
return defaultRequestTimeout, nil
|
||||||
|
}
|
||||||
|
d, err := time.ParseDuration(raw)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("llmgateway: %s: %w", label, err)
|
||||||
|
}
|
||||||
|
if d <= 0 {
|
||||||
|
return 0, fmt.Errorf("llmgateway: %s must be positive, got %q", label, raw)
|
||||||
|
}
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveProviderTimeout resolves the request timeout for one provider:
|
||||||
|
// providerRaw (ProviderConfig.RequestTimeout) when non-empty, validated
|
||||||
|
// via resolveRequestTimeout under a provider-attributed label — an
|
||||||
|
// explicit per-provider override always wins over the global default,
|
||||||
|
// house rule. Otherwise globalTimeout, the caller's already-resolved
|
||||||
|
// Config.RequestTimeout (buildAdapters, providers.go, resolves it once up
|
||||||
|
// front via resolveRequestTimeout, so a malformed global value fails
|
||||||
|
// construction even for a deployment where every provider happens to set
|
||||||
|
// its own override — a config bug must not lie dormant only to surface
|
||||||
|
// once someone adds a provider that relies on the default).
|
||||||
|
func resolveProviderTimeout(providerName, providerRaw string, globalTimeout time.Duration) (time.Duration, error) {
|
||||||
|
if providerRaw == "" {
|
||||||
|
return globalTimeout, nil
|
||||||
|
}
|
||||||
|
return resolveRequestTimeout(providerRaw, fmt.Sprintf("provider %q: requestTimeout", providerName))
|
||||||
|
}
|
||||||
|
|
||||||
|
// watchdogBody wraps an upstream response body so that a period of
|
||||||
|
// timeout with no successful read aborts the request instead of blocking
|
||||||
|
// forever. It is the idle-progress half of this feature's two mechanisms
|
||||||
|
// (Transport.ResponseHeaderTimeout, set in newAdapterHTTPClient, is the
|
||||||
|
// other — it only bounds the wait for headers, never the body).
|
||||||
|
//
|
||||||
|
// Deliberately NOT http.Client.Timeout: that bounds the whole request
|
||||||
|
// including the body read, which would truncate a healthy, long-running
|
||||||
|
// stream — exactly what this feature must never do. A watchdogBody
|
||||||
|
// instead resets on every successful read, so a stream (or a large
|
||||||
|
// non-streaming body) that keeps making progress runs indefinitely; only
|
||||||
|
// SILENCE, never total duration, ends it.
|
||||||
|
//
|
||||||
|
// The mechanism: cancel is the CancelFunc for the *http.Request's own
|
||||||
|
// context, derived via context.WithCancel by the caller that built the
|
||||||
|
// request (upstreamBytes, providers.go; proxyUpstream, routes_passthrough.
|
||||||
|
// go) before this watchdogBody existed. Every successful Read resets timer
|
||||||
|
// to fire timeout in the future again; if timer fires first, fire cancels
|
||||||
|
// the request context, which unblocks whatever Read is currently in
|
||||||
|
// flight (or the next one) with an error. Read then substitutes that
|
||||||
|
// error for one wrapping errProviderTimeout, naming providerName, instead
|
||||||
|
// of passing through whatever the canceled context's own error text says
|
||||||
|
// — a real client disconnect (the parent context canceling instead of
|
||||||
|
// this watchdog) never sets timedOut, so it is never misreported this way.
|
||||||
|
// Close stops the timer (a no-op once already fired) and always calls
|
||||||
|
// cancel, so this watchdog never leaves a live timer or an un-canceled
|
||||||
|
// derived context behind on the hot path — every upstream request this
|
||||||
|
// gateway makes.
|
||||||
|
//
|
||||||
|
// timedOut is accessed via sync/atomic's function API (atomic.StoreInt32/
|
||||||
|
// LoadInt32), not the newer atomic.Bool type, per this plugin's
|
||||||
|
// yaegi-interpreted-stdlib guardrail.
|
||||||
|
type watchdogBody struct {
|
||||||
|
rc io.ReadCloser
|
||||||
|
cancel context.CancelFunc
|
||||||
|
timer *time.Timer
|
||||||
|
providerName string
|
||||||
|
timeout time.Duration
|
||||||
|
timedOut int32
|
||||||
|
}
|
||||||
|
|
||||||
|
// newWatchdogBody returns a watchdogBody wrapping rc, arming its watchdog
|
||||||
|
// for timeout starting now — the moment the response's headers arrived,
|
||||||
|
// so even the very first body read is bounded by the same window ("sends
|
||||||
|
// headers then stalls mid-body" is caught here, not left to
|
||||||
|
// ResponseHeaderTimeout, which has already been satisfied by the time a
|
||||||
|
// body exists to wrap).
|
||||||
|
func newWatchdogBody(rc io.ReadCloser, cancel context.CancelFunc, timeout time.Duration, providerName string) *watchdogBody {
|
||||||
|
wb := &watchdogBody{rc: rc, cancel: cancel, timeout: timeout, providerName: providerName}
|
||||||
|
wb.timer = time.AfterFunc(timeout, wb.fire)
|
||||||
|
return wb
|
||||||
|
}
|
||||||
|
|
||||||
|
// fire runs once, in its own goroutine (time.AfterFunc's own contract —
|
||||||
|
// no goroutine is ever spawned for a request whose timer is Stopped
|
||||||
|
// before it fires, which is the common, non-timed-out case), the moment
|
||||||
|
// timeout passes with no successful Read: it marks timedOut so the next
|
||||||
|
// Read error substitutes the provider-attributed timeout error, then
|
||||||
|
// cancels the request context, unblocking whatever Read is currently
|
||||||
|
// parked waiting on the network.
|
||||||
|
func (wb *watchdogBody) fire() {
|
||||||
|
atomic.StoreInt32(&wb.timedOut, 1)
|
||||||
|
wb.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read implements io.Reader. A successful read (err == nil) resets the
|
||||||
|
// watchdog for another full timeout window — this is what lets a stream
|
||||||
|
// that keeps producing chunks run indefinitely: only silence, never total
|
||||||
|
// duration, ends it. A failed read while timedOut is set (fire already
|
||||||
|
// ran) is reported as errProviderTimeout, naming providerName and the
|
||||||
|
// configured timeout, instead of whatever error canceling the request
|
||||||
|
// context actually produced. Any other error — a real client disconnect,
|
||||||
|
// a clean EOF, a genuine network failure that has nothing to do with this
|
||||||
|
// watchdog — passes through unchanged.
|
||||||
|
func (wb *watchdogBody) Read(p []byte) (int, error) {
|
||||||
|
n, err := wb.rc.Read(p)
|
||||||
|
if err != nil {
|
||||||
|
if atomic.LoadInt32(&wb.timedOut) == 1 {
|
||||||
|
return n, fmt.Errorf("%w: %w: provider %q: no upstream progress for %s", errUpstream, errProviderTimeout, wb.providerName, wb.timeout)
|
||||||
|
}
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
wb.timer.Reset(wb.timeout)
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close stops the watchdog timer (a harmless no-op if it already fired)
|
||||||
|
// and always calls cancel, so the derived request context this
|
||||||
|
// watchdogBody's cancel belongs to is released promptly — whether Close
|
||||||
|
// is reached because the body was read to completion, because the caller
|
||||||
|
// gave up early, or because the watchdog itself already fired — instead
|
||||||
|
// of being left to be reclaimed only once its parent context ends. Then
|
||||||
|
// closes the wrapped body.
|
||||||
|
func (wb *watchdogBody) Close() error {
|
||||||
|
wb.timer.Stop()
|
||||||
|
wb.cancel()
|
||||||
|
return wb.rc.Close()
|
||||||
|
}
|
||||||
+694
@@ -0,0 +1,694 @@
|
|||||||
|
package traefikllmgateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newTimeoutTestAdapter returns an *openaiAdapter pointed at baseURL with
|
||||||
|
// its request timeout resolved to timeout — mirroring exactly what
|
||||||
|
// buildAdapters (providers.go) does after newOpenAIAdapter's own bare
|
||||||
|
// construction: set .timeout, then rebuild .client via
|
||||||
|
// newAdapterHTTPClient(timeout) so Transport.ResponseHeaderTimeout
|
||||||
|
// reflects it too.
|
||||||
|
func newTimeoutTestAdapter(baseURL string, timeout time.Duration) *openaiAdapter {
|
||||||
|
a := newOpenAIAdapter("p1", baseURL, "sk-test")
|
||||||
|
a.timeout = timeout
|
||||||
|
a.client = newAdapterHTTPClient(timeout)
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
// newStallingServer returns an httptest.Server whose single handler runs
|
||||||
|
// prewrite (nil is a no-op — writes nothing) and then blocks until
|
||||||
|
// release is called.
|
||||||
|
//
|
||||||
|
// Deliberately NOT `<-r.Context().Done()`: verified empirically that,
|
||||||
|
// in this environment, neither a client-side Transport.
|
||||||
|
// ResponseHeaderTimeout nor a client-side context.CancelFunc reliably
|
||||||
|
// unblocks a server handler parked on its own r.Context().Done() within
|
||||||
|
// a bounded time — httptest.Server.Close hung past 60s waiting for the
|
||||||
|
// connection in both cases. That is a property of net/http's server-side
|
||||||
|
// close detection under httptest, not of this feature's client-side
|
||||||
|
// behavior (which every test below still verifies independently, via the
|
||||||
|
// CLIENT-side call returning promptly). release decouples test cleanup
|
||||||
|
// from that detection entirely: the handler exits the instant the test
|
||||||
|
// explicitly says so, so Close never has anything left to wait for.
|
||||||
|
func newStallingServer(prewrite func(http.ResponseWriter)) (srv *httptest.Server, release func()) {
|
||||||
|
done := make(chan struct{})
|
||||||
|
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if prewrite != nil {
|
||||||
|
prewrite(w)
|
||||||
|
}
|
||||||
|
<-done
|
||||||
|
}))
|
||||||
|
return srv, func() {
|
||||||
|
close(done)
|
||||||
|
srv.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestChatCompletion_StreamKeepsEmittingPastTimeout_CompletesIntact is the
|
||||||
|
// single highest-priority test in this feature (written first, per the
|
||||||
|
// task brief): a streaming response whose TOTAL duration exceeds the
|
||||||
|
// configured timeout, but whose every INTER-CHUNK gap stays well under
|
||||||
|
// it, must complete intact — this is the exact behavior a total-duration
|
||||||
|
// cap (explicitly rejected by the operator) would break, and the whole
|
||||||
|
// reason the watchdog resets on every successful read instead of firing
|
||||||
|
// once at a fixed deadline.
|
||||||
|
//
|
||||||
|
// MUTATION VERIFIED: commenting out watchdogBody.Read's `wb.timer.Reset(
|
||||||
|
// wb.timeout)` (timeout.go) turns the idle watchdog into a de facto
|
||||||
|
// total-duration cap — the timer set at construction fires once, at
|
||||||
|
// timeout after the first byte, regardless of intervening progress. Under
|
||||||
|
// that mutation this test failed with "chatCompletion: ... provider
|
||||||
|
// request timed out" partway through the stream, confirming it kills the
|
||||||
|
// exact regression this test exists to catch. Reverted before committing.
|
||||||
|
func TestChatCompletion_StreamKeepsEmittingPastTimeout_CompletesIntact(t *testing.T) {
|
||||||
|
const (
|
||||||
|
timeout = 80 * time.Millisecond
|
||||||
|
chunkGap = 30 * time.Millisecond
|
||||||
|
numChunks = 6 // 6*30ms = 180ms total: well past timeout, but no single gap is
|
||||||
|
)
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
fl := w.(http.Flusher)
|
||||||
|
for i := 0; i < numChunks; i++ {
|
||||||
|
time.Sleep(chunkGap)
|
||||||
|
_, _ = fmt.Fprintf(w, "data: {\"id\":\"c\",\"choices\":[{\"delta\":{\"content\":\"tok%d\"}}]}\n\n", i)
|
||||||
|
fl.Flush()
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprint(w, "data: [DONE]\n\n")
|
||||||
|
fl.Flush()
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
a := newTimeoutTestAdapter(srv.URL, timeout)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
if _, err := a.chatCompletion(context.Background(), rec, map[string]any{"model": "gpt-5", "messages": []any{}, "stream": true}); err != nil {
|
||||||
|
t.Fatalf("chatCompletion: %v (a stream whose total duration exceeds the timeout, but whose every gap stays under it, must complete — a total-duration cap was explicitly rejected)", err)
|
||||||
|
}
|
||||||
|
body := rec.Body.String()
|
||||||
|
for i := 0; i < numChunks; i++ {
|
||||||
|
want := fmt.Sprintf("tok%d", i)
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("rec.Body missing %q — stream was truncated: %s", want, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "[DONE]") {
|
||||||
|
t.Errorf("rec.Body missing [DONE] terminator — stream did not complete: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestChatCompletion_NeverSendsHeaders_AbortedAtTimeout proves the
|
||||||
|
// Transport.ResponseHeaderTimeout mechanism (newAdapterHTTPClient,
|
||||||
|
// providers.go): a provider that accepts the connection and then writes
|
||||||
|
// nothing at all is aborted, not left hanging forever.
|
||||||
|
//
|
||||||
|
// MUTATION VERIFIED: removing `tr.ResponseHeaderTimeout = timeout` from
|
||||||
|
// newAdapterHTTPClient (providers.go) made this test hang past a 5s
|
||||||
|
// `go test -run ... -timeout 5s` bound (test process killed, reported
|
||||||
|
// FAIL) — confirming no other mechanism bounds a provider that never
|
||||||
|
// sends headers. Reverted before committing.
|
||||||
|
func TestChatCompletion_NeverSendsHeaders_AbortedAtTimeout(t *testing.T) {
|
||||||
|
const timeout = 60 * time.Millisecond
|
||||||
|
srv, release := newStallingServer(nil)
|
||||||
|
defer release()
|
||||||
|
|
||||||
|
a := newTimeoutTestAdapter(srv.URL, timeout)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
_, err := a.chatCompletion(context.Background(), rec, map[string]any{"model": "gpt-5", "messages": []any{}})
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("chatCompletion: want an error for a provider that never sends headers, got nil")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, errUpstream) {
|
||||||
|
t.Errorf("err = %v, want it to wrap errUpstream", err)
|
||||||
|
}
|
||||||
|
if elapsed > 2*time.Second {
|
||||||
|
t.Errorf("chatCompletion took %s to abort a %s ResponseHeaderTimeout", elapsed, timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestChatCompletion_Stream_StallsMidBody_AbortedAtTimeout proves the
|
||||||
|
// idle-progress watchdog (watchdogBody, timeout.go): a provider that
|
||||||
|
// sends headers and one chunk, then goes silent, is aborted — proving
|
||||||
|
// ResponseHeaderTimeout alone (already satisfied once headers arrived) is
|
||||||
|
// not enough, and something must also bound the body.
|
||||||
|
//
|
||||||
|
// MUTATION VERIFIED: deleting the `resp.Body = newWatchdogBody(...)` line
|
||||||
|
// in upstreamBytes (providers.go) made this test hang past a 5s
|
||||||
|
// `-timeout 5s` bound (same failure mode as the ResponseHeaderTimeout
|
||||||
|
// test above, confirming this is a genuinely separate mechanism).
|
||||||
|
// Reverted before committing.
|
||||||
|
func TestChatCompletion_Stream_StallsMidBody_AbortedAtTimeout(t *testing.T) {
|
||||||
|
const timeout = 60 * time.Millisecond
|
||||||
|
srv, release := newStallingServer(func(w http.ResponseWriter) {
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
fl := w.(http.Flusher)
|
||||||
|
_, _ = fmt.Fprint(w, "data: {\"id\":\"c\",\"choices\":[{\"delta\":{\"content\":\"hi\"}}]}\n\n")
|
||||||
|
fl.Flush()
|
||||||
|
})
|
||||||
|
defer release()
|
||||||
|
|
||||||
|
a := newTimeoutTestAdapter(srv.URL, timeout)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
_, err := a.chatCompletion(context.Background(), rec, map[string]any{"model": "gpt-5", "messages": []any{}, "stream": true})
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("chatCompletion: want an error for a stream that stalls mid-body, got nil")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, errProviderTimeout) {
|
||||||
|
t.Errorf("err = %v, want errors.Is(err, errProviderTimeout)", err)
|
||||||
|
}
|
||||||
|
if elapsed > 2*time.Second {
|
||||||
|
t.Errorf("chatCompletion took %s to abort a %s idle timeout", elapsed, timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestChatCompletion_NonStream_StallsMidBody_AbortedAtTimeout is the
|
||||||
|
// non-streaming counterpart: "Non-streaming: at most timeout waiting for
|
||||||
|
// the response" (the task brief) is satisfied by the SAME watchdogBody
|
||||||
|
// mechanism as streaming — forwardJSON's io.ReadAll blocks on Read just
|
||||||
|
// like forwardStream's readSSE does, so one mechanism covers both
|
||||||
|
// documented behaviors without a second, total-duration-shaped one.
|
||||||
|
func TestChatCompletion_NonStream_StallsMidBody_AbortedAtTimeout(t *testing.T) {
|
||||||
|
const timeout = 60 * time.Millisecond
|
||||||
|
srv, release := newStallingServer(func(w http.ResponseWriter) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
fl := w.(http.Flusher)
|
||||||
|
_, _ = fmt.Fprint(w, `{"id":"c1",`) // deliberately incomplete JSON: a real close never arrives
|
||||||
|
fl.Flush()
|
||||||
|
})
|
||||||
|
defer release()
|
||||||
|
|
||||||
|
a := newTimeoutTestAdapter(srv.URL, timeout)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
_, err := a.chatCompletion(context.Background(), rec, map[string]any{"model": "gpt-5", "messages": []any{}})
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("chatCompletion: want an error for a non-streaming body that stalls mid-write, got nil")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, errProviderTimeout) {
|
||||||
|
t.Errorf("err = %v, want errors.Is(err, errProviderTimeout)", err)
|
||||||
|
}
|
||||||
|
if elapsed > 2*time.Second {
|
||||||
|
t.Errorf("chatCompletion took %s to abort a %s idle timeout", elapsed, timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestChatCompletion_Timeout_ClassifiedAsProviderFailure_DistinctFromClientDisconnect
|
||||||
|
// proves the non-negotiable classification requirement: a watchdog-fired
|
||||||
|
// timeout must wrap errProviderTimeout and name the provider, and must
|
||||||
|
// never be confusable with a genuine client disconnect (context.Canceled)
|
||||||
|
// — and vice versa. handleAdapterErrorEnvelope (routes_unified.go) relies
|
||||||
|
// on exactly this mutual exclusivity to decide whether to log-only (a
|
||||||
|
// client that walked away) or report a provider failure with a 502.
|
||||||
|
//
|
||||||
|
// MUTATION VERIFIED: changing watchdogBody.Read to return the raw
|
||||||
|
// underlying error unchanged (dropping the errProviderTimeout wrap
|
||||||
|
// entirely) made the first subtest fail its errors.Is(err,
|
||||||
|
// errProviderTimeout) assertion, confirming the test actually checks the
|
||||||
|
// wrap rather than merely "an error occurred". Reverted before
|
||||||
|
// committing.
|
||||||
|
func TestChatCompletion_Timeout_ClassifiedAsProviderFailure_DistinctFromClientDisconnect(t *testing.T) {
|
||||||
|
t.Run("watchdog timeout wraps errProviderTimeout, names the provider, never context.Canceled", func(t *testing.T) {
|
||||||
|
const timeout = 60 * time.Millisecond
|
||||||
|
srv, release := newStallingServer(func(w http.ResponseWriter) {
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.(http.Flusher).Flush()
|
||||||
|
})
|
||||||
|
defer release()
|
||||||
|
|
||||||
|
a := newTimeoutTestAdapter(srv.URL, timeout)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
_, err := a.chatCompletion(context.Background(), rec, map[string]any{"model": "gpt-5", "messages": []any{}, "stream": true})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("want an error")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, errProviderTimeout) {
|
||||||
|
t.Errorf("err = %v, want errors.Is(err, errProviderTimeout)", err)
|
||||||
|
}
|
||||||
|
if errors.Is(err, context.Canceled) {
|
||||||
|
t.Errorf("err = %v, must NOT satisfy errors.Is(err, context.Canceled) — a watchdog firing is a provider failure, not a client disconnect", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), `"p1"`) {
|
||||||
|
t.Errorf("err = %v, want it to name the provider (%q)", err, "p1")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("client cancellation before headers wraps context.Canceled, never errProviderTimeout", func(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
a := newTimeoutTestAdapter(srv.URL, 5*time.Second) // generous: must never fire
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
go func() {
|
||||||
|
time.Sleep(30 * time.Millisecond)
|
||||||
|
cancel()
|
||||||
|
}()
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
_, err := a.chatCompletion(ctx, rec, map[string]any{"model": "gpt-5", "messages": []any{}})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("want an error")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
t.Errorf("err = %v, want errors.Is(err, context.Canceled)", err)
|
||||||
|
}
|
||||||
|
if errors.Is(err, errProviderTimeout) {
|
||||||
|
t.Errorf("err = %v, must NOT satisfy errors.Is(err, errProviderTimeout) — this is a client disconnect, not a provider failure", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("client cancellation mid-stream (after headers) wraps context.Canceled, never errProviderTimeout", func(t *testing.T) {
|
||||||
|
srv, release := newStallingServer(func(w http.ResponseWriter) {
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
fl := w.(http.Flusher)
|
||||||
|
_, _ = fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}]}\n\n")
|
||||||
|
fl.Flush()
|
||||||
|
})
|
||||||
|
defer release()
|
||||||
|
|
||||||
|
a := newTimeoutTestAdapter(srv.URL, 5*time.Second) // generous: must never fire
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
go func() {
|
||||||
|
time.Sleep(30 * time.Millisecond)
|
||||||
|
cancel()
|
||||||
|
}()
|
||||||
|
_, err := a.chatCompletion(ctx, rec, map[string]any{"model": "gpt-5", "messages": []any{}, "stream": true})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("want an error")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
t.Errorf("err = %v, want errors.Is(err, context.Canceled)", err)
|
||||||
|
}
|
||||||
|
if errors.Is(err, errProviderTimeout) {
|
||||||
|
t.Errorf("err = %v, must NOT satisfy errors.Is(err, errProviderTimeout) — watchdogBody must pass a real client cancel through unchanged", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestResolveRequestTimeout is table-driven over resolveRequestTimeout's
|
||||||
|
// full contract: empty means the default; a valid duration parses; a
|
||||||
|
// malformed string, a zero, and a negative duration are all construction
|
||||||
|
// errors — the zero/negative case never means "no timeout" (that is the
|
||||||
|
// bug this feature fixes).
|
||||||
|
//
|
||||||
|
// MUTATION VERIFIED: removing the `if d <= 0 { return error }` branch
|
||||||
|
// (timeout.go) made the "zero" and "negative" cases return (0, nil)
|
||||||
|
// instead of an error, failing this test's assertions for both. Reverted
|
||||||
|
// before committing.
|
||||||
|
func TestResolveRequestTimeout(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
raw string
|
||||||
|
want time.Duration
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{name: "empty means default", raw: "", want: defaultRequestTimeout},
|
||||||
|
{name: "valid minutes", raw: "5m", want: 5 * time.Minute},
|
||||||
|
{name: "valid seconds", raw: "90s", want: 90 * time.Second},
|
||||||
|
{name: "malformed string is an error", raw: "not-a-duration", wantErr: true},
|
||||||
|
{name: "zero is an error, not unlimited", raw: "0s", wantErr: true},
|
||||||
|
{name: "negative is an error", raw: "-5s", wantErr: true},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
got, err := resolveRequestTimeout(c.raw, "requestTimeout")
|
||||||
|
if c.wantErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("resolveRequestTimeout(%q) = (%v, nil), want an error", c.raw, got)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolveRequestTimeout(%q): %v", c.raw, err)
|
||||||
|
}
|
||||||
|
if got != c.want {
|
||||||
|
t.Errorf("resolveRequestTimeout(%q) = %v, want %v", c.raw, got, c.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestResolveProviderTimeout_OverrideBeatsGlobal proves the house rule
|
||||||
|
// ("explicit override always wins") for this feature specifically: a
|
||||||
|
// non-empty per-provider raw wins regardless of what globalTimeout is,
|
||||||
|
// and an empty one inherits globalTimeout exactly.
|
||||||
|
func TestResolveProviderTimeout_OverrideBeatsGlobal(t *testing.T) {
|
||||||
|
const global = 2 * time.Minute
|
||||||
|
|
||||||
|
got, err := resolveProviderTimeout("p1", "30s", global)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolveProviderTimeout: %v", err)
|
||||||
|
}
|
||||||
|
if got != 30*time.Second {
|
||||||
|
t.Errorf("provider override: got %v, want 30s (must win over the 2m global)", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err = resolveProviderTimeout("p1", "", global)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolveProviderTimeout: %v", err)
|
||||||
|
}
|
||||||
|
if got != global {
|
||||||
|
t.Errorf("empty provider override: got %v, want %v (inherit the global)", got, global)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := resolveProviderTimeout("p1", "0s", global); err == nil {
|
||||||
|
t.Error("resolveProviderTimeout(\"p1\", \"0s\", ...): want an error, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuildAdapters_RequestTimeout covers the full config-resolution
|
||||||
|
// matrix end to end through buildAdapters, matching the sibling
|
||||||
|
// TestBuildAdapters_WiresRetryPolicyFromConfig's own style (retry_test.go):
|
||||||
|
// unset everywhere resolves to five minutes; a global default applies
|
||||||
|
// when a provider sets no override; an explicit provider override wins
|
||||||
|
// over the global; and a malformed value at either level fails
|
||||||
|
// construction — including a malformed GLOBAL value that happens to be
|
||||||
|
// shadowed by every provider's own valid override, proving the global is
|
||||||
|
// still validated unconditionally rather than lying dormant.
|
||||||
|
func TestBuildAdapters_RequestTimeout(t *testing.T) {
|
||||||
|
t.Run("unset everywhere yields the five-minute default", func(t *testing.T) {
|
||||||
|
cfg := CreateConfig()
|
||||||
|
cfg.Providers = map[string]*ProviderConfig{"p1": {Type: "openai", APIKey: "k"}}
|
||||||
|
adapters, err := buildAdapters(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildAdapters: %v", err)
|
||||||
|
}
|
||||||
|
oa := adapters["p1"].(*openaiAdapter)
|
||||||
|
if oa.timeout != defaultRequestTimeout {
|
||||||
|
t.Errorf("oa.timeout = %v, want %v", oa.timeout, defaultRequestTimeout)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("global default applies when the provider sets no override", func(t *testing.T) {
|
||||||
|
cfg := CreateConfig()
|
||||||
|
cfg.RequestTimeout = "2m"
|
||||||
|
cfg.Providers = map[string]*ProviderConfig{"p1": {Type: "openai", APIKey: "k"}}
|
||||||
|
adapters, err := buildAdapters(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildAdapters: %v", err)
|
||||||
|
}
|
||||||
|
oa := adapters["p1"].(*openaiAdapter)
|
||||||
|
if oa.timeout != 2*time.Minute {
|
||||||
|
t.Errorf("oa.timeout = %v, want 2m", oa.timeout)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("provider override wins over the global", func(t *testing.T) {
|
||||||
|
cfg := CreateConfig()
|
||||||
|
cfg.RequestTimeout = "2m"
|
||||||
|
cfg.Providers = map[string]*ProviderConfig{"p1": {Type: "openai", APIKey: "k", RequestTimeout: "30s"}}
|
||||||
|
adapters, err := buildAdapters(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildAdapters: %v", err)
|
||||||
|
}
|
||||||
|
oa := adapters["p1"].(*openaiAdapter)
|
||||||
|
if oa.timeout != 30*time.Second {
|
||||||
|
t.Errorf("oa.timeout = %v, want 30s (override must win over the 2m global)", oa.timeout)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("malformed global fails construction even when every provider overrides it", func(t *testing.T) {
|
||||||
|
cfg := CreateConfig()
|
||||||
|
cfg.RequestTimeout = "not-a-duration"
|
||||||
|
cfg.Providers = map[string]*ProviderConfig{"p1": {Type: "openai", APIKey: "k", RequestTimeout: "30s"}}
|
||||||
|
if _, err := buildAdapters(cfg); err == nil {
|
||||||
|
t.Fatal("buildAdapters: want an error for a malformed global requestTimeout, got nil (it must be validated even when shadowed)")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("zero global is a construction error", func(t *testing.T) {
|
||||||
|
cfg := CreateConfig()
|
||||||
|
cfg.RequestTimeout = "0s"
|
||||||
|
cfg.Providers = map[string]*ProviderConfig{"p1": {Type: "openai", APIKey: "k"}}
|
||||||
|
if _, err := buildAdapters(cfg); err == nil {
|
||||||
|
t.Fatal("buildAdapters: want an error for requestTimeout: \"0s\", got nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("negative provider override is a construction error", func(t *testing.T) {
|
||||||
|
cfg := CreateConfig()
|
||||||
|
cfg.Providers = map[string]*ProviderConfig{"p1": {Type: "openai", APIKey: "k", RequestTimeout: "-1s"}}
|
||||||
|
if _, err := buildAdapters(cfg); err == nil {
|
||||||
|
t.Fatal("buildAdapters: want an error for a provider requestTimeout of \"-1s\", got nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewAdapterHTTPClient_SetsResponseHeaderTimeout proves
|
||||||
|
// newAdapterHTTPClient wires its timeout argument onto the returned
|
||||||
|
// client's Transport.ResponseHeaderTimeout — the mechanism
|
||||||
|
// TestChatCompletion_NeverSendsHeaders_AbortedAtTimeout exercises
|
||||||
|
// end to end; this is the narrower, direct check.
|
||||||
|
func TestNewAdapterHTTPClient_SetsResponseHeaderTimeout(t *testing.T) {
|
||||||
|
const want = 42 * time.Second
|
||||||
|
c := newAdapterHTTPClient(want)
|
||||||
|
tr, ok := c.Transport.(*http.Transport)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("client.Transport is %T, want *http.Transport", c.Transport)
|
||||||
|
}
|
||||||
|
if tr.ResponseHeaderTimeout != want {
|
||||||
|
t.Errorf("Transport.ResponseHeaderTimeout = %v, want %v", tr.ResponseHeaderTimeout, want)
|
||||||
|
}
|
||||||
|
if c.Timeout != 0 {
|
||||||
|
t.Errorf("Client.Timeout = %v, want 0 (unset) — it would bound the whole request including body read, truncating a healthy long stream", c.Timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeReadCloser is a minimal io.ReadCloser test double for watchdogBody's
|
||||||
|
// own unit tests below: Read always blocks until unblock is closed (or
|
||||||
|
// forever if it never is), simulating a stalled upstream body without a
|
||||||
|
// real network connection.
|
||||||
|
type fakeReadCloser struct {
|
||||||
|
unblock chan struct{}
|
||||||
|
closed chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakeReadCloser() *fakeReadCloser {
|
||||||
|
return &fakeReadCloser{unblock: make(chan struct{}), closed: make(chan struct{})}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeReadCloser) Read(p []byte) (int, error) {
|
||||||
|
<-f.unblock
|
||||||
|
return 0, io.EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeReadCloser) Close() error {
|
||||||
|
select {
|
||||||
|
case <-f.closed:
|
||||||
|
default:
|
||||||
|
close(f.closed)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWatchdogBody_Close_StopsTimer_FireNeverRuns proves Close's "no timer
|
||||||
|
// or goroutine leak" contract directly and deterministically (not via
|
||||||
|
// runtime.NumGoroutine, which only observes a timer once it has already
|
||||||
|
// fired): closing a watchdogBody before its timeout elapses must stop the
|
||||||
|
// timer outright, so fire (and therefore cancel) never runs even once
|
||||||
|
// timeout has since passed.
|
||||||
|
//
|
||||||
|
// MUTATION VERIFIED: removing `wb.timer.Stop()` from watchdogBody.Close
|
||||||
|
// (timeout.go) made this test fail — timedOut read back as 1 after the
|
||||||
|
// sleep, since the un-stopped timer still fired on schedule. Reverted
|
||||||
|
// before committing.
|
||||||
|
func TestWatchdogBody_Close_StopsTimer_FireNeverRuns(t *testing.T) {
|
||||||
|
const timeout = 30 * time.Millisecond
|
||||||
|
frc := newFakeReadCloser()
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
wb := newWatchdogBody(frc, cancel, timeout, "p1")
|
||||||
|
|
||||||
|
if err := wb.Close(); err != nil {
|
||||||
|
t.Fatalf("Close: %v", err)
|
||||||
|
}
|
||||||
|
time.Sleep(3 * timeout) // well past when an un-stopped timer would fire
|
||||||
|
|
||||||
|
if got := wb.timedOut; got != 0 {
|
||||||
|
t.Errorf("wb.timedOut = %d after Close, want 0 — the timer must not fire once stopped", got)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
// Close itself calls cancel unconditionally (its own doc comment),
|
||||||
|
// so ctx.Done() firing here is expected and does not indicate fire
|
||||||
|
// ran — only wb.timedOut distinguishes that.
|
||||||
|
default:
|
||||||
|
t.Error("ctx.Done() not closed after wb.Close() — Close must call cancel unconditionally")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWatchdogBody_NoBlockingGoroutineLeak_AcrossManyRequests drives many
|
||||||
|
// successful (fast) and many timed-out requests through a real adapter
|
||||||
|
// and asserts the live goroutine count returns to its baseline afterward
|
||||||
|
// — proving neither path leaves a goroutine permanently blocked. The
|
||||||
|
// task's own concern ("do not leak a goroutine or a timer per request")
|
||||||
|
// is specifically about a goroutine that never exits (the classic
|
||||||
|
// `go func(){ select{ case <-time.After(d): ...; case <-done: } }()`
|
||||||
|
// leak, when done is never closed on the success path); watchdogBody's
|
||||||
|
// actual design (time.AfterFunc + Stop) never spawns a goroutine at all
|
||||||
|
// unless the watchdog fires, and that goroutine runs fire() to
|
||||||
|
// completion and exits immediately either way.
|
||||||
|
//
|
||||||
|
// MUTATION VERIFIED: adding `go func() { <-make(chan struct{}) }()` (a
|
||||||
|
// permanently blocked goroutine) to the top of watchdogBody.fire
|
||||||
|
// (timeout.go) made this test fail — the goroutine count after the
|
||||||
|
// timed-out-request loop stayed elevated by roughly the loop's iteration
|
||||||
|
// count and never settled within the poll window, confirming the test
|
||||||
|
// catches a real per-request leak. Reverted before committing.
|
||||||
|
func TestWatchdogBody_NoBlockingGoroutineLeak_AcrossManyRequests(t *testing.T) {
|
||||||
|
const (
|
||||||
|
iterations = 40
|
||||||
|
fastTimeout = 2 * time.Second // generous: must never fire for the success path
|
||||||
|
hangTimeout = 20 * time.Millisecond
|
||||||
|
)
|
||||||
|
|
||||||
|
fastSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte(`{"id":"c1","choices":[{"message":{"role":"assistant","content":"hi"}}],"usage":{"prompt_tokens":1,"completion_tokens":1}}`))
|
||||||
|
}))
|
||||||
|
defer fastSrv.Close()
|
||||||
|
|
||||||
|
hangSrv, release := newStallingServer(nil)
|
||||||
|
|
||||||
|
runtime.GC()
|
||||||
|
baseline := runtime.NumGoroutine()
|
||||||
|
|
||||||
|
fast := newTimeoutTestAdapter(fastSrv.URL, fastTimeout)
|
||||||
|
for i := 0; i < iterations; i++ {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
if _, err := fast.chatCompletion(context.Background(), rec, map[string]any{"model": "gpt-5", "messages": []any{}}); err != nil {
|
||||||
|
t.Fatalf("fast request %d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hang := newTimeoutTestAdapter(hangSrv.URL, hangTimeout)
|
||||||
|
for i := 0; i < iterations; i++ {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
if _, err := hang.chatCompletion(context.Background(), rec, map[string]any{"model": "gpt-5", "messages": []any{}}); err == nil {
|
||||||
|
t.Fatalf("hung request %d: want a timeout error, got nil", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// release BEFORE measuring: each of the iterations above left its own
|
||||||
|
// server-side handler goroutine parked on newStallingServer's <-done
|
||||||
|
// (this test's own fixture design, not this feature's watchdog code)
|
||||||
|
// — releasing them here, before the goroutine count below, keeps the
|
||||||
|
// measurement about the watchdog's own goroutines settling, not about
|
||||||
|
// this test's still-blocked handlers swamping the signal.
|
||||||
|
release()
|
||||||
|
|
||||||
|
deadline := time.Now().Add(3 * time.Second)
|
||||||
|
var final int
|
||||||
|
const slack = 5 // unrelated Go runtime/http.Transport bookkeeping goroutines, not this feature's concern
|
||||||
|
for {
|
||||||
|
runtime.GC()
|
||||||
|
final = runtime.NumGoroutine()
|
||||||
|
if final <= baseline+slack || time.Now().After(deadline) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if final > baseline+slack {
|
||||||
|
t.Errorf("goroutines after %d fast + %d timed-out requests = %d, baseline = %d (slack %d) — possible leak", iterations, iterations, final, baseline, slack)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandlePassthrough_StalledUpstream_AbortedAtTimeout proves the
|
||||||
|
// passthrough route (routes_passthrough.go's proxyUpstream) shares this
|
||||||
|
// feature's idle-progress watchdog, not just each provider adapter's own
|
||||||
|
// upstreamBytes call path — the task brief's explicit "also cover the
|
||||||
|
// passthrough route" requirement. adapter.httpClient() and
|
||||||
|
// adapter.requestTimeout() are the SAME values chatCompletion uses, so a
|
||||||
|
// provider's requestTimeout config applies identically through native
|
||||||
|
// passthrough.
|
||||||
|
func TestHandlePassthrough_StalledUpstream_AbortedAtTimeout(t *testing.T) {
|
||||||
|
srv, release := newStallingServer(nil)
|
||||||
|
defer release()
|
||||||
|
|
||||||
|
cfg := CreateConfig()
|
||||||
|
cfg.Providers = map[string]*ProviderConfig{
|
||||||
|
"openai": {Type: "openai", BaseURL: srv.URL, APIKey: "sk-up", RequestTimeout: "60ms"},
|
||||||
|
}
|
||||||
|
cfg.Groups = map[string]*GroupConfig{"default": {}}
|
||||||
|
cfg.Users = &UsersConfig{Inline: []*UserConfig{
|
||||||
|
{Name: "alice", Group: "default", APIKey: "sk-alice", Limits: &LimitsConfig{}},
|
||||||
|
}}
|
||||||
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
|
||||||
|
h, err := New(context.Background(), next, cfg, "llmgw")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New: %v", err)
|
||||||
|
}
|
||||||
|
gw := h.(*Gateway)
|
||||||
|
gw.limiter.spawn = func(f func()) { f() }
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/openai/v1/native-endpoint", strings.NewReader(`{}`))
|
||||||
|
req.Header.Set("Authorization", "Bearer sk-alice")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadGateway {
|
||||||
|
t.Errorf("status = %d, want 502, body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if elapsed > 3*time.Second {
|
||||||
|
t.Errorf("passthrough took %s to abort a 60ms provider requestTimeout", elapsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewGateway_TargetTimeout_ResolvedFromConfig proves the MCP/A2A
|
||||||
|
// target proxy's own client (g.targetClient, llmgateway.go) picks up
|
||||||
|
// Config.RequestTimeout the same way a provider adapter does — g.
|
||||||
|
// targetTimeout is what proxyUpstream's handleTargetProxy call site
|
||||||
|
// (mcp_a2a.go) passes as the idle-watchdog bound, and targetClient's own
|
||||||
|
// Transport.ResponseHeaderTimeout must match it too.
|
||||||
|
func TestNewGateway_TargetTimeout_ResolvedFromConfig(t *testing.T) {
|
||||||
|
cfg := CreateConfig()
|
||||||
|
cfg.RequestTimeout = "90s"
|
||||||
|
cfg.Providers = map[string]*ProviderConfig{"p1": {Type: "openai", APIKey: "k"}}
|
||||||
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
|
||||||
|
h, err := New(context.Background(), next, cfg, "llmgw")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New: %v", err)
|
||||||
|
}
|
||||||
|
gw := h.(*Gateway)
|
||||||
|
if gw.targetTimeout != 90*time.Second {
|
||||||
|
t.Errorf("gw.targetTimeout = %v, want 90s", gw.targetTimeout)
|
||||||
|
}
|
||||||
|
tr, ok := gw.targetClient.Transport.(*http.Transport)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("targetClient.Transport is %T, want *http.Transport", gw.targetClient.Transport)
|
||||||
|
}
|
||||||
|
if tr.ResponseHeaderTimeout != 90*time.Second {
|
||||||
|
t.Errorf("targetClient Transport.ResponseHeaderTimeout = %v, want 90s", tr.ResponseHeaderTimeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user