From 00b1da5321aa581980ef56f9cddeabfca9deaf81 Mon Sep 17 00:00:00 2001 From: Florian Date: Tue, 3 Nov 2026 11:45:55 +0100 Subject: [PATCH] Moved handlers to its own module, updated errors so they can propagate upstream if necessary --- src/feed_errors.py | 20 ++++++++++++-- src/handlers.py | 65 ++++++++++++++++++++++++++++++++++++++++++++++ src/main.py | 50 +++-------------------------------- 3 files changed, 87 insertions(+), 48 deletions(-) create mode 100644 src/handlers.py diff --git a/src/feed_errors.py b/src/feed_errors.py index fdb4f9a..16f4c69 100644 --- a/src/feed_errors.py +++ b/src/feed_errors.py @@ -1,35 +1,51 @@ class FeedError(Exception): """Base class for every feed problem the API can explain to a client.""" - status_code = 400 + status_code = 502 + code = "srr_upstream_error" + client_visible = False + + @property + def client_message(self) -> str: + return str(self) if self.client_visible else "The feed could not be checked right now, please try again later" class InvalidFeedUrlError(FeedError, ValueError): - """The URL is malformed or doesn't fit the chosen feed type. + """The URL is malformed or doesn't fit the chosen feed type. Raise it with the message to show. Subclasses ValueError so Pydantic reports it as a normal 422 validation error. """ status_code = 422 + code = "srr_invalid_url" + client_visible = True class WrongHostError(InvalidFeedUrlError): + code = "srr_wrong_host" + def __init__(self, feed_type: str, expected_host: str): super().__init__(f"A {feed_type} feed must be on {expected_host}") class UnsupportedPathError(InvalidFeedUrlError): + code = "srr_unsupported_path" + def __init__(self, hint: str): super().__init__(f"Unrecognised feed path ({hint})") class FeedNotFoundError(FeedError): status_code = 422 + code = "srr_feed_not_found" + client_visible = True def __init__(self, url: str): super().__init__(f"The feed does not exist: {url}") class FeedCheckFailedError(FeedError): + """The feed exists as far as we know, but checking it failed. The message names the URL, so it stays hidden.""" status_code = 502 + code = "srr_feed_check_failed" def __init__(self, url: str): super().__init__(f"Could not verify the feed right now, try again later: {url}") \ No newline at end of file diff --git a/src/handlers.py b/src/handlers.py new file mode 100644 index 0000000..89e8625 --- /dev/null +++ b/src/handlers.py @@ -0,0 +1,65 @@ +from fastapi import FastAPI, Request +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse +from starlette.middleware.base import BaseHTTPMiddleware + +from metrics_server import REQUEST_COUNTER +from feed_errors import FeedError +from simple_logger_handler import setup_logger + +logger = setup_logger(__name__) + + +async def feed_error_handler(request: Request, exc: FeedError): + log = logger.warning if exc.client_visible else logger.error + log(f"[FastAPI] {request.method} {request.url.path} failed: {exc}") + return JSONResponse( + status_code=exc.status_code, + content={"code": exc.code, "detail": exc.client_message}, + ) + + +async def validation_error_handler(request: Request, exc: RequestValidationError): + errors = exc.errors() + + for err in errors: + original = (err.get("ctx") or {}).get("error") + if isinstance(original, FeedError): + return JSONResponse( + status_code=original.status_code, + content={"code": original.code, "detail": original.client_message}, + ) + + # Problems in headers, path or query are the calling service's fault, not the user's + if any(err["loc"][0] != "body" for err in errors): + logger.error(f"[FastAPI] Invalid request from a calling service: {[(e['loc'], e['msg']) for e in errors]}") + return JSONResponse(status_code=400, content={"code": "bad_request", "detail": "The request could not be processed"}) + + messages = [] + for err in errors: + field = ".".join(str(p) for p in err["loc"] if p != "body") + msg = err["msg"].removeprefix("Value error, ") + messages.append(f"{field}: {msg}" if field else msg) + return JSONResponse(status_code=422, content={"code": "validation_error", "detail": "; ".join(messages)}) + + +async def prometheus_middleware(request: Request, call_next): + logger.debug(f"[FastAPI] Incoming request: {request.method} {request.url.path}") + status = 500 + try: + response = await call_next(request) + status = response.status_code + logger.debug(f"[FastAPI] Request processed with status {status}") + except Exception as e: + logger.error(f"[FastAPI] Exception occurred: {e}", exc_info=True) + raise + finally: + REQUEST_COUNTER.labels(request.method, request.url.path, status).inc() + logger.debug(f"[FastAPI] Counter incremented for {request.method} {request.url.path} [{status}]") + return response + +def register_handlers(app: FastAPI) -> None: + """Attach the error handlers and the metrics middleware to the app.""" + app.add_exception_handler(FeedError, feed_error_handler) + app.add_exception_handler(RequestValidationError, validation_error_handler) + app.add_middleware(BaseHTTPMiddleware, dispatch=prometheus_middleware) diff --git a/src/main.py b/src/main.py index 6a8d271..2bd458b 100644 --- a/src/main.py +++ b/src/main.py @@ -4,13 +4,11 @@ from contextlib import asynccontextmanager import uvicorn from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request -from fastapi.exceptions import RequestValidationError -from fastapi.responses import JSONResponse from requests.exceptions import RequestException from auth_config import Caller, auth from db_module import close_connection_pool, create_connection_pool, get_db, start_healthcheck_thread -from feed_errors import FeedCheckFailedError, FeedError +from feed_errors import FeedCheckFailedError from feed_handler import ( build_gitea_runner, ensure_feed_exists, @@ -18,7 +16,8 @@ from feed_handler import ( grab_gitea_release_information, grab_royal_road_chapter_information, ) -from metrics_server import REQUEST_COUNTER, metrics_api +from handlers import register_handlers +from metrics_server import metrics_api from models import FeedEntry from secret_manager import cleanup_secret_files, get_secret from send_notifications import NotificationClient, NotificationError, NotificationUnavailableError @@ -60,48 +59,7 @@ api = FastAPI( dependencies=[Depends(auth.authenticate)], ) -@api.exception_handler(FeedError) -async def feed_error_handler(request: Request, exc: FeedError): - return JSONResponse(status_code=exc.status_code, content={"detail":str(exc)}) - -@api.exception_handler(RequestValidationError) -async def validation_error_handler(request: Request, exc: RequestValidationError): - errors = exc.errors() - - # 1) If one of our own FeedErrors caused it, use its message as is - for err in errors: - original = (err.get("ctx") or {}).get("error") - if isinstance(original, FeedError): - return JSONResponse( - status_code=original.status_code, - content={"detail": str(original)}, - ) - - # 2) Anything else (missing field, bad enum value, bad header): flatten to one string - messages = [] - for err in errors: - field = ".".join(str(p) for p in err["loc"] if p != "body") - msg = err["msg"].removeprefix("Value error, ") - messages.append(f"{field}: {msg}" if field else msg) - return JSONResponse(status_code=422, content={"detail": "; ".join(messages)}) - -@api.middleware("http") -async def prometheus_middleware(request: Request, call_next): - logger.debug(f"[FastAPI] Incoming request: {request.method} {request.url.path}") - status = 500 - try: - response = await call_next(request) - status = response.status_code - logger.debug(f"[FastAPI] Request processed with status {status}") - except Exception as e: - logger.error(f"[FastAPI] Exception occurred: {e}", exc_info=True) - raise - finally: - REQUEST_COUNTER.labels(request.method, request.url.path, status).inc() - logger.debug(f"[FastAPI] Counter incremented for {request.method} {request.url.path} [{status}]") - return response - - +register_handlers(api) user_router = APIRouter(dependencies=[Depends(auth.require(Caller.GATEWAY))]) scheduler_router = APIRouter(dependencies=[Depends(auth.require(Caller.SCHEDULER))])