DEV Community

Sir Max
Sir Max

Posted on

The Right Way to Handle API Rate Limits — A Practical Guide with Python Code

The Right Way to Handle API Rate Limits — A Practical Guide with Python Code

A few years ago I was building an integration with a payment API. Everything worked fine in testing. Then Black Friday hit.

Our backend started making calls as fast as it could. The API responded with 429 Too Many Requests. Our code, in its infinite wisdom, saw the error and retried immediately — adding even more pressure. Ten minutes later, we were rate-limited for the rest of the day. We lost thousands in transactions.

I learned the hard way that retry logic is not just about retrying. It's about how and when you retry. Here's everything I wish someone had told me before that day.


The Naive Approach (Don't Do This)

import requests

def call_api_naive(url, headers):
    for attempt in range(5):
        resp = requests.get(url, headers=headers)
        if resp.status_code == 429:
            continue  # try again immediately — BAD
        return resp.json()
    raise Exception("API failed after 5 attempts")
Enter fullscreen mode Exit fullscreen mode

This makes everything worse. Every retry hits the API at the same moment, creating a tight loop of 429s. You're not solving the problem — you're part of it.


The Three Rules of Good Retry Logic

Rule 1: Exponential Backoff

Don't retry immediately. Wait. Then wait longer. Then even longer.

The math is simple: delay = base_delay * (2 ** attempt). With a 1-second base and 5 attempts, your delays look like this:

Attempt 0: instant
Attempt 1: wait 1 second
Attempt 2: wait 2 seconds
Attempt 3: wait 4 seconds
Attempt 4: wait 8 seconds
Enter fullscreen mode Exit fullscreen mode
import time

def call_with_backoff(url, headers, max_retries=5, base_delay=1):
    for attempt in range(max_retries):
        resp = requests.get(url, headers=headers)
        if resp.status_code != 429:
            return resp.json()

        delay = base_delay * (2 ** attempt)
        print(f"Rate limited. Waiting {delay}s before retry {attempt + 1}")
        time.sleep(delay)

    raise Exception(f"Still rate-limited after {max_retries} attempts")
Enter fullscreen mode Exit fullscreen mode

This alone would have saved my Black Friday. But there's more.

Rule 2: Add Jitter

If you have 100 servers all hitting the same API, and they all use exponential backoff with the same base delay — they're still in sync. They all retry at exactly the same moment.

The fix is jitter: add a random component to the delay.

import random

delay = base_delay * (2 ** attempt) + random.uniform(0, 1)
Enter fullscreen mode Exit fullscreen mode

Now your 100 servers spread their retries across a 1-second window instead of colliding at the exact same millisecond. The API sees a smooth flow of requests instead of spikes.

Rule 3: Respect the Retry-After Header

Most APIs that return 429 also send a Retry-After header telling you exactly how long to wait. Ignoring it is like ignoring a traffic cop.

def call_api_smart(url, headers, max_retries=5, base_delay=1):
    for attempt in range(max_retries):
        resp = requests.get(url, headers=headers)

        if resp.status_code == 429:
            # Try to read Retry-After header
            retry_after = resp.headers.get("Retry-After")
            if retry_after and retry_after.isdigit():
                delay = int(retry_after)
            else:
                delay = base_delay * (2 ** attempt) + random.uniform(0, 1)

            print(f"429 received. Waiting {delay:.1f}s (attempt {attempt + 1}/{max_retries})")
            time.sleep(delay)
            continue

        resp.raise_for_status()
        return resp.json()

    raise Exception(f"API unavailable after {max_retries} attempts")
Enter fullscreen mode Exit fullscreen mode

Putting It All Together

Here's a reusable RateLimitHandler class I've been using in production:

import time
import random
import requests
from typing import Optional, Dict, Any

class RateLimitHandler:
    def __init__(self, max_retries: int = 5, base_delay: float = 1.0):
        self.max_retries = max_retries
        self.base_delay = base_delay

    def call(self, url: str, headers: Dict[str, str], 
             method: str = "GET", json_data: Optional[Dict] = None) -> Dict[str, Any]:

        for attempt in range(self.max_retries):
            resp = requests.request(method, url, headers=headers, json=json_data)

            if resp.status_code == 429:
                delay = self._get_delay(resp, attempt)
                print(f"[429] Backing off {delay:.1f}s (attempt {attempt + 1})")
                time.sleep(delay)
                continue

            if resp.status_code >= 500:
                delay = self._get_delay(resp, attempt)
                print(f"[5xx] Server error. Retrying in {delay:.1f}s")
                time.sleep(delay)
                continue

            resp.raise_for_status()
            return resp.json()

        raise Exception(f"All {self.max_retries} retries exhausted")

    def _get_delay(self, resp, attempt: int) -> float:
        retry_after = resp.headers.get("Retry-After")
        if retry_after and retry_after.isdigit():
            return float(retry_after)
        return self.base_delay * (2 ** attempt) + random.uniform(0, 1)

# Usage
handler = RateLimitHandler(max_retries=5, base_delay=1.0)
data = handler.call("https://api.example.com/v1/data", 
                     headers={"Authorization": "Bearer YOUR_TOKEN"})
Enter fullscreen mode Exit fullscreen mode

What I Learned the Hard Way

  1. Never retry without a delay. Immediate retries compound the problem.
  2. Jitter is not optional at scale. Without it, thundering herds still happen.
  3. Retry-After is a gift. Use it. The API is telling you what it needs.
  4. Log everything. When something breaks at 3 AM, you'll want to see the backoff pattern.
  5. Have a circuit breaker. If the API keeps returning 429s for 30+ seconds, stop trying. Return a degraded response or cached data. Don't take down your own service.

The Black Friday disaster was avoidable. A few dozen lines of code would have kept us running through the traffic spike. If you're calling any third-party API — payment, AI, social media, anything — take 10 minutes to add proper retry logic. Your future self will thank you.


What's your approach to handling rate limits? I'd love to hear what patterns you use in the comments.

Top comments (0)