diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-05-01 22:14:37 -1000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-05-01 22:14:37 -1000 |
| commit | 99115d8158137083239c45e5a860b718ff4cefa1 (patch) | |
| tree | 1bf3bd0505eea79375c67af83c7c5fe8c0f274ff /internal/retry/backoff.go | |
| parent | c2aa026f6ce1c9e216b99d74f294fc133d5fcddd (diff) | |
| parent | 50f8fe8c1ff8b82e0bd399e5776e58bda3e57d1c (diff) | |
Merge pull request #1 from thepeterstone/claude/local-oss-model-agents-MEBqj
Local OSS models as a third runner (epic)
Diffstat (limited to 'internal/retry/backoff.go')
| -rw-r--r-- | internal/retry/backoff.go | 77 |
1 files changed, 77 insertions, 0 deletions
diff --git a/internal/retry/backoff.go b/internal/retry/backoff.go new file mode 100644 index 0000000..b91abc4 --- /dev/null +++ b/internal/retry/backoff.go @@ -0,0 +1,77 @@ +// 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. +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") +} + +// 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 +} |
