Debugging 429 Too Many Requests Errors in Python

A 429 is the cheapest error a provider can send you and the most expensive one to guess at. Part of the Making HTTP Requests with the Requests Library guide. This page resolves one specific decision: when your worker starts returning 429, do you add a retry, add a limiter, or change how the fleet is keyed? Those three fixes cost wildly different amounts of engineering time, and picking the wrong one burns a weekend and still pages you at 3am. The short version — read the headers first, honour Retry-After second, and only then decide whether you are fighting a per-key budget or a per-IP budget, because the fix for one does nothing for the other.

Read the headers before you touch the retry loop

Almost every debugging session that goes badly starts with someone wrapping the failing call in a retry and shipping it. The response already told you what to do. A well-behaved provider returns three families of signal on a 429: a wait instruction, a budget snapshot, and a scope hint. Ignore any of them and you are guessing.

Retry-After is the wait instruction. It is either an integer number of seconds or an HTTP-date, and RFC 9110 permits both, so your parser must handle both or it will silently fall through to a default backoff on half the providers you integrate. The x-ratelimit-* family is the budget snapshot: limit, remaining, and reset. Treat remaining as advisory rather than authoritative — it is computed on one edge node and your next request may land on another — but treat it as an excellent early warning. The scope hint is the one builders skip, and it is the one that decides your architecture.

Anatomy of a 429 response A rate-limited HTTP response with its retry-after, limit, remaining, reset and scope headers annotated with the action each one implies. What the provider actually sent back HTTP/1.1 429 Too Many Requests retry-after: 30 x-ratelimit-limit: 600 x-ratelimit-remaining: 0 x-ratelimit-reset: 1774281600 x-ratelimit-scope: api-key body: {"error": "rate_limited"} Sleep exactly 30s, then retry never invent your own number Budget is 600 per window size your bucket from this Counter belongs to the key more IPs will not help you

The reset header is the second-most-misread value here. Some providers send an absolute Unix epoch, others send seconds-until-reset. Branch on magnitude rather than trusting the docs, and log the raw string when you cannot classify it.

Retry-After is not a suggestion

Here is the client I actually ship. It uses httpx because the async path matters once you have more than one in-flight call — the trade-offs are covered in httpx vs requests for async. Every knob comes from the environment, and the 429 branch does exactly one thing: it obeys the server.

Python
import asyncio
import os
import random
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime

import httpx

MAX_SLEEP = float(os.getenv("RATE_LIMIT_MAX_SLEEP", "120"))
MAX_ATTEMPTS = int(os.getenv("RATE_LIMIT_MAX_ATTEMPTS", "5"))


def seconds_from_retry_after(raw: str | None, attempt: int) -> float:
    """Parse Retry-After as delta-seconds or HTTP-date, else back off."""
    if raw is None:
        return min(2.0**attempt, MAX_SLEEP)
    raw = raw.strip()
    if raw.isdigit():
        return min(float(raw), MAX_SLEEP)
    try:
        when = parsedate_to_datetime(raw)
    except (TypeError, ValueError):
        return min(2.0**attempt, MAX_SLEEP)
    if when.tzinfo is None:
        when = when.replace(tzinfo=timezone.utc)
    delta = (when - datetime.now(timezone.utc)).total_seconds()
    return min(max(delta, 0.0), MAX_SLEEP)


async def get_json(client: httpx.AsyncClient, path: str) -> dict:
    for attempt in range(MAX_ATTEMPTS):
        response = await client.get(path)
        if response.status_code != 429:
            response.raise_for_status()
            return response.json()
        wait = seconds_from_retry_after(response.headers.get("retry-after"), attempt)
        # Full jitter keeps a fleet from re-synchronising on the same reset tick.
        await asyncio.sleep(wait + random.uniform(0, min(wait, 1.0)))
    raise RuntimeError(f"still rate limited after {MAX_ATTEMPTS} attempts: {path}")


async def main() -> None:
    headers = {"Authorization": f"Bearer {os.environ['PROVIDER_API_KEY']}"}
    timeout = httpx.Timeout(connect=3.0, read=10.0, write=10.0, pool=5.0)
    async with httpx.AsyncClient(
        base_url=os.environ["PROVIDER_BASE_URL"], headers=headers, timeout=timeout
    ) as client:
        print(await get_json(client, os.getenv("PROVIDER_PROBE_PATH", "/v1/account")))


if __name__ == "__main__":
    asyncio.run(main())

Two details earn their keep. The jitter is added on top of the server's number rather than replacing it, so you never retry earlier than instructed while still de-synchronising a fleet that all got throttled on the same second. And the attempt cap is low: five attempts with a 120-second ceiling is roughly ten minutes of patience, which is long enough for a window reset and short enough that a broken key surfaces as a real alert. If retries are becoming a large part of your control flow, move them into a declarative policy — retrying failed HTTP requests with tenacity covers that migration.

One warning: retry only 429 and 5xx. A 401 that arrives in the middle of a throttling incident is a different bug entirely, and hammering it looks like credential stuffing to the provider's abuse system — see debugging 401 unauthorized API errors for that branch.

Per-key or per-IP? Diagnose before you scale

This is the fork that decides whether your fix works. A per-key limit follows your credential everywhere; adding machines multiplies the load against a single counter and makes the problem worse. A per-IP limit follows your egress address; if your workers sit behind one NAT gateway or one platform egress pool, ten containers share a single budget and each one thinks it is being reasonable.

Per-key and per-IP counters on the same fleet Three workers sharing one API key and one NAT egress address hit two independent counters at the provider edge; whichever reaches zero first returns the 429. worker-1 key K, 8 req/s worker-2 key K, 8 req/s worker-3 key K, 8 req/s NAT egress one shared IP provider edge counters per-key budget: 600/min 24 req/s fleet burns it in 25s per-IP budget: 1200/min unchanged by adding keys first counter to hit zero returns 429 the headers tell you which one

Diagnosing this takes ten minutes, not a redesign. Run the same request twice: once from your production egress with a second, unused key, and once from a laptop or a different region with the original key. If the fresh key from the same IP still throttles, the counter is on the address. If the original key throttles from a clean IP, the counter is on the credential. Classify the answer in code so the limiter can react instead of a human.

Python
import os
from enum import StrEnum

import httpx


class LimitScope(StrEnum):
    KEY = "key"
    ADDRESS = "address"
    UNKNOWN = "unknown"


def classify(response: httpx.Response) -> LimitScope:
    """Infer which counter throttled us from the response headers."""
    hinted = response.headers.get(
        os.getenv("RATE_LIMIT_SCOPE_HEADER", "x-ratelimit-scope"), ""
    ).lower()
    match hinted:
        case "api-key" | "key" | "account" | "organization":
            return LimitScope.KEY
        case "ip" | "address" | "global" | "edge":
            return LimitScope.ADDRESS
        case _:
            body = response.text[:400].lower()
            if "ip address" in body or "from this network" in body:
                return LimitScope.ADDRESS
            return LimitScope.KEY if "x-ratelimit-limit" in response.headers else LimitScope.UNKNOWN
SignalPer-key limitPer-IP limit
Fresh key, same hostClearsStill throttles
Same key, new hostStill throttlesClears
Correct fixPace the fleetSpread egress
Cost to fixFreeProxy or region spend

The commercial consequence sits in that last row. Pacing a fleet against a per-key budget costs you latency and nothing else. Working around a per-IP budget means paying for proxies, extra regions, or a higher provider tier — real recurring spend that belongs in your cost per API request model before you commit to it. Buying more keys to dodge a documented per-account limit is also the fastest route to a terminated account, so read the terms before you get clever.

Shape the burst client-side with a token bucket

Once you know the budget, stop discovering it by collision. A client-side token bucket converts a spiky workload into a flat one, and flat workloads never see 429 at all. The numbers matter more than the algorithm. Take a job that fires 82 requests as fast as it can against a 10-requests-per-second budget: the first second sends 40 and 30 bounce, the second sends 38 and 28 bounce, and you have paid for 58 rejected round-trips and a poisoned reputation score to finish two seconds sooner than the shaped version.

Unshaped burst versus token-bucket shaped output Bar chart of 82 requests sent over eight seconds: the unshaped run spikes to 40 and 38 requests per second above the limit of ten, while the shaped run stays flat at ten. requests sent per second, 82 total, budget 10/s 40 20 0 provider budget 0s 1s 2s 3s 4s 5s 6s 7s unshaped: 58 rejected shaped: 0 rejected

Keep the budget in configuration, not in code, so raising a tier is a deploy of a file rather than a patch. tomllib is in the standard library on 3.11 and reads a per-provider table cleanly.

Python
import asyncio
import os
import tomllib
from dataclasses import dataclass, field


@dataclass
class TokenBucket:
    """Async token bucket: `rate` tokens per second, bursting to `capacity`."""

    rate: float
    capacity: float
    tokens: float = field(init=False)
    _lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False)

    def __post_init__(self) -> None:
        self.tokens = self.capacity
        self._updated = asyncio.get_event_loop().time()

    async def acquire(self, cost: float = 1.0) -> None:
        while True:
            async with self._lock:
                now = asyncio.get_event_loop().time()
                self.tokens = min(
                    self.capacity, self.tokens + (now - self._updated) * self.rate
                )
                self._updated = now
                if self.tokens >= cost:
                    self.tokens -= cost
                    return
                wait = (cost - self.tokens) / self.rate
            await asyncio.sleep(wait)


def load_bucket(provider: str) -> TokenBucket:
    with open(os.environ["RATE_LIMIT_CONFIG"], "rb") as fh:
        table = tomllib.load(fh)["providers"][provider]
    return TokenBucket(rate=float(table["rate"]), capacity=float(table["burst"]))

Set rate to about 80 percent of the published budget. The remaining headroom absorbs clock skew between your host and the provider's window boundary, plus whatever other process on your account also holds that key. If you run a job queue, put the bucket inside the worker rather than the producer — running background jobs with Celery shows where that boundary sits.

Coordinate the fleet through one shared counter

An in-process bucket is correct for one process and wrong for three. Three replicas each pacing at the full budget send triple the budget. Move the counter to Redis, evaluate it atomically in Lua so two workers cannot both read the same token, and have the script return the wait time rather than a boolean — a boolean forces a polling loop, a wait time lets the caller sleep exactly once. The same Redis instance you already run for caching Python API responses handles this without extra infrastructure.

Two workers coordinating through a shared Redis bucket Sequence diagram: worker one takes a token from Redis and calls the provider, while worker two is told to wait 240 milliseconds instead of sending a request that would return 429. worker-1 worker-2 redis bucket provider take 1 token wait 0.0s, 3 tokens left GET /v1/items → 200 OK take 1 token wait 0.24s, bucket empty sends sleeps, no 429
Python
import asyncio
import os

import redis.asyncio as redis

BUCKET_LUA = """
local tokens = tonumber(redis.call('HGET', KEYS[1], 'tokens'))
local ts = tonumber(redis.call('HGET', KEYS[1], 'ts'))
local rate, capacity, now, cost = tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3]), tonumber(ARGV[4])
if tokens == nil then tokens = capacity; ts = now end
tokens = math.min(capacity, tokens + math.max(0, now - ts) * rate)
local wait = 0
if tokens >= cost then tokens = tokens - cost else wait = (cost - tokens) / rate end
redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', now)
redis.call('EXPIRE', KEYS[1], 300)
return tostring(wait)
"""


class FleetLimiter:
    def __init__(self, client: redis.Redis, key: str, rate: float, burst: float):
        self._script = client.register_script(BUCKET_LUA)
        self._key, self._rate, self._burst = key, rate, burst

    async def acquire(self, cost: float = 1.0) -> None:
        loop = asyncio.get_running_loop()
        while True:
            raw = await self._script(
                keys=[self._key], args=[self._rate, self._burst, loop.time(), cost]
            )
            wait = float(raw)
            if wait <= 0:
                return
            await asyncio.sleep(wait)


async def build_limiter() -> FleetLimiter:
    client = redis.from_url(os.environ["REDIS_URL"], decode_responses=True)
    return FleetLimiter(
        client,
        key=os.getenv("RATE_LIMIT_KEY", "ratelimit:provider"),
        rate=float(os.getenv("RATE_LIMIT_RATE", "8")),
        burst=float(os.getenv("RATE_LIMIT_BURST", "16")),
    )

Use a wall-clock source shared across hosts in production rather than each loop's monotonic clock; time.time() with NTP is accurate enough for a limiter measured in hundreds of milliseconds. Emit the returned wait value as a metric. Rising wait times are the leading indicator that you need a higher tier, and structured logging with structlog makes that a one-line dashboard query.

When to choose each fix, and when to skip it

Retry-only is right when 429 is rare — under one percent of calls — and your workload is a handful of interactive requests. Adding a limiter there costs latency on every single call to save you a retry you almost never perform. An in-process token bucket wins the moment you have one batch job that fans out, and it stays right until you run more than one replica. The Redis-backed fleet limiter earns its operational cost at three or more workers, or any autoscaling group where the replica count is not something you control.

Avoid the distributed limiter when throughput is genuinely small: a shared counter adds one to three milliseconds to every outbound call and one more service that can take your pipeline down. Avoid buying proxy egress until you have proven the counter is on the address, because that spend recurs monthly.

Migration path from blind retries to a shaped fleet

Do this in four steps, each shippable on its own. First, log the full header set on every 429 for a day and classify the scope — this is the diagnosis and it costs you one deploy. Second, replace the fixed sleep with Retry-After parsing and jitter; this alone typically halves the error rate because you stop retrying into a closed window. Third, drop the in-process bucket in front of the client and set the rate from the observed x-ratelimit-limit at 80 percent. Fourth, when you scale past one replica, swap the bucket implementation for the Redis-backed one — keep the acquire() signature identical and the call sites never change. If a key rotation is on your roadmap, sequence it after step three, since rotating API keys without downtime briefly doubles your key count and confuses a per-key counter.

Builder verdict

Ship the Redis-backed token bucket. Not on day one, but as the target you migrate toward, because it is the only option whose cost stays flat as you scale. Retry loops are free to write and expensive to run: every rejected call still costs you a TCP handshake, a TLS negotiation, and a slot in your connection pool, and at a hundred thousand calls a day a five percent rejection rate is five thousand wasted round-trips that also degrade how the provider scores your traffic. The in-process bucket is the right stopgap for a single-container side project and takes twenty minutes. The distributed version takes an afternoon and one Redis key you probably already pay for, and it removes an entire class of 3am pages. The one thing not worth doing is minting extra API keys to route around a limit: it works for a month, then the provider notices, and rebuilding under a suspension notice costs more than every other option here combined.

FAQ

How much does a 429 storm actually cost me at 1M requests a month? Rejected calls are not free even though they return no data. At a five percent rejection rate you pay for 50,000 extra TLS handshakes, roughly 40 to 60 seconds of aggregate worker CPU, and the retry traffic itself. On a small container that is a couple of dollars a month in compute, but the real cost is the throughput ceiling: workers blocked in backoff are workers not doing billable work, so you provision more of them than you need.

Is it cheaper to pace my requests or to buy a higher provider tier? Pace first, always. Shaping costs you engineering hours once and zero recurring spend, and it also tells you your true required throughput. Once a shaped client still saturates its budget for hours a day, you have hard evidence for the upgrade, and you can price the tier against the revenue that traffic produces instead of guessing.

Will rotating my API key reset the rate limit counter? No, for any provider worth integrating. Limits are attached to the account or project, not the individual credential, so a fresh key inherits the same budget. Rotation that appears to reset the counter usually means the counter is per-key on a provider that also enforces a per-account ceiling you have not hit yet, and relying on that is a good way to lose the account.

How risky is migrating from a per-process limiter to a shared Redis one? Low, if you keep the interface identical. The failure mode to plan for is Redis being unreachable: decide upfront whether the limiter fails open (send anyway, risk 429) or fails closed (block, risk a stalled queue). For paid third-party APIs, fail open with a conservative local fallback bucket — a stalled pipeline usually costs more than a handful of rejected calls.

Does this apply to the API I sell, or only the ones I consume? Both, from opposite sides. Everything here describes being a good client; the mirror image is enforcing limits on your own customers, which is where tier design and preventing free-tier abuse come in. Send the same headers you wish providers sent you — a documented retry-after and an honest scope hint cut your support load noticeably.