Source code for app.middlewares.error_handler
"""Catch-all error handler that keeps CORS headers on 500 responses.
Starlette builds its middleware stack with ``ServerErrorMiddleware`` as the
**outermost** layer, sitting *above* the user-added ``CORSMiddleware``. So an
unhandled exception (a real 500) is rendered by ``ServerErrorMiddleware`` and
never passes back through ``CORSMiddleware`` - the response ships **without**
``Access-Control-Allow-Origin``. The browser then blocks the cross-origin
response and the dashboard shows a bare *"Network Error"* instead of the real
status/message (see ``reference_network_error_means_500``).
This pure-ASGI middleware is installed *inside* ``CORSMiddleware`` (CORS is
added last in ``app/main.py`` so it stays outermost). It converts any
unhandled exception into a JSON ``500`` **within** the stack, so the response
flows back out through ``CORSMiddleware`` and gets its CORS headers. The
traceback is still logged (and forwarded to Sentry) so observability is
unchanged.
``HTTPException`` and its subclasses never reach here - Starlette's
``ExceptionMiddleware`` (also inside CORS) already turns them into proper 4xx
responses with CORS headers. Only genuine, unhandled errors are caught.
"""
import logging
import sentry_sdk
from fastapi.responses import JSONResponse
logger = logging.getLogger(__name__)
[docs]
class CatchUnhandledErrorsMiddleware:
"""Render unhandled exceptions as a JSON 500 from inside the stack."""
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
# websocket / lifespan - nothing to translate.
await self.app(scope, receive, send)
return
response_started = False
async def send_wrapper(message):
"""Track whether the response has started before forwarding it.
Records the first ``http.response.start`` so the outer handler
knows it can no longer safely replace the response with a 500.
"""
nonlocal response_started
if message["type"] == "http.response.start":
response_started = True
await send(message)
try:
await self.app(scope, receive, send_wrapper)
except Exception as exc: # noqa: BLE001 - deliberate catch-all for 500s
# Preserve observability: the traceback still reaches the logs and
# Sentry even though we no longer let the exception bubble up to
# ServerErrorMiddleware.
logger.exception("Unhandled exception while handling request")
sentry_sdk.capture_exception(exc)
if response_started:
# Headers/body already in flight - we cannot replace the
# response; re-raise so the server tears the connection down.
raise
response = JSONResponse(
status_code=500,
content={"detail": "Internal server error"},
)
await response(scope, receive, send)