Choosing Retry Budgets and Backoff for Grant API Failures

Set a retry policy for funder APIs in Python: classify transient vs terminal HTTP faults, exponential backoff with full jitter, a retry budget and deadline, idempotency preconditions, and a circuit breaker under 2 CFR §200.302.

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.

Retry decision flow with a retryability gate, jittered backoff inside a retry budget, and a circuit breaker A request to a funder API returns an error that hits a retryability gate. Terminal errors (400, 401, 422) fail fast to the caller. Transient errors (429, 503, timeout) enter exponential backoff with full jitter, guarded by a retry budget and overall deadline. If budget and deadline remain, the request is retried through a circuit breaker that must be closed; a sustained outage opens the breaker and short-circuits further attempts. When the budget or deadline is exhausted, the request is written to a dead-letter queue. Every attempt and outcome is appended to an audit record for 2 CFR 200.302. Retry only what can succeed, back off with jitter, stop at the budget Every attempt is appended to an immutable call record — recovered, dead-lettered, or short-circuited, never silently retried forever. terminal 4xx transient budget spent breaker open wait + retry Funder API call fails retryable? classify code backoff + jitter within retry budget breaker closed? Retry attempt idempotency key Fail fast 400 · 401 · 422 Dead-letter queue budget / deadline spent Circuit open stop, cool down

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, a 503 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:

text
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.

python
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:

text
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.

python
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.

python
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.

python
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.

python
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:

  1. Terminal errors never retry. Drive call_with_retries with a send that returns (422, None) and assert disposition == "failed_terminal" and attempts == 1 — no backoff, no budget spent.
  2. 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.
  3. The budget bounds the loop. With a send that always returns (503, None), assert the call ends in dead_letter and that attempts never exceeds max_retries + 1, and that the deadline guard can end the loop early.
  4. A sustained outage opens the breaker. Feed enough consecutive failures to cross the threshold and assert the next call returns circuit_open without invoking send.

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.

python
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.