Stop LLM adapters following redirects and cap Retry-After
MQ-01: the Anthropic adapter used a default http.Client, so a 3xx from the configured endpoint was followed and the x-api-key header resent to the redirect target; net/http strips only its own sensitive headers. Both adapters now use a client whose CheckRedirect returns ErrUseLastResponse, so a 3xx comes back as an llm.ProviderError and nothing is requested again. The OpenAI-compatible adapter gets the same change: it forwards configured extra headers (which may carry a credential and which net/http does not treat as sensitive) across hosts, and net/http keeps Authorization on a same-host https-to-http redirect. MQ-02: a delay-seconds Retry-After too large for time.Duration overflowed into a negative wait. retryAfter now caps the value at the most whole seconds a time.Duration holds (also for values beyond int64) and the response still maps to ErrRateLimited. Tests: TestRedirectIsNotFollowed (302 to a second server, which must get no request) and TestRateLimitedHugeRetryAfterIsCapped in both packages, plus huge-number cases in TestRetryAfter. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
@@ -132,7 +133,7 @@ func newFromConfig(cfg Config) (*Provider, error) {
|
||||
model: cfg.Model,
|
||||
headers: headers,
|
||||
mode: cfg.StructuredOutput,
|
||||
client: &http.Client{},
|
||||
client: noRedirectClient(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -462,10 +463,15 @@ func retryAfter(value string, now time.Time) time.Duration {
|
||||
if value == "" {
|
||||
return 0
|
||||
}
|
||||
if secs, err := strconv.Atoi(value); err == nil {
|
||||
if secs, err := strconv.ParseInt(value, 10, 64); err == nil || errors.Is(err, strconv.ErrRange) {
|
||||
if secs < 0 {
|
||||
return 0
|
||||
}
|
||||
// time.Duration counts nanoseconds in an int64; a larger wait is
|
||||
// capped to the most whole seconds it holds instead of overflowing.
|
||||
if secs > maxRetryAfterSeconds {
|
||||
secs = maxRetryAfterSeconds
|
||||
}
|
||||
return time.Duration(secs) * time.Second
|
||||
}
|
||||
if at, err := http.ParseTime(value); err == nil && at.After(now) {
|
||||
@@ -474,6 +480,22 @@ func retryAfter(value string, now time.Time) time.Duration {
|
||||
return 0
|
||||
}
|
||||
|
||||
// maxRetryAfterSeconds is the largest delay-seconds value retryAfter
|
||||
// returns unchanged: the most whole seconds a time.Duration holds.
|
||||
const maxRetryAfterSeconds = math.MaxInt64 / int64(time.Second)
|
||||
|
||||
// noRedirectClient returns an HTTP client that never follows a redirect.
|
||||
// A followed redirect would resend the credential headers, which net/http
|
||||
// strips only for its own list of sensitive headers and only across hosts;
|
||||
// instead the 3xx response is returned and becomes a provider error.
|
||||
func noRedirectClient() *http.Client {
|
||||
return &http.Client{
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func isTimeout(err error) bool {
|
||||
var ne net.Error
|
||||
return errors.Is(err, context.DeadlineExceeded) || (errors.As(err, &ne) && ne.Timeout())
|
||||
|
||||
Reference in New Issue
Block a user