Skip to content

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:

  1. Per-API profile โ€” matched on the response host
  2. Your headers_map โ€” if you passed one to the constructor
  3. Standard header patterns โ€” the common conventions
  4. 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:

limiter.request("POST", url, json=payload, cost={"tokens": 1500})

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:

smartratelimit probe "https://api.github.com/users/octocat"
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