iscc-app / sinetti_api /errors.py
Couscouskabyle's picture
Deploy Sinetti V2 (FastAPI + React) [publication:ce9378e5d2984478a403032d3eeafd86]
e459b62 verified
Raw History Blame
6.93 kB
from __future__ import annotations
import uuid
from fastapi import Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from starlette.exceptions import HTTPException as StarletteHTTPException
import obs
from sinetti_services import diagnostics_contract
from sinetti_services.tenant_context import TenantRequired
_ACTION_UNAVAILABLE_BODY = {
"ok": False,
"code": "action_unavailable",
"message": "We couldn’t complete this action. Please try again later or contact support.",
}
def request_trace_id(request: Request) -> str:
trace_id = str(getattr(request.state, "trace_id", "") or "")
if trace_id:
return trace_id
trace_id = str(uuid.uuid4())
request.state.trace_id = trace_id
return trace_id
async def http_exception_handler(request: Request, exc: StarletteHTTPException) -> JSONResponse:
code = {
400: "invalid_request",
401: "unauthorized",
403: "forbidden",
404: "not_found",
409: "conflict",
422: "validation_error",
503: "service_unavailable",
}.get(exc.status_code, "http_error")
trace_id = request_trace_id(request)
headers = {**(exc.headers or {}), "X-Request-ID": trace_id}
return JSONResponse(
status_code=exc.status_code,
headers=headers,
content={
"error": {
"code": code,
"message": str(exc.detail or "Request failed"),
"trace_id": trace_id,
}
},
)
async def request_validation_exception_handler(
request: Request,
exc: RequestValidationError,
) -> JSONResponse:
trace_id = request_trace_id(request)
# Locations and error types only — never the rejected input, which carries user content.
try:
fields = ",".join(
sorted(
{
".".join(str(part) for part in (error.get("loc") or ())[:4])
+ ":"
+ str(error.get("type") or "")
for error in (exc.errors() or ())
}
)
)[:400]
except Exception: # noqa: BLE001 — diagnostics must never mask the 422
fields = ""
obs.error(
"request_validation_failed",
trace_id=trace_id,
method=request.method,
path=diagnostics_contract.log_path(request),
fields=fields,
)
return JSONResponse(
status_code=422,
headers={"X-Request-ID": trace_id},
content={
"error": {
"code": "validation_error",
"message": "Request validation failed.",
"trace_id": trace_id,
}
},
)
async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
trace_id = request_trace_id(request)
obs.error(
"request_failed",
trace_id=trace_id,
method=request.method,
path=diagnostics_contract.log_path(request),
exception_class=type(exc).__name__,
)
# Best-effort ops ledger append — never fail the HTTP 500 path if the write fails.
try:
from sinetti_services import ops_error_ledger
# Class name only — never str(exc); exception text can carry secrets.
ops_error_ledger.record_error(
source="server",
trace_id=trace_id,
message=type(exc).__name__,
path=diagnostics_contract.log_path(request),
)
except Exception as ledger_exc: # noqa: BLE001 — ledger must not mask the original 500
obs.error(
"ops_error_ledger_write_failed",
trace_id=trace_id,
error_type=type(ledger_exc).__name__,
error=str(ledger_exc),
)
return JSONResponse(
status_code=500,
headers={"X-Request-ID": trace_id},
content={
"error": {
"code": "internal_error",
"message": "The request could not be completed.",
"trace_id": trace_id,
}
},
)
class ActionUnavailable(Exception):
"""A protected action could not safely be completed; its reason remains internal."""
def __init__(self, internal_reason: str) -> None:
self.internal_reason = internal_reason
super().__init__(internal_reason)
async def action_unavailable_handler(request: Request, exc: ActionUnavailable) -> JSONResponse:
trace_id = request_trace_id(request)
obs.error(
"action_unavailable",
internal_reason=exc.internal_reason,
trace_id=trace_id,
method=request.method,
path=diagnostics_contract.log_path(request),
)
return JSONResponse(status_code=429, content=_ACTION_UNAVAILABLE_BODY)
# Human-readable names for the plan-blocked message. Keep in sync with store_billing.FEATURE_KEYS.
_FEATURE_DISPLAY_NAMES = {
"counterparty_dd": "Counterparty Due Diligence",
"business_footprint": "Company research",
"csv_export": "CSV Export",
"pdf_deep_dives": "PDF Deep-Dives",
"market_balances": "Market Balances",
"batch_check": "Batch check",
"research_data": "Research data",
"plant_table": "Plant table",
}
class FeatureBlocked(Exception):
"""Raised by sinetti_api.deps.require_feature when the caller's plan lacks `feature`.
Handled as a graceful HTTP 200 envelope (never a bare 403) — see feature_blocked_handler.
"""
def __init__(self, feature: str, *, reason: str = "plan", message: str = "") -> None:
self.feature = feature
self.reason = reason
self.message = message
super().__init__(f"feature not entitled: {feature}")
async def feature_blocked_handler(request: Request, exc: FeatureBlocked) -> JSONResponse:
name = _FEATURE_DISPLAY_NAMES.get(exc.feature, exc.feature)
return JSONResponse(
status_code=200,
content={
"ok": False,
"blocked": True,
"reason": exc.reason,
"feature": exc.feature,
"message": exc.message
or f"{name} is not included in your plan — contact your administrator.",
},
)
async def tenant_required_handler(request: Request, exc: TenantRequired) -> JSONResponse:
trace_id = request_trace_id(request)
message = str(exc) or "Organisation access must be assigned before using this feature."
return JSONResponse(
status_code=403,
headers={"X-Request-ID": trace_id},
content={
"error": {
"code": "tenant_required",
"message": message,
"trace_id": trace_id,
}
},
)