DEV Community

Mean for APIKumo

Posted on

Handling API Rate Limits Gracefully: Retry Logic, Exponential Backoff, and the Headers You're Ignoring

Handling API Rate Limits Gracefully: Retry Logic, Exponential Backoff, and the Headers You're Ignoring

Every API developer has seen it: a 429 Too Many Requests response that breaks your integration at the worst possible time. Rate limits exist for a good reason — they protect API servers from abuse and keep things fair for everyone. But most client code handles them poorly, either crashing outright or hammering the server in a tight retry loop that makes things worse.

This guide covers how to handle rate limits like a pro, with real code you can drop into your projects today.


Understanding Rate Limit Headers

Before writing any retry logic, read what the server is already telling you. Most APIs return rate limit metadata in response headers. Here are the three most common:

Header Meaning
X-RateLimit-Limit Total requests allowed in the window
X-RateLimit-Remaining Requests left in the current window
X-RateLimit-Reset Unix timestamp when the window resets
Retry-After Seconds (or date) to wait before retrying (used with 429)

A smart client reads these on every response, not just on errors:

import httpx
import time

def request_with_rate_awareness(client: httpx.Client, url: str) -> httpx.Response:
    response = client.get(url)

    remaining = int(response.headers.get("X-RateLimit-Remaining", 1))
    reset_at   = int(response.headers.get("X-RateLimit-Reset", 0))

    # Proactively slow down when nearly exhausted
    if remaining < 5:
        wait = max(0, reset_at - int(time.time()))
        print(f"Rate limit almost exhausted. Waiting {wait}s for reset.")
        time.sleep(wait)

    return response
Enter fullscreen mode Exit fullscreen mode

Reading these headers lets you prevent 429s rather than just recovering from them.


Basic Retry with Exponential Backoff

When you do hit a 429, the worst thing you can do is retry immediately in a loop. Each retry that arrives too early just wastes one of your future quota slots. Instead, use exponential backoff: wait a little, then a little more, doubling each time.

import time
import random
import httpx

def fetch_with_backoff(url: str, max_retries: int = 5) -> httpx.Response:
    delay = 1.0  # start with 1 second

    for attempt in range(max_retries):
        response = httpx.get(url)

        if response.status_code != 429:
            return response

        # Respect Retry-After if provided
        retry_after = response.headers.get("Retry-After")
        if retry_after:
            wait = float(retry_after)
        else:
            # Exponential backoff with jitter
            wait = delay * (2 ** attempt) + random.uniform(0, 0.5)

        print(f"Rate limited. Attempt {attempt + 1}/{max_retries}. Waiting {wait:.1f}s.")
        time.sleep(wait)

    raise Exception(f"Max retries exceeded for {url}")
Enter fullscreen mode Exit fullscreen mode

The random.uniform(0, 0.5) part is called jitter — it prevents a thundering herd problem where many clients all wake up and retry at the exact same moment.


A Reusable Rate-Limit-Aware HTTP Client

For production use, wrap this into a reusable class that handles everything automatically:

import time
import random
import httpx
from typing import Optional

class RateLimitAwareClient:
    def __init__(self, base_url: str, api_key: str, max_retries: int = 5):
        self.base_url = base_url.rstrip("/")
        self.headers = {"Authorization": f"Bearer {api_key}"}
        self.max_retries = max_retries

    def get(self, path: str, **kwargs) -> dict:
        url = f"{self.base_url}{path}"
        return self._request("GET", url, **kwargs)

    def post(self, path: str, **kwargs) -> dict:
        url = f"{self.base_url}{path}"
        return self._request("POST", url, **kwargs)

    def _request(self, method: str, url: str, **kwargs) -> dict:
        delay = 1.0
        for attempt in range(self.max_retries):
            with httpx.Client() as client:
                response = client.request(method, url, headers=self.headers, **kwargs)

            if response.status_code == 429:
                retry_after = response.headers.get("Retry-After")
                wait = float(retry_after) if retry_after else (delay * (2 ** attempt) + random.uniform(0, 0.5))
                print(f"[429] Waiting {wait:.1f}s (attempt {attempt + 1})")
                time.sleep(wait)
                continue

            response.raise_for_status()

            # Proactive slow-down
            remaining = int(response.headers.get("X-RateLimit-Remaining", 99))
            if remaining < 3:
                reset_at = int(response.headers.get("X-RateLimit-Reset", time.time()))
                time.sleep(max(0, reset_at - int(time.time())))

            return response.json()

        raise Exception(f"Exceeded {self.max_retries} retries for {url}")
Enter fullscreen mode Exit fullscreen mode

Usage is clean:

client = RateLimitAwareClient("https://api.example.com", api_key="your-key")
data = client.get("/users/me")
Enter fullscreen mode Exit fullscreen mode

Common Mistakes to Avoid

Retrying non-retriable errors. Only retry 429 (and often 503). Don't retry 401, 403, or 422 — those won't succeed no matter how many times you try.

No jitter. Without randomness, all your parallel workers retry in sync, spiking the server together.

Ignoring Retry-After. When the server tells you exactly how long to wait, use that value — it's more accurate than your own backoff calculation.

Not logging rate limit events. Hitting rate limits frequently is a signal your integration needs optimization — batch requests, cache responses, or upgrade your API plan.


Wrapping Up

Rate limits aren't an obstacle — they're a contract. Read the headers, wait the right amount of time, and add jitter to be a good citizen. A well-behaved client recovers from 429s invisibly and never hammers the server into the ground.

Tools like APIKumo let you define pre/post processors on your API collections, so you can bake this retry logic in once and have it apply across every request automatically — no copy-pasting across projects.

Happy building — and may your Remaining counter never hit zero at a bad time.

Top comments (0)