feat: same-provider retry for transient upstream failures

Adds spec §1's opt-in retry engine: RetryConfig (top-level, zero value
disabled) and retryPolicy (retry.go), wired into upstreamJSON — the
single HTTP chokepoint every adapter's chat/embeddings/listModels call
already funneled through. A transient failure (net error, 429, 5xx)
retries up to Attempts times with exponential backoff capped at 2s,
honoring a qualifying Retry-After header on 429, draining and closing
each failed attempt's body before retrying. Never retries a non-429 4xx,
a translate error, or after context cancellation.

Because upstreamJSON returns before any adapter forwards a byte to the
client (forwardStream/forwardJSON run only after it returns), the
'zero bytes reached client' safety rule and the 'streaming retries only
the initial exchange' rule both hold by construction — no adapter
streaming logic needed to change.

buildAdapters(cfg) keeps its v0.1 signature: it derives the retryPolicy
from cfg.Retry internally rather than taking it as a parameter, so
every existing direct call (15+ across provider_openai_test.go) keeps
compiling unmodified. Every v0.1 test passes byte-identically, without
edits.
This commit is contained in:
2026-08-20 17:11:40 +01:00
parent a280ee8aee
commit 34968e27b2
7 changed files with 847 additions and 30 deletions
+10 -4
View File
@@ -13,7 +13,13 @@ import (
// geminiAdapter is the providerAdapter for provider type "gemini".
type geminiAdapter struct {
client *http.Client
client *http.Client
// retry is nil for every adapter built by a bare newGeminiAdapter call
// (every v0.1 test, and any caller that never wires one) — nil-safe,
// per retryPolicy.do's own contract, so leaving it unset is exactly
// v0.1's one-attempt behavior. buildAdapters (providers.go) sets it
// from cfg.Retry for production use.
retry *retryPolicy
adapterName string
baseURL string
apiKey string
@@ -119,7 +125,7 @@ func (a *geminiAdapter) chatCompletion(ctx context.Context, w http.ResponseWrite
endpoint = a.baseURL + geminiAPIPrefix + escapedModel + ":streamGenerateContent?alt=sse"
}
resp, err := upstreamJSON(ctx, a.client, http.MethodPost, endpoint, hdr, body)
resp, err := upstreamJSON(ctx, a.client, http.MethodPost, endpoint, hdr, body, a.retry)
if err != nil {
return usage{}, err
}
@@ -228,7 +234,7 @@ func (a *geminiAdapter) embeddings(ctx context.Context, w http.ResponseWriter, r
endpoint = a.baseURL + geminiAPIPrefix + escapedModel + ":batchEmbedContents"
}
resp, err := upstreamJSON(ctx, a.client, http.MethodPost, endpoint, a.requestHeaders(true), body)
resp, err := upstreamJSON(ctx, a.client, http.MethodPost, endpoint, a.requestHeaders(true), body, a.retry)
if err != nil {
return usage{}, err
}
@@ -273,7 +279,7 @@ type geminiModelsListPayload struct {
// model ids, matching the shape openai-type and anthropic-type adapters
// both return.
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)
resp, err := upstreamJSON(ctx, a.client, http.MethodGet, a.baseURL+"/v1beta/models", a.requestHeaders(false), nil, a.retry)
if err != nil {
return nil, err
}