Handle Python API Rate Limiting Gracefully
Learn to handle Python API rate limiting with exponential backoff, Retry-After headers, custom throttling, and circuit breakers. Practical code examples for resilient API clients.
How to Handle Python API Rate Limiting Gracefully
Ever sent too many requests to an API and got slapped with a 429 status code? It's frustrating, but it's actually the API protecting itself — and you. Rate limiting is how services ensure fair usage and prevent server overload.
At PythonSkillset, we've dealt with this countless times. Whether you're pulling data from Twitter, GitHub, or Stripe, hitting rate limits is inevitable. The real skill isn't avoiding them — it's handling them gracefully.
What Rate Limiting Looks Like in Practice
Rate limits come in different flavors. Some APIs tell you exactly how many requests you can make per minute. Others use sliding windows or token buckets. The common responses you'll see:
- 429 Too Many Requests — the classic
- Retry-After header — the server telling you when to try again
- X-RateLimit-Remaining — a countdown until your next batch
For example, the GitHub API allows 60 unauthenticated requests per hour. Try firing 61 in 10 seconds, and you'll get a 429 faster than you can say "pagination."
The Wrong Way: Just Retry Immediately
import requests
response = requests.get("https://api.example.com/data")
if response.status_code == 429:
response = requests.get("https://api.example.com/data") # Same mistake!
This is like banging on a door that just slammed shut. You'll get hit again, and you might even get IP-blocked.
The Right Way: Exponential Backoff
Exponential backoff means you wait longer between each retry attempt. Start with 1 second, then 2, then 4, then 8 — you get the idea. Most APIs recommend this.
Here's a simple implementation:
import time
import requests
from requests.exceptions import RequestException
def fetch_with_backoff(url, headers=None, max_retries=5):
for attempt in range(max_retries):
try:
response = requests.get(url, headers=headers)
if response.status_code == 429:
wait_time = 2 ** attempt # 1, 2, 4, 8, 16 seconds
print(f"Rate limited. Waiting {wait_time} seconds...")
time.sleep(wait_time)
continue
response.raise_for_status()
return response
except RequestException as e:
if attempt == max_retries - 1:
raise e
time.sleep(2 ** attempt)
return None
Using Retry-After Header
Many APIs send a Retry-After header with a specific number of seconds to wait. This is much more reliable than guessing:
def fetch_with_retry_after(url):
response = requests.get(url)
if response.status_code == 429:
retry_after = int(response.headers.get("Retry-After", 60))
print(f"Server says wait {retry_after} seconds")
time.sleep(retry_after)
return requests.get(url)
return response
Throttling Requests Before They Happen
Prevention is better than cure. If you know your limit is 100 requests per minute, pace yourself. This is where token bucket algorithms come in handy.
Here's a custom rate limiter you can drop into any script:
import time
from collections import deque
class RateLimiter:
def __init__(self, max_calls, period=60):
self.max_calls = max_calls
self.period = period
self.calls = deque()
def wait_if_needed(self):
now = time.time()
# Remove calls older than the period
while self.calls and self.calls[0] < now - self.period:
self.calls.popleft()
if len(self.calls) >= self.max_calls:
sleep_time = self.calls[0] + self.period - now
if sleep_time > 0:
print(f"Throttling: waiting {sleep_time:.2f}s")
time.sleep(sleep_time)
self.calls.append(time.time())
# Usage
limiter = RateLimiter(max_calls=30, period=60)
for i in range(100):
limiter.wait_if_needed()
response = requests.get("https://api.example.com/data")
When It's Not Your Fault
Sometimes rate limits hit because of shared infrastructure. If you're using a company VPN or a cloud server, you might share an IP with other users. The fix? Use a dedicated IP or authenticate properly. Authenticated requests often get higher limits.
At PythonSkillset, we once had a dashboard that scraped data from multiple sources. By implementing queue-based throttling, we reduced 429 errors by 95% and improved data freshness.
The All-in-One Solution: backoff Library
Why reinvent the wheel? Python has a fantastic library called backoff that handles all this for you:
import backoff
import requests
@backoff.on_exception(
backoff.expo,
requests.exceptions.RequestException,
max_tries=8,
giveup=lambda e: e.response.status_code != 429 if hasattr(e, 'response') else False
)
def fetch_data(url):
response = requests.get(url)
response.raise_for_status()
return response.json()
This library handles exponential backoff, jitter (random wait time to avoid thundering herd), and even respects Retry-After headers.
Practical Tips From the Trenches
Add jitter to your waits — randomize your sleep times by ±10-20%. This prevents all your retries from hitting the server simultaneously.
Log every rate limit event — track how often you get hit. It tells you if you're overfetching or if the API changed its limits.
Respect weekends and nights — some APIs have different limits during off-peak hours.
Use async when possible — with asyncio and aiohttp, you can make requests concurrently while still respecting limits. It's faster and more efficient.
When to Give Up
Not every retry is worth it. For ephemeral data, failing fast is better than waiting minutes for a 429 to clear. Add a circuit breaker pattern: after X consecutive failures, stop trying for a few minutes.
class CircuitBreaker:
def __init__(self, failure_threshold=5, recovery_time=60):
self.failure_count = 0
self.failure_threshold = failure_threshold
self.recovery_time = recovery_time
self.last_failure_time = None
self.state = "closed" # closed, open, half-open
def call(self, func, *args, **kwargs):
if self.state == "open":
if time.time() - self.last_failure_time > self.recovery_time:
self.state = "half-open"
else:
raise Exception("Circuit breaker is open")
try:
result = func(*args, **kwargs)
self.failure_count = 0
self.state = "closed"
return result
except Exception as e:
self.failure_count += 1
self.last_failure_time = time.time()
if self.failure_count >= self.failure_threshold:
self.state = "open"
raise e
Final Thoughts
Rate limiting isn't a bug — it's a feature. APIs use it to keep their servers healthy and to ensure fair access for everyone. When you handle it gracefully, you're not just a better developer. You're a better neighbor in the API ecosystem.
Start simple with exponential backoff and add retry logic based on headers. As your needs grow, reach for libraries like backoff or build your own throttling system. The key is to be patient and respectful — the data will come.
Happy coding, and may your status codes always be 200.
Comments
Questions, corrections, and tips stay visible for everyone reading this page.
Join the discussion
No comments yet
Be the first to leave a note — it helps the next reader.