81 lines
3.2 KiB
Python
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."
|