main
md 88 lines 2.58 KB
Rendered Raw
1 # Exponential Backoff with Jitter and Retry-After Headers
2
3 confidence: high
4 discovered_by: Farnsworth, Bender (GitHub crawler phase)
5 date: 2026-05-19
6
7 ## Pattern
8
9 Implement resilient HTTP retry logic that combines:
10 1. Exponential backoff (2^attempt, capped at 60s) for deterministic delay
11 2. Random jitter (0.3–1.7s) to prevent thundering herd
12 3. Server-provided Retry-After header (HTTP 429, 503) takes precedence
13 4. Secondary rate limit detection with enforced minimum backoff (8s + 0.0–5s jitter)
14 5. Rate limit state tracking (X-RateLimit-Remaining, X-RateLimit-Reset)
15
16 ## When to Use
17
18 - External HTTP requests to rate-limited APIs (GitHub GraphQL, RSS feeds, third-party crawlers)
19 - Handling HTTP 429, 500, 502, 503, 504 responses
20 - Distributed systems where retry storms can amplify load (thundering herd)
21 - API quota exhaustion scenarios with server-provided retry guidance
22
23 ## Implementation
24
25 ```python
26 # Exponential backoff calculation
27 base_delay = min(2**attempt, 60) # Cap at 60 seconds
28 jitter = random.uniform(0.3, 1.7)
29 delay = base_delay + jitter
30
31 # Honor Retry-After header (seconds)
32 if "Retry-After" in response_headers:
33 retry_after = float(response_headers["Retry-After"])
34 delay = max(retry_after, 1.0)
35
36 # Secondary rate limit: enforce minimum
37 if "secondary rate limit" in response_body.lower():
38 delay = max(delay, 8.0 + random.uniform(0.0, 5.0))
39
40 # Cap total delay to prevent indefinite waits
41 delay = min(delay, max_delay_seconds)
42
43 # Sleep and retry
44 time.sleep(delay)
45 ```
46
47 ## Examples
48
49 From `scripts/crawl.py` (GitHub GraphQL crawler):
50
51 ```python
52 def _sleep_before_retry(
53 self,
54 attempt: int,
55 headers: dict[str, str] | None,
56 body: str,
57 query: str,
58 retry_limit: int,
59 max_delay_seconds: float,
60 ) -> None:
61 reset_delay = self._reset_delay(headers)
62 retry_after = None
63 if headers and headers.get("Retry-After"):
64 try:
65 retry_after = max(float(headers["Retry-After"]), 1.0)
66 except ValueError:
67 retry_after = None
68
69 base_delay = min(2**attempt, 60)
70 jitter = random.uniform(0.3, 1.7)
71 delay = retry_after or reset_delay or (base_delay + jitter)
72 if "secondary rate limit" in body.lower():
73 delay = max(delay, 8.0 + random.uniform(0.0, 5.0))
74 delay = min(delay, max_delay_seconds)
75
76 log(f"Retrying {query} in {delay:.1f}s (attempt {attempt + 1}/{retry_limit}).")
77 time.sleep(delay)
78 ```
79
80 State tracking pattern:
81 ```python
82 self.rate_limit_reset = max(self.rate_limit_reset or 0, int(time.time() + retry_after))
83 ```
84
85 Retryable status codes:
86 ```python
87 RETRYABLE_STATUSES = {403, 429, 500, 502, 503, 504}
88 ```