OOB-Dashboard/app/errors.py

81 lines
3.2 KiB
Python

"""Error rendering.
Two jobs. First, answer a 401 the way the caller can use: an HTML navigation
gets a redirect to the sign-in page, a fetch gets JSON. Without this, clicking
Excel export on an expired session dumps raw JSON into a new tab.
Second, stop leaking internals. The local serve.py answers 500s with
`f"{type(exc).__name__}: {exc}"`, which is fine for a tool on your own laptop
and not fine on a shared server.
"""
from __future__ import annotations
import logging
import uuid
from fastapi import FastAPI, HTTPException, Request, status
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse, RedirectResponse
log = logging.getLogger(__name__)
def wants_html(request: Request) -> bool:
accept = request.headers.get("accept") or ""
# fetch() sends */* or application/json; a browser navigation asks for HTML
# explicitly and first.
return "text/html" in accept
def _login_redirect(request: Request) -> RedirectResponse:
nxt = request.url.path
if request.url.query:
nxt = f"{nxt}?{request.url.query}"
return RedirectResponse(f"/login?next={nxt}", status_code=status.HTTP_303_SEE_OTHER)
def install(app: FastAPI) -> None:
@app.exception_handler(HTTPException)
async def http_exception(request: Request, exc: HTTPException):
if exc.status_code == status.HTTP_401_UNAUTHORIZED and wants_html(request):
return _login_redirect(request)
payload = {"error": exc.detail}
if isinstance(exc.detail, dict):
payload = exc.detail
return JSONResponse(payload, status_code=exc.status_code,
headers=getattr(exc, "headers", None))
@app.exception_handler(RequestValidationError)
async def validation_error(request: Request, exc: RequestValidationError):
# Literal 422: the Starlette constant for it was renamed, and pinning to
# either spelling would tie this file to a version range.
return JSONResponse({"error": _readable(exc)}, status_code=422)
@app.exception_handler(ValueError)
async def value_error(request: Request, exc: ValueError):
# The pipeline raises ValueError with deliberate user-facing copy
# ("No change-history files uploaded yet."). Pass it straight through.
return JSONResponse({"error": str(exc)}, status_code=status.HTTP_400_BAD_REQUEST)
@app.exception_handler(Exception)
async def unhandled(request: Request, exc: Exception):
ref = uuid.uuid4().hex[:12]
log.exception("unhandled error ref=%s on %s %s", ref, request.method,
request.url.path)
return JSONResponse(
{"error": "Something went wrong on our side.", "ref": ref},
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR)
def _readable(exc: RequestValidationError) -> str:
"""Turn pydantic's error list into one sentence a person can act on."""
for err in exc.errors():
field = ".".join(str(p) for p in err.get("loc", ()) if p not in ("body", "query"))
msg = err.get("msg", "is not valid")
if field:
return f"{field}: {msg}"
return msg
return "That request was not valid."