← back to Unclaimed Property Platform

services/common/rate_limit.py

54 lines

"""In-memory sliding-window rate limiter (stdlib).

Replaces the search API's `require_rate_limit_STUB` with a real control. This is the
single-process reference; production keys the same algorithm off Redis (atomic INCR + TTL,
or a sorted-set sliding window) so limits hold across many API instances.

The clock is INJECTABLE so time-based behavior is tested deterministically (no sleeps).

SECURITY: the limiter key MUST be derived from the TRUSTED-proxy client IP (+ optional
session), never a raw client-supplied X-Forwarded-For header (that is spoofable and would
let an attacker mint unlimited buckets). The caller is responsible for passing a trusted key.
"""
from __future__ import annotations

import time
from collections import defaultdict, deque
from typing import Callable


class SlidingWindowRateLimiter:
    def __init__(self, max_requests: int, window_seconds: float,
                 clock: Callable[[], float] = time.monotonic) -> None:
        if max_requests < 1 or window_seconds <= 0:
            raise ValueError("max_requests>=1 and window_seconds>0 required")
        self.max_requests = max_requests
        self.window_seconds = window_seconds
        self._clock = clock
        self._hits: dict[str, deque[float]] = defaultdict(deque)

    def allow(self, key: str) -> bool:
        """Record a request for `key`; return False if it exceeds the window budget."""
        now = self._clock()
        cutoff = now - self.window_seconds
        bucket = self._hits[key]
        while bucket and bucket[0] <= cutoff:
            bucket.popleft()
        if len(bucket) >= self.max_requests:
            return False
        bucket.append(now)
        return True

    def retry_after(self, key: str) -> float:
        """Seconds until the oldest in-window hit for `key` ages out (for a 429 header)."""
        bucket = self._hits.get(key)
        if not bucket:
            return 0.0
        return max(0.0, self.window_seconds - (self._clock() - bucket[0]))

    def reset(self, key: str | None = None) -> None:
        if key is None:
            self._hits.clear()
        else:
            self._hits.pop(key, None)