"""Idempotency-Key handling for state-mutating POSTs (``API-STANDARD.md`` ยง7).""" from __future__ import annotations import hashlib from collections.abc import Callable from functools import wraps from typing import Any from django.core.cache import cache __all__ = ["idempotent"] _TTL_SECONDS = 24 * 60 * 60 def _cache_key(request: Any, idempotency_key: str) -> str: from infrasynth.tenancy.context import get_current_tenant tenant = get_current_tenant() tenant_part = str(tenant.pk) if tenant is not None else "anon" fingerprint = hashlib.sha256(f"{request.method}:{request.path}".encode()).hexdigest()[:16] return f"tenant:{tenant_part}:idempotency:{idempotency_key}:{fingerprint}" def idempotent(view_method: Callable[..., Any]) -> Callable[..., Any]: """Replays the first successful response for a repeated ``Idempotency-Key``. Views that create real-world side effects (checkout, invitation acceptance) apply this to the action method. Without the header the request is a no-op. """ @wraps(view_method) def wrapper(self: Any, request: Any, *args: Any, **kwargs: Any) -> Any: from rest_framework.response import Response key = request.headers.get("Idempotency-Key") if not key: return view_method(self, request, *args, **kwargs) cache_key = _cache_key(request, key) cached = cache.get(cache_key) if cached is not None: response = Response(cached["data"], status=cached["status"]) response["Idempotent-Replay"] = "true" return response response = view_method(self, request, *args, **kwargs) if 200 <= response.status_code < 300: try: cache.set(cache_key, {"data": response.data, "status": response.status_code}, _TTL_SECONDS) except Exception: # noqa: BLE001 - never fail a request over cache serialization pass return response return wrapper