main
md 2.58 KB

Exponential Backoff with Jitter and Retry-After Headers

confidence: high discovered_by: Farnsworth, Bender (GitHub crawler phase) date: 2026-05-19

Pattern

Implement resilient HTTP retry logic that combines: 1. Exponential backoff (2attempt, capped at 60s) for deterministic delay 2. Random jitter (0.3–1.7s) to prevent thundering herd 3. Server-provided Retry-After header (HTTP 429, 503) takes precedence 4. Secondary rate limit detection with enforced minimum backoff (8s + 0.0–5s jitter) 5. Rate limit state tracking (X-RateLimit-Remaining, X-RateLimit-Reset)

When to Use

  • External HTTP requests to rate-limited APIs (GitHub GraphQL, RSS feeds, third-party crawlers)
  • Handling HTTP 429, 500, 502, 503, 504 responses
  • Distributed systems where retry storms can amplify load (thundering herd)
  • API quota exhaustion scenarios with server-provided retry guidance

Implementation

# Exponential backoff calculation
base_delay = min(2**attempt, 60)  # Cap at 60 seconds
jitter = random.uniform(0.3, 1.7)
delay = base_delay + jitter

# Honor Retry-After header (seconds)
if "Retry-After" in response_headers:
    retry_after = float(response_headers["Retry-After"])
    delay = max(retry_after, 1.0)

# Secondary rate limit: enforce minimum
if "secondary rate limit" in response_body.lower():
    delay = max(delay, 8.0 + random.uniform(0.0, 5.0))

# Cap total delay to prevent indefinite waits
delay = min(delay, max_delay_seconds)

# Sleep and retry
time.sleep(delay)

Examples

From scripts/crawl.py (GitHub GraphQL crawler):

def _sleep_before_retry(
    self,
    attempt: int,
    headers: dict[str, str] | None,
    body: str,
    query: str,
    retry_limit: int,
    max_delay_seconds: float,
) -> None:
    reset_delay = self._reset_delay(headers)
    retry_after = None
    if headers and headers.get("Retry-After"):
        try:
            retry_after = max(float(headers["Retry-After"]), 1.0)
        except ValueError:
            retry_after = None

    base_delay = min(2**attempt, 60)
    jitter = random.uniform(0.3, 1.7)
    delay = retry_after or reset_delay or (base_delay + jitter)
    if "secondary rate limit" in body.lower():
        delay = max(delay, 8.0 + random.uniform(0.0, 5.0))
    delay = min(delay, max_delay_seconds)

    log(f"Retrying {query} in {delay:.1f}s (attempt {attempt + 1}/{retry_limit}).")
    time.sleep(delay)

State tracking pattern: python self.rate_limit_reset = max(self.rate_limit_reset or 0, int(time.time() + retry_after))

Retryable status codes: python RETRYABLE_STATUSES = {403, 429, 500, 502, 503, 504}