"""Tenant-scoped throttling + standard rate-limit headers (``API-STANDARD.md`` §9).""" from __future__ import annotations import time from typing import Any from rest_framework.throttling import SimpleRateThrottle __all__ = ["TenantRateThrottle", "apply_rate_limit_headers"] class TenantRateThrottle(SimpleRateThrottle): """Throttles per tenant *and* per identity, never per IP alone (``TENANCY.md`` §7). Requires a ``DEFAULT_THROTTLE_RATES["tenant"]`` rate; when it is absent the throttle is a no-op so the kit still runs with zero configuration. """ scope = "tenant" def get_rate(self) -> str | None: # type: ignore[override] from django.conf import settings rates = getattr(settings, "DEFAULT_THROTTLE_RATES", {}) return rates.get(self.scope) def get_cache_key(self, request: Any, view: Any) -> str | None: if not self.rate: return None from infrasynth.tenancy.context import get_current_tenant tenant = get_current_tenant() tenant_part = str(tenant.pk) if tenant is not None else "anon" ident = self.get_ident(request) return f"tenant:{tenant_part}:ratelimit:{self.scope}:{ident}" def allow_request(self, request: Any, view: Any) -> bool: allowed = super().allow_request(request, view) history = getattr(self, "history", []) num_requests = getattr(self, "num_requests", 0) if num_requests: request._rate_limit = { # type: ignore[attr-defined] "limit": num_requests, "remaining": max(0, num_requests - len(history)), "reset": int(history[-1]) if history else int(time.time()), } return allowed def apply_rate_limit_headers(response: Any, request: Any) -> None: """Copies throttle state recorded on the request onto the response headers.""" info = getattr(request, "_rate_limit", None) if not info: return response["X-RateLimit-Limit"] = str(info["limit"]) response["X-RateLimit-Remaining"] = str(info["remaining"]) response["X-RateLimit-Reset"] = str(info["reset"])