This guide is part of the Pipeline Fallback & Retry Logic section within the broader Core Architecture & Compliance Mapping framework, and it solves one narrow problem: how do you retry a failed call to a funder API — Grants.gov, a foundation portal, a state charity registry — so that transient faults recover on their own without turning a partial outage into a self-inflicted flood or a duplicate submission?
The naive policy is for _ in range(3): try ... except: retry. It retries a 400 Bad Request that will never succeed, retries a POST that already created a record, and — because every worker retries on the same fixed schedule — synchronizes the whole fleet into a thundering herd that hammers a struggling API the instant it starts to recover. This guide replaces that with a policy built on five decisions: which errors are worth retrying, how long to wait between attempts, how to cap total effort, what must be true before a retry is safe, and when to stop retrying entirely.
When to Use This Approach
Reach for a budgeted, jittered retry policy when a call crosses a network boundary you do not control and the fault might be temporary. That describes almost every write to a funder API: submitting an application package to Grants.gov, posting a status update to a foundation portal, or pushing a renewal to a state registry. Use it when all three of the following hold:
- The failure could be transient. A
429 Too Many Requests, a503 Service Unavailable, a connection reset, or a read timeout is the API telling you not right now — the same request may succeed in a few seconds. Those are the only faults a retry can fix. - The operation is idempotent, or you can make it so. Retrying a create that already succeeded double-submits a grant. A retry is only safe when the server can recognize a repeat and return the original result, which requires an idempotency key on the request — see exactly-once processing with idempotency keys. Establish that precondition before you enable any retry.
- The call is on the auditable path. Every attempt against a funder system is an external transaction that 2 CFR §200.302 financial-management records must be able to reconstruct. The policy here logs each attempt, its wait, and its terminal disposition so an auditor can replay exactly what was sent and when.
What this guide does not cover: choosing an alternate endpoint after retries are exhausted is fallback routing, which lives in the sibling guide Building a Fallback Routing System for Grant APIs. Proactively staying under a funder’s published request ceiling — rather than reacting to 429s — belongs to API Polling & Rate Limiting. And where dead-lettered requests are triaged is Error Categorization & Logging. This page is only the retry decision.
Step-by-Step Implementation
The reference implementation targets Python 3.10+ and the standard library plus requests. Pin your dependencies so the policy is reproducible across CI and production:
requests==2.32.3
Jitter must come from a source that is fast and uniform; use random for the wait computation and secrets only where an unguessable value is required (the idempotency key). Never seed the jitter globally in a way that synchronizes workers — the whole point is to de-correlate them.
Step 1: Classify the error as transient or terminal
The first decision gates everything else: a terminal error must never be retried. A 400 Bad Request, 401 Unauthorized, or 422 Unprocessable Entity means the request itself is wrong — malformed payload, bad token, failed validation — and retrying it wastes budget and floods the API with a request that cannot succeed. Only transient signals (429, 503, 502, 504, and transport-level timeouts) are eligible.
import logging
from dataclasses import dataclass, field
logger = logging.getLogger("grant_pipeline.retry")
TRANSIENT_STATUS: frozenset[int] = frozenset({429, 500, 502, 503, 504})
TERMINAL_STATUS: frozenset[int] = frozenset({400, 401, 403, 404, 422})
@dataclass(frozen=True)
class Classification:
retryable: bool
reason: str
def classify(status_code: int | None, exc: Exception | None = None) -> Classification:
"""Decide whether a failed funder-API call is worth retrying."""
if exc is not None and status_code is None:
# Connection reset / read timeout: transient by nature.
return Classification(retryable=True, reason=f"transport:{type(exc).__name__}")
if status_code in TERMINAL_STATUS:
return Classification(retryable=False, reason=f"terminal:{status_code}")
if status_code in TRANSIENT_STATUS:
return Classification(retryable=True, reason=f"transient:{status_code}")
# Unknown codes are treated as terminal — fail loud, don't guess.
return Classification(retryable=False, reason=f"unclassified:{status_code}")
Defaulting unknown status codes to terminal is deliberate: an unrecognized response is more likely a contract change than a blip, and retrying it blindly is how a client amplifies a funder-side incident.
Step 2: Compute exponential backoff with full jitter
Fixed delays synchronize every worker; plain exponential backoff still leaves them in lockstep. Full jitter breaks the correlation by spreading each retry uniformly across the whole backoff window. The wait for attempt n (starting at 0) is:
delay = random_uniform(0, min(cap, base * 2 ** n))
Each retry samples independently from [0, cap] bounded by the growing exponential ceiling, so a fleet of workers that all failed at the same instant fan their retries out smoothly instead of arriving together.
import random
def full_jitter_delay(attempt: int, base: float = 0.5, cap: float = 30.0) -> float:
"""Full-jitter exponential backoff: uniform draw over [0, min(cap, base*2**n)]."""
ceiling: float = min(cap, base * (2 ** attempt))
delay: float = random.uniform(0.0, ceiling)
logger.debug("backoff attempt=%d ceiling=%.2fs delay=%.2fs", attempt, ceiling, delay)
return delay
Honor a server-supplied Retry-After header when present — it is the API telling you exactly how long to wait — and take the maximum of that value and the jittered delay, so you never retry earlier than the funder asked.
Step 3: Bound total work with a retry budget and deadline
max_attempts=3 is a weak cap: three attempts with a 30-second backoff can still tie up a worker for well over a minute, and a naive count says nothing about wall-clock cost. Bound the work two ways at once — a retry budget (how many retries you will spend) and an overall deadline (the latest wall-clock time you will still be trying). Whichever is hit first stops the loop.
import time
@dataclass
class RetryBudget:
max_retries: int = 4
deadline_s: float = 45.0
_started: float = field(default_factory=time.monotonic)
_spent: int = 0
def can_retry(self) -> bool:
if self._spent >= self.max_retries:
logger.info("retry budget exhausted: spent=%d", self._spent)
return False
if time.monotonic() - self._started >= self.deadline_s:
logger.info("retry deadline exceeded: %.1fs", self.deadline_s)
return False
return True
def would_exceed_deadline(self, next_delay: float) -> bool:
elapsed: float = time.monotonic() - self._started
return elapsed + next_delay >= self.deadline_s
def spend(self) -> None:
self._spent += 1
would_exceed_deadline is the guard that prevents sleeping past your own deadline: if the computed backoff would land after the cutoff, stop now and dead-letter rather than burning the wait for an attempt you have already decided not to make.
Step 4: Require idempotency before any retry
A retry is only safe if a repeated request cannot create a second grant record. Attach a stable idempotency key — the same value on the first attempt and every retry — so the funder API can collapse duplicates to a single effect. Derive it once, from the payload, before the loop begins; regenerating it per attempt defeats the purpose.
import hashlib
import secrets
def idempotency_key(submission_id: str, payload: bytes) -> str:
"""Stable per-submission key: same across all retries of one logical request."""
digest = hashlib.sha256(submission_id.encode() + payload).hexdigest()[:32]
return f"grant-{digest}"
def new_correlation_id() -> str:
"""Unguessable id that ties every attempt of one call to one audit thread."""
return secrets.token_hex(8)
The key is a function of the submission and payload, so it survives process restarts and is identical whether the retry happens in this worker or another. The correlation id, drawn from secrets, threads all attempts of one logical call into a single 2 CFR §200.302 audit record. The full duplicate-suppression contract is detailed in exactly-once processing with idempotency keys.
Step 5: Coordinate with a circuit breaker and drive the loop
Retries handle a blip; they must not fight a sustained outage. A circuit breaker counts consecutive failures and, past a threshold, opens — short-circuiting further calls for a cool-down window so a down funder API gets room to recover instead of a retry storm. The driver below classifies, checks the breaker, respects the budget, sleeps with full jitter, and dead-letters on exhaustion — logging every transition.
class CircuitBreaker:
def __init__(self, threshold: int = 5, cooldown_s: float = 60.0) -> None:
self.threshold = threshold
self.cooldown_s = cooldown_s
self._failures = 0
self._opened_at: float | None = None
def is_open(self) -> bool:
if self._opened_at is None:
return False
if time.monotonic() - self._opened_at >= self.cooldown_s:
self._opened_at = None # half-open: allow one probe
self._failures = 0
return False
return True
def record(self, ok: bool) -> None:
if ok:
self._failures = 0
self._opened_at = None
return
self._failures += 1
if self._failures >= self.threshold and self._opened_at is None:
self._opened_at = time.monotonic()
logger.warning("circuit opened after %d failures", self._failures)
@dataclass
class CallOutcome:
ok: bool
disposition: str # "succeeded" | "failed_terminal" | "dead_letter" | "circuit_open"
correlation_id: str
attempts: int
def call_with_retries(send, budget: RetryBudget, breaker: CircuitBreaker,
idem_key: str) -> CallOutcome:
"""Drive one logical funder-API call through classify -> backoff -> budget -> breaker."""
corr = new_correlation_id()
attempt = 0
while True:
if breaker.is_open():
logger.warning("circuit open, short-circuiting corr=%s", corr)
return CallOutcome(False, "circuit_open", corr, attempt)
status, exc = send(idem_key, corr) # returns (status_code|None, Exception|None)
ok = exc is None and status is not None and 200 <= status < 300
breaker.record(ok)
if ok:
logger.info("call succeeded corr=%s attempts=%d", corr, attempt + 1)
return CallOutcome(True, "succeeded", corr, attempt + 1)
verdict = classify(status, exc)
logger.info("attempt=%d corr=%s status=%s verdict=%s",
attempt, corr, status, verdict.reason)
if not verdict.retryable:
return CallOutcome(False, "failed_terminal", corr, attempt + 1)
delay = full_jitter_delay(attempt)
if not budget.can_retry() or budget.would_exceed_deadline(delay):
logger.warning("dead-lettering corr=%s after %d attempts", corr, attempt + 1)
return CallOutcome(False, "dead_letter", corr, attempt + 1)
budget.spend()
time.sleep(delay)
attempt += 1
The send callable is your only side-effecting dependency; inject it so the driver stays testable. When the breaker is open the driver returns immediately without spending budget — the outage is not the request’s fault, and a circuit_open disposition routes to fallback rather than to the dead-letter queue.
Verification
Confirm the policy behaves correctly with four checks:
- Terminal errors never retry. Drive
call_with_retrieswith asendthat returns(422, None)and assertdisposition == "failed_terminal"andattempts == 1— no backoff, no budget spent. - Full jitter stays within its ceiling. Sample
full_jitter_delay(3)many times and assert every value is in[0, min(cap, base*2**3)]and that the samples are not all equal — proof the fleet will de-correlate. - The budget bounds the loop. With a
sendthat always returns(503, None), assert the call ends indead_letterand thatattemptsnever exceedsmax_retries + 1, and that the deadline guard can end the loop early. - A sustained outage opens the breaker. Feed enough consecutive failures to cross the threshold and assert the next call returns
circuit_openwithout invokingsend.
Every attempt emits one structured log line carrying the correlation id, status, and verdict; the terminal disposition emits an INFO (success) or WARNING (dead-letter, circuit open). Ship those lines to a write-once tier so the external-call trail satisfies 2 CFR §200.302.
def flaky(n_fail: int):
calls = {"n": 0}
def send(idem_key: str, corr: str):
calls["n"] += 1
return (503, None) if calls["n"] <= n_fail else (200, None)
return send
out = call_with_retries(flaky(2), RetryBudget(max_retries=4, deadline_s=45.0),
CircuitBreaker(), idem_key="grant-abc")
assert out.ok and out.disposition == "succeeded"
assert out.attempts == 3 # two 503s, then a 2xx
Common Errors & Fixes
| Error | Cause | Fix |
|---|---|---|
A 400/422 gets retried until the budget drains |
Classifier treats all non-2xx as retryable | Gate on classify; terminal codes return retryable=False and fail fast. |
| Retries arrive in a synchronized burst | Fixed or un-jittered exponential delay | Use full_jitter_delay — sample uniformly over [0, min(cap, base*2**n)]. |
| A submission is created twice | Idempotency key regenerated per attempt | Derive idempotency_key once before the loop from submission_id + payload; send the same key every attempt. |
| Worker hangs far past its SLA | max_attempts cap with no wall-clock bound |
Add a RetryBudget.deadline_s and check would_exceed_deadline before sleeping. |
| Retry storm against a down API | No coordination across calls | Wrap calls in a shared CircuitBreaker; open after N consecutive failures and cool down. |
Related
- Parent section: Pipeline Fallback & Retry Logic
- When retries are exhausted and you need an alternate endpoint: Building a Fallback Routing System for Grant APIs
- Staying under a funder’s request ceiling instead of reacting to 429s: API Polling & Rate Limiting