Files
chatballs/apps/backend/hub_platform/api/exceptions.py
T
Andrey a5b6ad2087 🔒 feat(subscriptions): enforce C07 entitlements and quotas across modules
Coverage-matrix operation x entitlement/quota x transport in INVENTORY-CUSTOCRM-0009.

Enforcement infrastructure:
- HasEntitlement DRF permission (org-level feature gate, orthogonal to
  HasCapability); centralized api_exception_handler mapping
  EntitlementRequired->403, QuotaExceeded HARD->409 / RATE+CONCURRENT->429
  (+Retry-After), PolicyUnavailable->503, UsageConflict->409.
- quota_service.check() advisory pre-check.
- UsageReservation lease model + reserve_usage/release_usage/
  expire_stale_reservations for CONCURRENT quotas (SPEC-HUB-0022 section 7);
  worker sweep reaps lapsed leases. QuotaExceeded now carries mode.

Cumulative quota gates (record_usage, mirror ai_agent_slots):
- products (create_product), client_connections (create_integration),
  new_dialogs_per_period (new dialog only, is_new branch in ingest + support),
  managed_ai_credits (successful LlmInvocation, best-effort accounting).

Storage gate: assert_storage_quota pre-write check blocks overage on writes,
reads never gated (PLAN section 12).

Concurrent p2p_calls: reserve on CallSession create, release on terminal
transition in calls/lifecycle.py.

Entitlement gates on views: sales_department, support_department,
product_sales_api (inline), p2p_calls, knowledge_base, managed_ai
(inline handoff on deny).

Deferred (no target module): crm_api, voice_ai, sso_saml,
concurrent_voice_sessions, customer_audit (tracks B/C/C12).

bootstrap_edevs_owner now provisions an active STARTUP subscription
(ensure_default_subscription) since post-C07 every tenant operation requires
a subscription; create_test_subscription reconciles ai_agent_slot counter for
tests that create active agents directly.

Gate (PLAN section 12): coverage-matrix exists; storage overage blocks writes
not reads; downgrade/suspension zero-limit clears entitlements without delete;
Free counts only a brand-new dialog.

Targeted tests: reservations 5, quota_service 4, enforcement mapping 6.
Full backend regression green except one pre-existing SOCKS5 test and one
pre-existing membership-migration ProtectedError (both fail on clean HEAD).
UI unchanged. Production migration/deploy and publication of production
PlanVersion are not part of this commit.
2026-07-16 03:11:55 +03:00

86 lines
3.0 KiB
Python

from typing import Any
from rest_framework import status
from rest_framework.response import Response
from rest_framework.views import exception_handler as drf_exception_handler
from hub_platform.subscriptions.errors import (
EntitlementRequired,
PolicyUnavailable,
QuotaExceeded,
SubscriptionDomainError,
SubscriptionInactive,
UsageConflict,
)
def _flatten(data: Any) -> str:
if isinstance(data, str):
return data
if isinstance(data, dict):
return "; ".join(_flatten(value) for value in data.values())
if isinstance(data, (list, tuple)):
return "; ".join(_flatten(item) for item in data)
return str(data)
def _domain_error_response(exc: SubscriptionDomainError):
"""Map subscription-domain errors to the SPA contract (SPEC-HUB-0022 §13).
EntitlementRequired -> 403; QuotaExceeded HARD/period -> 409,
RATE/CONCURRENT -> 429 (with Retry-After for RATE); PolicyUnavailable /
SubscriptionInactive -> 503; UsageConflict -> 409. Other domain errors -> 409.
"""
if isinstance(exc, EntitlementRequired):
return Response(
{"detail": str(exc), "code": exc.code, "entitlement": exc.entitlement},
status=status.HTTP_403_FORBIDDEN,
)
if isinstance(exc, QuotaExceeded):
is_rate_like = exc.mode in {"RATE", "CONCURRENT"}
http_status = (
status.HTTP_429_TOO_MANY_REQUESTS if is_rate_like else status.HTTP_409_CONFLICT
)
response = Response(
{
"detail": str(exc),
"code": exc.code,
"resource": exc.resource,
"limit": exc.limit,
"used": exc.used,
"requested": exc.requested,
},
status=http_status,
)
if exc.mode == "RATE":
# Advisory only: the client may retry once the window elides.
response["Retry-After"] = "60"
return response
if isinstance(exc, PolicyUnavailable | SubscriptionInactive):
return Response({"detail": str(exc), "code": exc.code}, status=503)
if isinstance(exc, UsageConflict):
return Response({"detail": str(exc), "code": exc.code}, status=status.HTTP_409_CONFLICT)
return Response({"detail": str(exc), "code": exc.code}, status=status.HTTP_409_CONFLICT)
def api_exception_handler(exc: Exception, context: dict[str, Any]):
"""Normalize every DRF error body to the SPA contract: {"detail": "<text>"}.
Subscription-domain exceptions are mapped to explicit HTTP statuses before DRF
would otherwise render them as 500 (they are plain Exceptions, not APIException).
"""
if isinstance(exc, SubscriptionDomainError):
return _domain_error_response(exc)
response = drf_exception_handler(exc, context)
if response is None:
return None
data = response.data
if (
isinstance(data, dict)
and list(data.keys()) == ["detail"]
and isinstance(data["detail"], str)
):
return response
response.data = {"detail": _flatten(data)}
return response