"""Rate limiting for Responder, with pluggable storage backends."""
import functools
import inspect
import logging
import math
import threading
import time
from collections import OrderedDict
from typing import Protocol, runtime_checkable
from starlette.concurrency import run_in_threadpool
from ..util.net import resolve_client_ip
logger = logging.getLogger("responder")
def _find_req_resp(args):
"""Locate the Request and Response among a view's positional arguments.
Function views are called ``(req, resp)``; class-based-view methods are
bound, so the framework's ``view(request, response)`` call arrives as
``(self, req, resp)``. Selecting by type instead of position handles both.
"""
from ..models import Request, Response
req = resp = None
for arg in args:
if isinstance(arg, Request):
req = arg
elif isinstance(arg, Response):
resp = arg
return req, resp
@runtime_checkable
class RateLimitBackend(Protocol):
"""A synchronous rate-limit store.
``hit`` may return either ``(allowed, remaining)`` or — preferred —
``(allowed, remaining, reset_after)`` where ``reset_after`` is the
number of seconds until the client's window resets (used for the
``X-RateLimit-Reset`` response header). Two-tuple backends keep
working; the header is simply omitted.
"""
def hit(self, key: str, max_requests: int, period: int) -> tuple[bool, int]: ...
@runtime_checkable
class AsyncRateLimitBackend(Protocol):
"""An async-native rate-limit store (awaited directly, no thread).
Like :class:`RateLimitBackend`, ``ahit`` may return a 2-tuple
``(allowed, remaining)`` or a 3-tuple ``(allowed, remaining,
reset_after)``.
"""
async def ahit(
self, key: str, max_requests: int, period: int
) -> tuple[bool, int]: ...
class MemoryBackend:
"""Sliding-window backend storing hit timestamps in process memory.
The default backend. Counts are per-process — for multi-process or
multi-host deployments, use :class:`RedisBackend` instead.
Keys are bounded: at most ``max_keys`` distinct clients are tracked, with
least-recently-seen keys evicted beyond the cap. This stops an attacker who
rotates source IPs (or spoofs ``X-Forwarded-For``) from growing process
memory without bound. Evicting a key resets its window (fail-open), which is
acceptable for an in-memory single-process limiter.
"""
def __init__(self, max_keys: int = 100_000):
self._buckets: OrderedDict[str, list[float]] = OrderedDict()
self._lock = threading.Lock()
self._max_keys = max_keys
def hit(self, key, max_requests, period):
"""Record a hit for ``key``.
Returns ``(allowed, remaining, reset_after)``, where ``reset_after``
is the number of seconds until the oldest hit in the window expires.
"""
now = time.time()
cutoff = now - period
with self._lock:
bucket = [t for t in self._buckets.get(key, ()) if t > cutoff]
self._buckets[key] = bucket
self._buckets.move_to_end(key) # mark most-recently-used
if len(bucket) >= max_requests:
reset_after = max(bucket[0] + period - now, 0.0)
return False, 0, reset_after
bucket.append(now)
# Evict least-recently-used keys once over the cap.
while self._max_keys is not None and len(self._buckets) > self._max_keys:
self._buckets.popitem(last=False)
reset_after = max(bucket[0] + period - now, 0.0)
return True, max_requests - len(bucket), reset_after
# Atomic fixed-window increment: bump the counter and, on the first hit of a
# window, attach the TTL — in a single server-side step. Doing INCR and EXPIRE
# as separate calls risks the key never expiring (and thus locking a client out
# permanently) if the process dies between them. Also reads the remaining TTL
# in the same step so `hit` can report when the window resets.
_INCR_EXPIRE_LUA = """
local count = redis.call('INCR', KEYS[1])
if count == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[1])
end
local ttl = redis.call('TTL', KEYS[1])
if ttl < 0 then
ttl = tonumber(ARGV[1])
end
return {count, ttl}
"""
def _eval_result(result, period):
"""Normalize the Lua script's ``{count, ttl}`` reply.
Tolerates a bare count (an int) from fakes/proxies that emulate the
pre-8.1 script, falling back to ``period`` as the reset time.
"""
if isinstance(result, (list, tuple)):
count, ttl = result
return int(count), int(ttl)
return int(result), period
class RedisBackend:
"""Fixed-window backend backed by Redis, shared across processes.
Pass an existing client, or a ``url`` to create one (requires the
``redis`` package)::
from responder.ext.ratelimit import RateLimiter, RedisBackend
limiter = RateLimiter(
requests=100, period=60,
backend=RedisBackend(url="redis://localhost:6379/0"),
)
"""
def __init__(self, client=None, *, url=None, prefix="responder:ratelimit:"):
if client is None:
try:
import redis
except ImportError as exc:
raise ImportError(
"redis is required for RedisBackend: pip install redis"
) from exc
client = redis.Redis.from_url(url or "redis://localhost:6379/0")
self.client = client
self.prefix = prefix
def hit(self, key, max_requests, period):
"""Record a hit for ``key``.
Returns ``(allowed, remaining, reset_after)``, where ``reset_after``
is the remaining TTL of the current fixed window, in seconds.
"""
redis_key = self.prefix + key
result = self.client.eval(_INCR_EXPIRE_LUA, 1, redis_key, period)
count, ttl = _eval_result(result, period)
if count > max_requests:
return False, 0, ttl
return True, max_requests - count, ttl
class AsyncRedisBackend:
"""Async-native fixed-window Redis backend (uses ``redis.asyncio``)."""
def __init__(self, client=None, *, url=None, prefix="responder:ratelimit:"):
if client is None:
try:
from redis import asyncio as aioredis
except ImportError as exc:
raise ImportError(
"redis is required for AsyncRedisBackend: pip install redis"
) from exc
client = aioredis.Redis.from_url(url or "redis://localhost:6379/0")
self.client = client
self.prefix = prefix
async def ahit(self, key, max_requests, period):
redis_key = self.prefix + key
result = await self.client.eval(_INCR_EXPIRE_LUA, 1, redis_key, period)
count, ttl = _eval_result(result, period)
if count > max_requests:
return False, 0, ttl
return True, max_requests - count, ttl
[docs]
class RateLimiter:
"""Per-client request rate limiter.
The limiting algorithm depends on the backend: :class:`MemoryBackend`
(the default) is a sliding window over the last ``period`` seconds,
while :class:`RedisBackend`/:class:`AsyncRedisBackend` count within
fixed windows.
Usage::
from responder.ext.ratelimit import RateLimiter
limiter = RateLimiter(requests=100, period=60) # 100 req/min
limiter.install(api)
Enforcement can also be hand-rolled in a before-request hook when you
need custom logic around it::
@api.route(before_request=True)
def rate_limit(req, resp):
limiter.check(req, resp)
Prefer :meth:`install` (or :meth:`limit`) when you don't: they enforce
the same way, but a manual ``check()`` call is invisible to the OpenAPI
generator, so the schema won't document the ``429``/``503`` responses.
To rate-limit a single route, apply :meth:`limit` beneath ``@api.route``.
Give each route its own ``RateLimiter`` for an independent budget::
expensive_limiter = RateLimiter(requests=5, period=60)
@api.route("/expensive")
@expensive_limiter.limit
async def expensive(req, resp):
...
"""
def __init__(
self,
requests=100,
period=60,
backend=None,
trust_proxy_headers=False,
key=None,
fail_open=False,
):
"""Create a rate limiter.
:param requests: Maximum requests allowed per ``period``.
:param period: The window length, in seconds.
:param backend: Storage backend (defaults to :class:`MemoryBackend`).
Any object with a
``hit(key, max_requests, period)`` method returning
``(allowed, remaining)`` or ``(allowed, remaining,
reset_after)`` works, e.g. :class:`RedisBackend`.
:param trust_proxy_headers: If ``True``, key by the proxy's
forwarding headers instead of the TCP peer — RFC 7239
``Forwarded``, then ``X-Forwarded-For``, then
``X-Real-IP``, matching ``ProxyHeadersMiddleware``
and access logging. Set this only
when Responder sits behind a reverse proxy that sets
those headers itself — behind a proxy, every request's
peer is the proxy, so without this every client shares
one rate-limit bucket; with it unset and no proxy in
front, a client could otherwise spoof the header to
dodge the limit.
:param key: Optional callable ``req -> str`` that names the bucket a
request counts against — e.g. an API key
(``lambda req: req.headers.get("x-api-key", "anon")``)
or an authenticated user id. Defaults to the client
IP. A returned ``None`` (or empty string) falls back
to the IP-based key.
:param fail_open: What to do when the backend errors out (e.g. Redis
is unreachable). ``False`` (the default) answers
``503 Service Unavailable`` — no request slips past
the limit, but an outage takes rate-limited routes
down with it. ``True`` logs a warning and lets the
request through unlimited — the app stays up, at the
cost of unmetered traffic while the backend is down.
"""
self.max_requests = requests
self.period = period
self.backend = MemoryBackend() if backend is None else backend
self.trust_proxy_headers = trust_proxy_headers
self.key = key
self.fail_open = fail_open
def _client_key(self, req):
if self.key is not None:
custom = self.key(req)
if custom:
return str(custom)
def get_header(name):
# Join repeated lines (each proxy hop may append its own
# Forwarded/X-Forwarded-For) so the resolver sees the full list
# in order; req.headers.get would keep only the last line.
values = req.headers.get_list(name)
return ", ".join(values) if values else None
ip = resolve_client_ip(
req.client, get_header, trust_proxy_headers=self.trust_proxy_headers
)
return ip or "unknown"
@staticmethod
def _normalize(result):
"""Accept 2-tuple ``(allowed, remaining)`` or 3-tuple backends."""
allowed, remaining, *rest = result
reset_after = rest[0] if rest else None
return allowed, remaining, reset_after
def _apply(self, allowed, remaining, reset_after, resp):
if reset_after is not None:
resp.headers["X-RateLimit-Reset"] = str(math.ceil(reset_after))
if not allowed:
headers = {"Retry-After": str(self.period)}
if self._use_problem_details(resp):
resp.problem(
429,
"Rate limit exceeded.",
title="Too Many Requests",
headers=headers,
)
else:
resp.status_code = 429
resp.media = {"error": "rate limit exceeded"}
resp.headers.update(headers)
return False
resp.headers["X-RateLimit-Limit"] = str(self.max_requests)
resp.headers["X-RateLimit-Remaining"] = str(remaining)
return True
@staticmethod
def _use_problem_details(resp):
request = getattr(resp, "req", None)
starlette_req = getattr(request, "_starlette", None)
scope = getattr(starlette_req, "scope", None)
return bool(scope and scope.get("problem_details"))
def _apply_failure(self, exc, resp):
if self.fail_open:
logger.warning(
"Rate-limit backend %s failed (%s: %s); fail_open=True, "
"allowing request unlimited.",
type(self.backend).__name__,
type(exc).__name__,
exc,
)
return True
logger.error(
"Rate-limit backend %s failed (%s: %s); fail_open=False, "
"answering 503.",
type(self.backend).__name__,
type(exc).__name__,
exc,
)
headers = {"Retry-After": str(self.period)}
if self._use_problem_details(resp):
resp.problem(
503,
"Rate limit backend unavailable.",
title="Service Unavailable",
headers=headers,
)
else:
resp.status_code = 503
resp.media = {"error": "rate limit backend unavailable"}
resp.headers.update(headers)
return False
[docs]
def check(self, req, resp):
"""Check the rate limit synchronously. Sets a ``429`` if exceeded.
Requires a sync backend (``hit``); use :meth:`acheck` for async-only
backends.
"""
key = self._client_key(req)
try:
result = self.backend.hit(key, self.max_requests, self.period)
except Exception as exc:
return self._apply_failure(exc, resp)
allowed, remaining, reset_after = self._normalize(result)
return self._apply(allowed, remaining, reset_after, resp)
[docs]
async def acheck(self, req, resp):
"""Check the rate limit, awaiting an async backend (or off-loading a
sync one to a thread). Works with any backend."""
key = self._client_key(req)
try:
if hasattr(self.backend, "ahit"):
result = await self.backend.ahit(key, self.max_requests, self.period)
else:
result = await run_in_threadpool(
self.backend.hit, key, self.max_requests, self.period
)
except Exception as exc:
return self._apply_failure(exc, resp)
allowed, remaining, reset_after = self._normalize(result)
return self._apply(allowed, remaining, reset_after, resp)
[docs]
def limit(self, f):
"""Decorator that rate-limits a single route handler.
Apply beneath ``@api.route()``. When the limit is exceeded, the
handler is skipped and a 429 response is returned. Works on both
function views ``(req, resp)`` and class-based-view methods
``(self, req, resp)`` — the request/response are located by type
among the positional arguments, so a bound method's leading ``self``
does not shift them.
"""
if inspect.isasyncgenfunction(f):
@functools.wraps(f)
async def wrapper(*args, **kwargs):
# Run the async check before constructing the generator. This
# keeps a denied stream as a normal 429 response instead of
# discovering the denial after streaming headers were sent.
req, resp = _find_req_resp(args)
if await self.acheck(req, resp):
return f(*args, **kwargs)
return None
elif inspect.iscoroutinefunction(f):
@functools.wraps(f)
async def wrapper(*args, **kwargs):
# Propagate the handler's return value so return-value-style
# handlers (return dict/str/bytes, or (data, status[, headers]))
# compose with @limit. When over the limit, acheck has already
# mutated resp to a 429 and we return None, leaving it intact.
req, resp = _find_req_resp(args)
if await self.acheck(req, resp):
return await f(*args, **kwargs)
return None
else:
@functools.wraps(f)
def wrapper(*args, **kwargs):
req, resp = _find_req_resp(args)
if self.check(req, resp):
return f(*args, **kwargs)
return None
wrapper._rate_limited = True # type: ignore[attr-defined]
wrapper._rate_limiter = self # type: ignore[attr-defined]
return wrapper
[docs]
def install(self, api):
"""Install as a before_request hook on the API (async, any backend)."""
api._openapi_rate_limited = True
api._openapi_rate_limiter = self
if hasattr(api, "openapi"):
api.openapi._spec_cache = None
@api.route(before_request=True)
async def _rate_limit(req, resp):
await self.acheck(req, resp)