import time from collections import defaultdict, deque from fastapi import Request class FailureLimiter: """Sliding-window counter of failed attempts, kept in process memory. Several uvicorn workers would each count separately, which only loosens the limit; the service runs a single backend process. """ def __init__(self, max_failures: int, window_seconds: float) -> None: self.max_failures = max_failures self.window = window_seconds self._hits: dict[str, deque[float]] = defaultdict(deque) def _prune(self, key: str, now: float) -> deque[float]: hits = self._hits[key] while hits and now - hits[0] > self.window: hits.popleft() if not hits: self._hits.pop(key, None) return hits def retry_after(self, *keys: str) -> int: """Seconds until a blocked key is allowed again, 0 if none is blocked.""" now = time.monotonic() wait = 0.0 for key in keys: hits = self._prune(key, now) if len(hits) >= self.max_failures: wait = max(wait, hits[0] + self.window - now) return int(wait) + 1 if wait > 0 else 0 def fail(self, *keys: str) -> None: now = time.monotonic() for key in keys: self._prune(key, now) self._hits[key].append(now) def reset(self, *keys: str) -> None: for key in keys: self._hits.pop(key, None) def client_ip(request: Request) -> str: forwarded = request.headers.get("x-forwarded-for") if forwarded: return forwarded.split(",")[0].strip() return request.client.host if request.client else "unknown" login_limiter = FailureLimiter(max_failures=8, window_seconds=15 * 60)