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 by sleeping and retrying once. 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.

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
limit Nothing is stored; detection fails and the request is unpaced

That 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