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")
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
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")
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)
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")
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"})
What I Learned the Hard Way
- Never retry without a delay. Immediate retries compound the problem.
- Jitter is not optional at scale. Without it, thundering herds still happen.
-
Retry-Afteris a gift. Use it. The API is telling you what it needs. - Log everything. When something breaks at 3 AM, you'll want to see the backoff pattern.
- 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)