// Package retry provides exponential-backoff retry helpers used across the // codebase for rate-limit-aware HTTP/subprocess calls. package retry import ( "context" "fmt" "regexp" "strconv" "strings" "time" ) var retryAfterRe = regexp.MustCompile(`(?i)retry[-_ ]after[:\s]+(\d+)`) const maxBackoffDelay = 5 * time.Minute // IsRateLimitError returns true if err looks like a transient rate-limit // (e.g. HTTP 429, "too many requests", "overloaded") that is worth retrying. // // Patterns are additive across providers: the original set matches // OpenAI-compatible error text (internal/llm); "rate_limit_error", // "overloaded_error", and "529" additionally match Anthropic's Messages API // error shape (HTTP 429 with error.type "rate_limit_error", or HTTP 529 // with error.type "overloaded_error" — see internal/provider/anthropic); // "resource_exhausted" additionally matches Gemini's generateContent API // error shape (HTTP 429 with error.status "RESOURCE_EXHAUSTED" — see // internal/provider/google). The plain "429" check above already covers // Gemini's HTTP status code, but the status string is matched too for // parity with how the Anthropic error types are matched by name, not just // by status code. func IsRateLimitError(err error) bool { if err == nil { return false } msg := strings.ToLower(err.Error()) return strings.Contains(msg, "rate limit") || strings.Contains(msg, "too many requests") || strings.Contains(msg, "429") || strings.Contains(msg, "overloaded") || strings.Contains(msg, "rate_limit_error") || strings.Contains(msg, "overloaded_error") || strings.Contains(msg, "529") || strings.Contains(msg, "resource_exhausted") } // ParseRetryAfter extracts a Retry-After duration from an error message. // Returns 0 if no retry-after value is found. func ParseRetryAfter(msg string) time.Duration { m := retryAfterRe.FindStringSubmatch(msg) if m == nil { return 0 } secs, err := strconv.Atoi(m[1]) if err != nil || secs <= 0 { return 0 } return time.Duration(secs) * time.Second } // RunWithBackoff calls fn repeatedly on rate-limit errors, using exponential backoff. // maxRetries is the max number of retry attempts (not counting the initial call). // baseDelay is the initial backoff duration (doubled each retry). func RunWithBackoff(ctx context.Context, maxRetries int, baseDelay time.Duration, fn func() error) error { var lastErr error for attempt := 0; attempt <= maxRetries; attempt++ { lastErr = fn() if lastErr == nil { return nil } if !IsRateLimitError(lastErr) { return lastErr } if attempt == maxRetries { break } delay := baseDelay * (1 << attempt) if delay > maxBackoffDelay { delay = maxBackoffDelay } if ra := ParseRetryAfter(lastErr.Error()); ra > 0 { delay = ra } select { case <-ctx.Done(): return fmt.Errorf("context cancelled during rate-limit backoff: %w", ctx.Err()) case <-time.After(delay): } } return lastErr }