Skip to content

[FEATURE] Bounded exponential retry with jitter for external calls #111

Description

@koydas

🎯 Goal

Add a shared retry utility with bounded exponential backoff + jitter to absorb transient external failures without destabilizing a run.

📍 Context

  • Repo: autonomous-dev-loop
  • Domain: Transient failure tolerance
  • Component: scripts/lib/anthropic_client.mjs, scripts/lib/groq_client.mjs, scripts/auto_fix_pr.mjs (ghFetch)

🚀 Description

Currently, external call resilience is inconsistent:

  • anthropic_client.mjs: single raw fetch, zero retry, zero timeout — any transient failure fails the entire run immediately.
  • groq_client.mjs: retries on HTTP 429 only (good pattern), but HTTP 5xx throws immediately with no retry, and there is no per-call timeout.
  • ghFetch in auto_fix_pr.mjs: no retry, no timeout — a single network error on any GitHub API call (diff fetch, label fetch, comment post) fails the entire run.

The fix is a single shared retryWithBackoff utility consumed by all three call sites.

🧩 Scope

In:

  • Create scripts/lib/retry.mjs exporting retryWithBackoff(fn, options).
  • Default configuration:
    • maxAttempts: 4
    • baseDelayMs: 200
    • maxDelayMs: 8000
    • timeoutMs: 15000 (per individual attempt)
    • jitter: true (±20% random spread on each delay)
  • Retryable status codes: 429, 500, 502, 503, 504.
  • Non-retryable status codes: 400, 401, 403, 404, 422 — throw immediately.
  • If the response includes a Retry-After header (or body hint), use that wait time instead of computed backoff (preserving groq_client.mjs existing behaviour).
  • Integrate into anthropic_client.mjs (wrap the existing fetch call).
  • Extend groq_client.mjs to pass 5xx through retryWithBackoff (the existing 429 loop can be simplified to use the same utility).
  • Apply to ghFetch in auto_fix_pr.mjs.

Out:

  • Changes to retry budgets for non-external operations.
  • Global pipeline performance optimisation.
  • Persistent cross-run state.

🧪 Acceptance criteria

  • Functional

    • HTTP 500, 502, 503, 504 responses are retried up to maxAttempts - 1 times before throwing.
    • HTTP 429 responses are retried using Retry-After wait time when present, or computed backoff otherwise.
    • HTTP 400, 401, 403, 404, 422 responses throw immediately without any retry attempt.
    • Network-level errors (connection reset, DNS failure) are treated as retryable and follow the same backoff schedule.
  • Edge cases

    • After maxAttempts (default: 4) total attempts, the last error is thrown — no further retries.
    • A single attempt that exceeds timeoutMs (default: 15 000 ms) is aborted and counted as a retryable failure.
    • Jitter ensures no two concurrent retries produce identical wait times.
    • A 429 with Retry-After: 60 waits exactly 60 seconds before the next attempt (regardless of backoff schedule).
  • Tests

    • Unit: retryWithBackoff retries exactly 3 times on 503 before throwing (4 total attempts).
    • Unit: retryWithBackoff throws immediately on 401 (1 total attempt, no wait).
    • Unit: backoff sequence for attempts 1–3 is approximately [200, 400, 800] ms before jitter.
    • Unit: timeoutMs triggers an AbortError that is counted as a retryable attempt.
    • Unit: Retry-After: 2 causes a ~2 000 ms wait on 429.
    • Regression: llm_client.mjs provider fallback is NOT triggered when anthropic_client.mjs throws on 401 (permanent error must propagate, not be absorbed).

⚙️ Constraints

  • No new runtime dependencies (use setTimeout + AbortController, both available in Node 20).
  • The utility must be synchronously importable (no top-level await).
  • All retry attempts must emit a structured log line via logger.mjs ({ msg: 'retry', attempt, statusCode, waitMs }).
  • Keep API minimal: retryWithBackoff(fn, options?) where fn is an async function returning Response.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestready-for-devIssue validated and ready for automated implementation

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions