infrasynth-backend-kit/infrasynth/api/exceptions.py
jcv-dev 551b42eab5 feat: production-hardening pass across the kit
Close the gaps between the documented contract (API-STANDARD, TENANCY,
ENTITLEMENTS) and the implementation, and remove committed build artifacts.

Security:
- verify + process inbound webhooks (HMAC/handler verify, size limit,
  timestamp tolerance, idempotency via InboundEvent.external_id)
- real 2FA login flow (pre-auth challenge; tokens only after verify/recovery)
- wire HybridPermission into security/audit views; add API-key rotate and
  users/<id>/permissions|roles endpoints
- tenant-scoped throttling on by default; webhook replay protection
- verify MercadoPago webhook signatures
- login brute-force guard, configurable password policy, real ALTCHA PoW

Correctness:
- apply verified billing webhooks idempotently (subscription/entitlement/
  invoice/PaymentTransaction); scheduled payment lifecycle jobs
- capture audit update diffs automatically; add audit retention purge
- working notification retries, per-channel rate limits, log retention
- pluggable virus scanner, upload-size limit, pipeline toggle
- feature rollout %/environment targeting; settings-driven registrations
- workflow guards (instance cap, route depth, self-assignment, clone on re-entry)
- wire every previously-dead INFRASYNTH_* setting; drop truly dead ones

Delivery:
- README + CHANGELOG; CI format check + coverage gate
- keep test media out of the tree; untrack .coverage, __pycache__,
  egg-info, docs/ and invoice artifacts
2026-09-24 10:41:21 -05:00

117 lines
4.1 KiB
Python

"""Envelope-aware DRF exception handler and error helpers (``API-STANDARD.md`` §4, §5)."""
from __future__ import annotations
from typing import Any
from django.http import JsonResponse
from rest_framework import status as http_status
from rest_framework.exceptions import (
APIException,
AuthenticationFailed,
NotAuthenticated,
NotFound,
PermissionDenied,
Throttled,
ValidationError,
)
from rest_framework.response import Response
from rest_framework.views import exception_handler as drf_exception_handler
from infrasynth.shared.exceptions import AppError
__all__ = ["envelope_exception_handler", "error_response", "envelope_body"]
def _flatten_details(detail: Any) -> list[dict[str, Any]]:
"""Turns a DRF ``detail`` (dict/list/str) into the standard details array."""
if isinstance(detail, dict):
flattened: list[dict[str, Any]] = []
for field, issue in detail.items():
if isinstance(issue, (list, tuple)):
for entry in issue:
flattened.append({"field": str(field), "issue": str(entry)})
else:
flattened.append({"field": str(field), "issue": str(issue)})
return flattened
if isinstance(detail, (list, tuple)):
return [{"issue": str(entry)} for entry in detail]
if detail is None:
return []
return [{"issue": str(detail)}]
def _map_exception(exc: APIException) -> tuple[str, str]:
if isinstance(exc, ValidationError):
return "VALIDATION_ERROR", "The request payload is invalid."
if isinstance(exc, (NotAuthenticated, AuthenticationFailed)):
return "AUTH_UNAUTHENTICATED", "Authentication credentials were not provided or are invalid."
if isinstance(exc, PermissionDenied):
return "AUTH_FORBIDDEN", "You do not have permission to perform this action."
if isinstance(exc, NotFound):
return "NOT_FOUND", "The requested resource was not found."
if isinstance(exc, Throttled):
return "RATE_LIMIT_EXCEEDED", "Too many requests."
return "SERVER_ERROR", "An unexpected error occurred."
def envelope_exception_handler(exc: Exception, context: dict[str, Any]) -> Response | None:
"""DRF ``EXCEPTION_HANDLER`` producing ``{code, message, details}`` bodies.
The envelope itself is added by :class:`EnvelopeJSONRenderer` based on the
HTTP status. Unexpected (non-``APIException``, non-``AppError``) exceptions
return ``None`` so Django still surfaces them loudly instead of masking a
bug behind a generic 500.
"""
if isinstance(exc, AppError):
return Response(exc.to_dict(), status=exc.status)
response = drf_exception_handler(exc, context)
if response is None:
return None
code, message = _map_exception(exc) if isinstance(exc, APIException) else ("SERVER_ERROR", str(exc))
detail = getattr(exc, "detail", None)
response.data = {
"code": code,
"message": message,
"details": _flatten_details(detail),
}
return response
def envelope_body(
*,
success: bool,
data: Any = None,
error: dict[str, Any] | None = None,
meta: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Builds an envelope dict for middleware that bypasses DRF views."""
return {"success": success, "data": data, "error": error, "meta": meta or {}}
def error_response(
code: str,
message: str,
*,
status_code: int = http_status.HTTP_403_FORBIDDEN,
details: list[dict[str, Any]] | None = None,
request: Any = None,
) -> JsonResponse:
"""Minimal envelope error for middleware (no DRF renderer in the chain)."""
from django.utils import timezone
body = envelope_body(
success=False,
error={"code": code, "message": message, "details": details or []},
meta={
"requestId": getattr(request, "request_id", "") if request else "",
"timestamp": timezone.now().isoformat().replace("+00:00", "Z"),
},
)
response = JsonResponse(body, status=status_code)
request_id = getattr(request, "request_id", "") if request else ""
if request_id:
response["X-Request-Id"] = request_id
return response