Detection & Headers¶
Detection is the reason RateLimiter() needs no arguments. This page is what it looks for, in what order, and what to do when an API doesn't play along.
Resolution order¶
For every response, the detector tries these in sequence and stops at the first that yields a usable limit:
- Per-API profile โ matched on the response host
- Your
headers_mapโ if you passed one to the constructor - Standard header patterns โ the common conventions
Retry-Afterโ 429 responses only
1. Per-API profiles¶
| Host | Limit | Remaining | Reset |
|---|---|---|---|
github.com |
X-RateLimit-Limit |
X-RateLimit-Remaining |
X-RateLimit-Reset |
api.stripe.com |
Stripe-RateLimit-Limit |
Stripe-RateLimit-Remaining |
Stripe-RateLimit-Reset |
api.twitter.com |
x-rate-limit-limit |
x-rate-limit-remaining |
x-rate-limit-reset |
api.openai.com |
x-ratelimit-limit-requests |
x-ratelimit-remaining-requests |
x-ratelimit-reset-requests |
Profiles are matched against the exact host of the response URL. api.github.com is not github.com โ requests to the API host fall through to the standard patterns in step 3, which recognise the same X-RateLimit-* headers anyway. The profiles exist for APIs whose header names are not standard.
2. Custom header map¶
For an API with its own naming:
from smartratelimit import RateLimiter
limiter = RateLimiter(
headers_map={
"limit": "X-MyAPI-Quota",
"remaining": "X-MyAPI-Quota-Left",
"reset": "X-MyAPI-Quota-Reset",
}
)
The map applies to every host this limiter talks to, and it is consulted after the built-in profiles. limit is required for the map to match; remaining and reset are optional and filled in with defaults if missing (see Gaps and fallbacks).
If you talk to several APIs with different conventions, use one limiter per API rather than one map that tries to cover both:
github = RateLimiter(storage="sqlite:///limits.db")
vendor = RateLimiter(storage="sqlite:///limits.db",
headers_map={"limit": "X-Vendor-Limit",
"remaining": "X-Vendor-Remaining",
"reset": "X-Vendor-Reset"})
They can share a backend safely โ state is keyed by endpoint.
3. Standard patterns¶
Recognised without any configuration, checked in this order:
| Role | Headers |
|---|---|
| Limit | X-RateLimit-Limit, X-RateLimit-Requests-Limit, RateLimit-Limit, X-Rate-Limit-Limit |
| Remaining | X-RateLimit-Remaining, X-RateLimit-Requests-Remaining, RateLimit-Remaining, X-Rate-Limit-Remaining |
| Reset | X-RateLimit-Reset, X-RateLimit-Requests-Reset, RateLimit-Reset, X-Rate-Limit-Reset |
| Retry after | Retry-After, X-Retry-After |
The X-RateLimit-Requests-* triplet is the RapidAPI convention, so most RapidAPI-hosted endpoints are detected out of the box.
Header lookup goes through requests' case-insensitive header mapping, so x-ratelimit-limit and X-RateLimit-Limit are the same header.
4. Retry-After on 429¶
If a 429 arrives with no rate-limit headers at all, Retry-After is still useful: it gives a window (and remaining = 0) even though it never gives a limit. Both integer seconds and RFC 7231 HTTP dates are parsed.
This path informs the stored window, and request() separately honours Retry-After when deciding how long to wait before each retry. See What happens on a 429.
How reset values are parsed¶
The reset header is tried as, in order:
| Value looks like | Read as | Example |
|---|---|---|
| An integer < 86400 | Seconds from now | 3600 โ resets in one hour |
| A number > 1000000000 | Unix timestamp (UTC) | 1734567890 โ an absolute time |
| ISO 8601 | Absolute datetime (Z accepted) |
2026-08-15T12:00:00Z |
| Anything else numeric | Seconds from now | โ |
The 86400 cutoff is the one rule to remember: a reset expressed as "seconds remaining" is only distinguishable from a timestamp by magnitude, so anything under a day is treated as relative.
Timestamps that resolve to a time in the past are discarded, and the window falls back to one hour.
Metered dimensions beyond requests¶
An LLM API meters tokens as well as requests, and the token budget is usually the one that binds first. Those headers are read too, and stored as separate dimensions of the same scope:
| Host | Dimension | Headers |
|---|---|---|
api.openai.com |
tokens |
x-ratelimit-{limit,remaining,reset}-tokens |
api.anthropic.com |
tokens |
anthropic-ratelimit-tokens-{limit,remaining,reset} |
api.anthropic.com |
input_tokens, output_tokens |
anthropic-ratelimit-{input,output}-tokens-* |
| any | tokens |
X-RateLimit-{Limit,Remaining,Reset}-Tokens |
Reading only the request headers would leave the limiter confidently pacing against a number that is not the constraint. Tell it what each call costs:
Gaps and fallbacks¶
APIs are inconsistent. What happens when a piece is missing:
| Missing | Behaviour |
|---|---|
remaining |
Assumed equal to limit โ treated as a fresh window |
reset |
Window defaults to 1 hour from now, and the limit is marked confidence="estimated" |
limit |
Nothing is stored; detection fails and the request is unpaced |
The reset row deserves care. A limit of 100 with no reset header could mean 100 per minute or 100 per day, and guessing wrong either throttles you for nothing or lets you sail past the real limit. The library does not present that guess as a reading โ status.confidence is "estimated" whenever the window was assumed:
status = limiter.get_status("api.example.com")
if status.confidence == "estimated":
# Replace the guess with the limit from the provider's docs
limiter.set_limit("api.example.com", limit=100, window="1m")
confidence is "confirmed" when the API reported its own window, and
"configured" when you set the limit yourself. Change the assumed window with
RateLimitDetector(default_window=...).
The last row is the important one. Without a limit header there is nothing to pace against โ remaining alone isn't enough. Supply the limit yourself:
# Fallback for any endpoint with no detected limit
limiter = RateLimiter(default_limits={"requests_per_minute": 60})
# Or state it per endpoint
limiter.set_limit("api.example.com", limit=5000, window="1h")
A detected limit always overrides default_limits; the defaults are only applied when nothing is stored for the endpoint yet.
Finding out what an API sends¶
Before writing a header map, ask:
Probing https://api.github.com/users/octocat...
Response Status: 200
Rate Limit Headers:
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 59
X-RateLimit-Reset: 1734567890
Detected Rate Limit:
Limit: 60
Remaining: 59
Window: 0:59:31
If the "Detected Rate Limit" section says nothing was found but you can see limit-ish headers in the response, that's exactly the case headers_map is for. More on the command: CLI.
Inspecting the detector directly¶
Useful in tests, or to check a map against a saved response:
import requests
from smartratelimit.detector import RateLimitDetector
detector = RateLimitDetector({"limit": "X-MyAPI-Quota",
"remaining": "X-MyAPI-Quota-Left",
"reset": "X-MyAPI-Quota-Reset"})
response = requests.get("https://api.example.com/data")
print(detector.detect_from_response(response))
# {'limit': 1000, 'remaining': 998, 'reset_time': datetime(...), 'window': timedelta(...)}
# or None if nothing matched