package ai import ( "context" "errors" "fmt" "math/rand/v2" "net/http" "strconv" "strings" "time" ) // StatusCoder is implemented by provider errors that expose an HTTP-like status code. type StatusCoder interface { StatusCode() int } // RetryAfterCoder is implemented by provider errors that expose a server // supplied retry delay, such as HTTP Retry-After on a 429/503 response. type RetryAfterCoder interface { RetryAfter() time.Duration } // HTTPError describes a failed provider HTTP response while preserving the // status code and Retry-After signal for retry classifiers. type HTTPError struct { Status string Code int Body string Header http.Header } func (e *HTTPError) Error() string { if e == nil { return "" } return fmt.Sprintf("API error (%s): %s", e.Status, e.Body) } func (e *HTTPError) StatusCode() int { if e == nil { return 0 } return e.Code } func (e *HTTPError) RetryAfter() time.Duration { if e == nil { return 0 } return parseRetryAfter(e.Header.Get("Retry-After"), time.Now()) } func NewHTTPError(resp *http.Response, body []byte) error { if resp == nil { return errors.New("API error: nil response") } return &HTTPError{Status: resp.Status, Code: resp.StatusCode, Body: string(body), Header: resp.Header.Clone()} } func parseRetryAfter(value string, now time.Time) time.Duration { value = strings.TrimSpace(value) if value == "" { return 0 } if seconds, err := strconv.Atoi(value); err == nil { if seconds <= 0 { return 0 } return time.Duration(seconds) * time.Second } when, err := http.ParseTime(value) if err != nil { return 0 } if delay := when.Sub(now); delay > 0 { return delay } return 0 } // ErrorKind classifies provider-boundary failures into stable buckets callers // can inspect without parsing provider-specific error strings. type ErrorKind string const ( ErrorKindUnknown ErrorKind = "unknown" ErrorKindCanceled ErrorKind = "canceled" ErrorKindTimeout ErrorKind = "timeout" ErrorKindRateLimited ErrorKind = "rate_limited" ErrorKindUnavailable ErrorKind = "unavailable" ErrorKindAuth ErrorKind = "auth" ErrorKindConfiguration ErrorKind = "configuration" ErrorKindProvider ErrorKind = "provider" ) // ClassifiedError is implemented by errors that expose a stable ErrorKind. type ClassifiedError interface { ErrorKind() ErrorKind } // RetryError is returned when Generate is retried and still fails. type RetryError struct { Attempts int Kind ErrorKind Err error } func (e *RetryError) Error() string { if e == nil { return "" } return fmt.Sprintf("ai generate failed after %d attempt(s) (%s): %v", e.Attempts, e.ErrorKind(), e.Err) } func (e *RetryError) Unwrap() error { if e == nil { return nil } return e.Err } func (e *RetryError) ErrorKind() ErrorKind { if e == nil || e.Kind == "" { return ErrorKindUnknown } return e.Kind } // GeneratePolicy controls timeout and retry behavior for a model call. type GeneratePolicy struct { Timeout time.Duration MaxAttempts int Backoff time.Duration // Jitter adds up to this duration of random delay to retry backoff. // It is opt-in so existing retry timing remains deterministic by default. Jitter time.Duration } // GenerateWithRetry calls m.Generate with per-attempt timeout and bounded retry. func GenerateWithRetry(ctx context.Context, m Model, req *Request, policy GeneratePolicy, opts ...GenerateOption) (*Response, error) { if policy.MaxAttempts <= 0 { policy.MaxAttempts = 1 } if m == nil { return nil, errors.New("ai model is nil") } var last error for attempt := 1; attempt <= policy.MaxAttempts; attempt++ { if err := ctx.Err(); err != nil { return nil, err } callCtx := ctx cancel := func() {} if policy.Timeout > 0 { callCtx, cancel = context.WithTimeout(ctx, policy.Timeout) } if info, ok := RunInfoFrom(callCtx); ok { info.Attempt = attempt info.MaxAttempts = policy.MaxAttempts callCtx = WithRunInfo(callCtx, info) } resp, err := generateAttempt(callCtx, m, req, opts...) cancel() // Caller cancellation/deadline always wins and is not retried, even if // a provider or tool loop swallowed the canceled tool result and returned // a final response. This keeps agent runs from appearing successful after // their controlling context was abandoned. if ctxErr := ctx.Err(); ctxErr != nil { return nil, ctxErr } if err == nil { return resp, nil } last = err transient := IsTransientError(err) if attempt == policy.MaxAttempts || !transient { if attempt > 1 || transient { return nil, &RetryError{Attempts: attempt, Kind: ClassifyError(err), Err: err} } return nil, err } // Always back off between retries — exponential and capped — so an // opt-in retry can never become a tight loop hammering the provider, // even if Backoff was left at zero. backoff := retryBackoffWithJitter(err, attempt, policy.Backoff, policy.Jitter) t := time.NewTimer(backoff) select { case <-ctx.Done(): if !t.Stop() { <-t.C } return nil, ctx.Err() case <-t.C: } } return nil, &RetryError{Attempts: policy.MaxAttempts, Kind: ClassifyError(last), Err: last} } func generateAttempt(ctx context.Context, m Model, req *Request, opts ...GenerateOption) (*Response, error) { if err := ctx.Err(); err != nil { return nil, err } type result struct { resp *Response err error } done := make(chan result, 1) go func() { resp, err := m.Generate(ctx, req, opts...) done <- result{resp: resp, err: err} }() select { case res := <-done: return res.resp, res.err case <-ctx.Done(): return nil, ctx.Err() } } func retryBackoff(err error, attempt int, base time.Duration) time.Duration { return retryBackoffWithJitter(err, attempt, base, 0) } func retryBackoffWithJitter(err error, attempt int, base, jitter time.Duration) time.Duration { backoff := base if backoff <= 0 { backoff = 200 * time.Millisecond } if shift := attempt - 1; shift > 0 { backoff <<= shift } if backoff > 30*time.Second { backoff = 30 * time.Second } var retryAfter RetryAfterCoder if errors.As(err, &retryAfter) { if delay := retryAfter.RetryAfter(); delay > backoff { backoff = delay } } if jitter > 0 { backoff += time.Duration(rand.Int64N(int64(jitter) + 1)) } if backoff > 30*time.Second { return 30 * time.Second } return backoff } // ClassifyError maps provider and context failures to stable operational kinds. func ClassifyError(err error) ErrorKind { if err == nil { return "" } var classified ClassifiedError if errors.As(err, &classified) { if kind := classified.ErrorKind(); kind != "" { return kind } } if errors.Is(err, context.Canceled) { return ErrorKindCanceled } if errors.Is(err, context.DeadlineExceeded) { return ErrorKindTimeout } var sc StatusCoder if errors.As(err, &sc) { code := sc.StatusCode() switch { case code == 429: return ErrorKindRateLimited case code == 401 || code == 403: return ErrorKindAuth case code == 400 || code == 404: return ErrorKindConfiguration case code >= 500: return ErrorKindUnavailable case code > 0: return ErrorKindProvider } } msg := strings.ToLower(err.Error()) switch { case strings.Contains(msg, "rate limit") || strings.Contains(msg, "too many requests"): return ErrorKindRateLimited case strings.Contains(msg, "timeout") || strings.Contains(msg, "deadline"): return ErrorKindTimeout case strings.Contains(msg, "unauthorized") || strings.Contains(msg, "forbidden") || strings.Contains(msg, "invalid api key") || strings.Contains(msg, "api key") || strings.Contains(msg, "credential"): return ErrorKindAuth case strings.Contains(msg, "missing") || strings.Contains(msg, "not configured") || strings.Contains(msg, "configuration") || strings.Contains(msg, "unsupported model") || strings.Contains(msg, "model not found"): return ErrorKindConfiguration case strings.Contains(msg, "temporar") || strings.Contains(msg, "unavailable"): return ErrorKindUnavailable default: return ErrorKindUnknown } } // IsTransientError reports whether err is worth retrying at the provider boundary. func IsTransientError(err error) bool { switch ClassifyError(err) { case ErrorKindTimeout, ErrorKindRateLimited, ErrorKindUnavailable: return true default: return false } }