"""Rate limiter cleanup background task. Registers an APScheduler job that periodically removes expired rate-limit entries from the in-memory rate limiter. Without this cleanup, the rate-limiter state dictionary grows unbounded over long runtimes, eventually consuming excessive memory. The cleanup is conservative: it only removes IPs with no recent attempts (all timestamps outside the rate-limit window), so active or recently-active IPs are preserved. Correlation IDs are propagated through the task using :mod:`app.utils.correlation` so that task logs can be correlated across runs. """ from __future__ import annotations import uuid from typing import TYPE_CHECKING import structlog from app.tasks.timeout_utils import run_with_timeout from app.utils.correlation import get_correlation_id, reset_correlation_id, set_correlation_id if TYPE_CHECKING: from fastapi import FastAPI log: structlog.stdlib.BoundLogger = structlog.get_logger() #: How often the cleanup job fires (seconds). Chosen to balance memory #: management against CPU overhead. A 30-minute interval handles typical #: brute-force attack patterns while staying lightweight. RATE_LIMITER_CLEANUP_INTERVAL: int = 30 * 60 # 30 minutes #: Stable APScheduler job ID — ensures re-registration replaces, not duplicates. JOB_ID: str = "rate_limiter_cleanup" #: Maximum seconds to allow for rate limiter cleanup to complete. TASK_TIMEOUT_SECONDS: int = 5 async def _run_cleanup( app: FastAPI, correlation_id: str | None = None, ) -> None: """Trigger cleanup of expired rate-limiter entries. Cleans up both the login-specific rate limiter (exponential backoff) and the global request rate limiter. Args: app: The FastAPI application instance (holds the rate limiters). correlation_id: Optional correlation ID for log correlation. """ if correlation_id is None: correlation_id = str(uuid.uuid4()) token = set_correlation_id(correlation_id) try: await _do_cleanup_with_app(app) finally: reset_correlation_id(token) async def _do_cleanup_with_app(app: FastAPI) -> None: """Inner cleanup logic that runs with correlation context set.""" async def _do_cleanup() -> None: login_limiter = getattr(app.state, "login_rate_limiter", None) if login_limiter is None: log.warning( "rate_limiter_cleanup_skipped", correlation_id=get_correlation_id(), reason="login_rate_limiter not found on app.state", ) else: login_limiter.cleanup_expired() global_limiter = getattr(app.state, "global_rate_limiter", None) if global_limiter is None: log.warning( "rate_limiter_cleanup_skipped", correlation_id=get_correlation_id(), reason="global_rate_limiter not found on app.state", ) else: global_limiter.cleanup_expired() await run_with_timeout("rate_limiter_cleanup", _do_cleanup(), TASK_TIMEOUT_SECONDS) def register(app: FastAPI) -> None: """Add (or replace) the rate-limiter cleanup job in the application scheduler. Must be called after the scheduler has been started (i.e., inside the lifespan handler, after ``scheduler.start()``). Args: app: The :class:`fastapi.FastAPI` application instance whose ``app.state.scheduler`` will receive the job. """ app.state.scheduler.add_job( _run_cleanup, trigger="interval", seconds=RATE_LIMITER_CLEANUP_INTERVAL, kwargs={"app": app}, id=JOB_ID, replace_existing=True, ) log.info( "rate_limiter_cleanup_scheduled", interval_seconds=RATE_LIMITER_CLEANUP_INTERVAL, )