Implementing Exponential Backoff in Python for OTA Rate Limits
When a channel push returns 429 Too Many Requests, the naive fix — sleep a fixed second and retry — is exactly the reflex that keeps a property throttled all morning: every sync worker retries on the same tick, re-trips the limit the instant the window opens, and the last rate an OTA actually accepted goes stale. This page shows how to compute exponential backoff with full jitter in Python correctly, so that a throttled push waits a mathematically predictable, de-synchronised interval instead. It is the backoff mechanics referenced by handling OTA API rate limits, where the same curve powers the token-bucket governor’s 429/Retry-After retry policy across the wider API Sync & Data Ingestion Workflows pipeline.
Prerequisites & environment
The backoff computation itself is pure standard library, but the production wrapper leans on a small, pinned stack. tenacity in particular changed its wait-composition API across major versions, so pin it:
- Python 3.11+ — for
asyncio.timeoutand native async sleeping. - httpx 0.27+ — async HTTP transport with per-request timeouts.
- tenacity 8.2+ — declarative retry policy and
wait_exponential_jitter. - structlog 24.1+ — key=value structured logs so each backoff is auditable.
- OTA / channel-manager API credentials with push scope. Keeping those tokens valid across a long retry sequence is owned by OAuth2 token refresh strategies; a
401mid-retry is an auth event, not a backoff event, and is classified in error categorization & retry logic.
Outbound payloads should already be validated and carry rate-plan identifiers resolved against the rate plan taxonomy; backoff only decides when to re-send a well-formed mutation, never whether it is correct.
Step-by-step implementation
We build backoff in four small pieces: the delay-curve function, the retryable-status classifier, a hand-rolled async retry loop that shows the mechanics explicitly, and finally the idiomatic tenacity decorator you would actually ship.
Step 1 — Compute the jittered delay curve
The delay for attempt n is base_delay * multiplier ** n, clamped to a ceiling, then randomised. Using full jitter — a uniform draw over [0, computed_delay] rather than a fixed fraction added on top — spreads retries across the widest interval and is the single most effective defence against a synchronised retry storm.
import random
def compute_backoff(
attempt: int,
base_delay: float = 1.0,
multiplier: float = 2.0,
max_delay: float = 30.0,
) -> float:
"""Full-jitter exponential backoff: a uniform draw over [0, capped_delay]."""
capped = min(base_delay * (multiplier ** attempt), max_delay)
return random.uniform(0.0, capped)
Clamping with min(...) before drawing the jitter matters: if you cap after jittering, the ceiling stops bounding the curve once the raw exponential blows past max_delay, and a late attempt can still sleep for an unbounded fraction of a runaway value.
Step 2 — Classify which statuses may be retried
Backoff must never fire on a deterministic client error. Retrying a 400 malformed payload or a 422 invalid rate wastes the channel’s request budget and delays the alert that a genuine schema drift needs. Only throttling and transient upstream faults are eligible.
RETRYABLE_STATUSES = frozenset({408, 429, 500, 502, 503, 504})
def is_retryable(status_code: int) -> bool:
# 408 Request Timeout is retryable; 4xx below it (400/401/403/404/409/422) is not.
return status_code in RETRYABLE_STATUSES
408 is deliberately in the retryable set while its 4xx neighbours are not: a request timeout is an upstream/network condition that a later attempt can clear, whereas a 409 conflict or 422 validation failure will fail identically on every retry.
Step 3 — Drive an async retry loop that honours Retry-After
This loop pushes one rate mutation for a property_id / room_type_code / rate_plan_code triple to an OTA and retries on a retryable status. When the channel returns a Retry-After header it tells you exactly when the window resets, so that value must win over the locally computed curve.
import asyncio
import httpx
import structlog
log = structlog.get_logger("rate_parity.backoff")
async def push_rate_with_backoff(
client: httpx.AsyncClient,
payload: dict,
max_retries: int = 5,
) -> httpx.Response:
ota = payload["ota"] # e.g. "booking_com", "expedia", "agoda"
for attempt in range(max_retries + 1):
resp = await client.post(f"/{ota}/rates/sync", json=payload)
if resp.status_code < 400:
log.info("rate_committed", ota=ota,
rate_plan_code=payload["rate_plan_code"], attempt=attempt)
return resp
if not is_retryable(resp.status_code) or attempt == max_retries:
return resp # terminal: caller dead-letters or alerts
retry_after = resp.headers.get("Retry-After")
delay = float(retry_after) if retry_after else compute_backoff(attempt)
log.warning("rate_throttled", ota=ota, status=resp.status_code,
attempt=attempt, delay_s=round(delay, 2),
honoured_retry_after=bool(retry_after))
await asyncio.sleep(delay) # non-blocking: never time.sleep in async code
return resp
asyncio.sleep — not time.sleep — is load-bearing here: a blocking sleep inside a coroutine would freeze the entire event loop, stalling every other channel’s concurrent pushes for the full backoff interval instead of just pausing this one.
Step 4 — Ship the idiomatic tenacity decorator
In production you rarely hand-roll the loop. Declaring the policy once at module level with tenacity builds the retry machinery a single time and keeps the retry contract readable. Defining the decorator inside the calling function would rebuild that state on every invocation and defeat attempt counting.
from tenacity import (
retry, stop_after_attempt, wait_exponential_jitter,
retry_if_exception, before_sleep_log,
)
class Throttled(Exception):
def __init__(self, status_code: int):
self.status_code = status_code
super().__init__(f"throttled: {status_code}")
@retry(
retry=retry_if_exception(lambda e: isinstance(e, Throttled)),
wait=wait_exponential_jitter(initial=1, max=30, jitter=2), # same curve as Step 1
stop=stop_after_attempt(5),
before_sleep=before_sleep_log(log, log_level=30), # WARNING
reraise=True,
)
async def push_rate(client: httpx.AsyncClient, payload: dict) -> httpx.Response:
resp = await client.post(f"/{payload['ota']}/rates/sync", json=payload)
if is_retryable(resp.status_code):
raise Throttled(resp.status_code)
resp.raise_for_status() # deterministic 4xx surface as HTTPStatusError, not a retry
return resp
Raising a dedicated Throttled exception rather than retrying on any exception keeps the policy narrow: wait_exponential_jitter fires only for the statuses you chose in Step 2, while a raise_for_status() on a 400/422 escapes the retry loop immediately as an HTTPStatusError.
Gotchas & production notes
Retry-Afterbeats your curve — always. OTAs frequently returnRetry-Afteron a429. If your wait function ignores it and uses a shorter computed delay, every retry lands before the window resets and re-trips the limit; the channel stays throttled indefinitely. Honour the header first (Step 3) and treat the exponential curve as the fallback for when the header is absent.tenacity’s built-inwait_exponential_jitterdoes not read headers, so a header-aware wrapper is required when the channel supplies one.- Jitter is not optional at fleet scale. A single worker with fixed backoff is merely slow; a fleet of workers with fixed backoff is a thundering herd that retries in lockstep and manufactures the very
429storm it is trying to escape. Full jitter de-correlates them. Verify it is actually applied — a regression that drops therandom.uniformcall still passes every functional test. - Bound total wait against the parity SLA, not just per-attempt. Five attempts at a 30s cap can stall a push for over a minute. A last-minute availability drop that arrives 90 seconds late is an overbooking risk, so cap
stop_after_attemptlow for high-priority mutations and dead-letter the rest for the nightly batch reconciliation sweep rather than blocking indefinitely. - Make retries idempotent or backoff becomes double-application. A
429sometimes arrives after the OTA already committed the mutation. Without a content-derived idempotency key on the request, the retry applies the rate a second time. Stamp one per mutation (as the governor does in handling OTA API rate limits) so a re-sent push collapses to a no-op OTA-side, and normalisestay_dateto the property’s local timezone before hashing so a UTC/local mismatch does not mint two keys for the same night.
Verification snippet
Backoff is easy to break silently, so assert the three properties that actually matter: the curve stays under its ceiling, jitter never exceeds the capped value, and classification is deterministic.
def test_backoff_curve_is_bounded_and_jittered():
# Full jitter: every sample sits within [0, capped_delay] and never exceeds the cap.
for attempt in range(0, 8):
capped = min(1.0 * 2.0 ** attempt, 30.0)
samples = [compute_backoff(attempt, max_delay=30.0) for _ in range(500)]
assert all(0.0 <= s <= capped for s in samples)
assert max(samples) <= 30.0 # ceiling holds at every attempt
# Later attempts must, on average, wait longer than early ones.
early = sum(compute_backoff(1) for _ in range(500)) / 500
late = sum(compute_backoff(4) for _ in range(500)) / 500
assert late > early
def test_status_classification_is_deterministic():
assert is_retryable(429) and is_retryable(503) and is_retryable(408)
assert not is_retryable(400) and not is_retryable(422) and not is_retryable(401)
The ceiling assertion is the one that guards production: a curve that silently loses its cap does not fail a smoke test, it just turns a brief throttle into a multi-minute stall that shows up only as stale rates on the channel.
Related
- Handling OTA API Rate Limits — the token-bucket governor this backoff curve plugs into as its
429retry policy. - Error Categorization & Retry Logic — the full deterministic-vs-transient status classification behind Step 2.
- OAuth2 Token Refresh Strategies — keeping the bearer token valid across a long retry sequence.
- Batch Reconciliation Workflows — where mutations that exhaust their retry budget get reconciled overnight.
- Async Polling for Inventory Updates — the state-reconciliation sweep for channels that stay throttled.
← Back to Handling OTA API Rate Limits