Moved handlers to its own module, updated errors so they can propagate upstream if necessary

This commit is contained in:
florian committed 2026-11-03 11:45:55 +01:00
1 parent df3f9e147c
commit 00b1da5321
3 files changed
+87 -48

No files matched your search

+18 -2
View File
@@ -1,35 +1,51 @@
class FeedError(Exception): class FeedError(Exception):
"""Base class for every feed problem the API can explain to a client.""" """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): 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. Subclasses ValueError so Pydantic reports it as a normal 422 validation error.
""" """
status_code = 422 status_code = 422
code = "srr_invalid_url"
client_visible = True
class WrongHostError(InvalidFeedUrlError): class WrongHostError(InvalidFeedUrlError):
code = "srr_wrong_host"
def __init__(self, feed_type: str, expected_host: str): def __init__(self, feed_type: str, expected_host: str):
super().__init__(f"A {feed_type} feed must be on {expected_host}") super().__init__(f"A {feed_type} feed must be on {expected_host}")
class UnsupportedPathError(InvalidFeedUrlError): class UnsupportedPathError(InvalidFeedUrlError):
code = "srr_unsupported_path"
def __init__(self, hint: str): def __init__(self, hint: str):
super().__init__(f"Unrecognised feed path ({hint})") super().__init__(f"Unrecognised feed path ({hint})")
class FeedNotFoundError(FeedError): class FeedNotFoundError(FeedError):
status_code = 422 status_code = 422
code = "srr_feed_not_found"
client_visible = True
def __init__(self, url: str): def __init__(self, url: str):
super().__init__(f"The feed does not exist: {url}") super().__init__(f"The feed does not exist: {url}")
class FeedCheckFailedError(FeedError): 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 status_code = 502
code = "srr_feed_check_failed"
def __init__(self, url: str): def __init__(self, url: str):
super().__init__(f"Could not verify the feed right now, try again later: {url}") super().__init__(f"Could not verify the feed right now, try again later: {url}")
+65
View File
@@ -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)
+4 -46
View File
@@ -4,13 +4,11 @@ from contextlib import asynccontextmanager
import uvicorn import uvicorn
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from requests.exceptions import RequestException from requests.exceptions import RequestException
from auth_config import Caller, auth from auth_config import Caller, auth
from db_module import close_connection_pool, create_connection_pool, get_db, start_healthcheck_thread 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 ( from feed_handler import (
build_gitea_runner, build_gitea_runner,
ensure_feed_exists, ensure_feed_exists,
@@ -18,7 +16,8 @@ from feed_handler import (
grab_gitea_release_information, grab_gitea_release_information,
grab_royal_road_chapter_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 models import FeedEntry
from secret_manager import cleanup_secret_files, get_secret from secret_manager import cleanup_secret_files, get_secret
from send_notifications import NotificationClient, NotificationError, NotificationUnavailableError from send_notifications import NotificationClient, NotificationError, NotificationUnavailableError
@@ -60,48 +59,7 @@ api = FastAPI(
dependencies=[Depends(auth.authenticate)], dependencies=[Depends(auth.authenticate)],
) )
@api.exception_handler(FeedError) register_handlers(api)
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
user_router = APIRouter(dependencies=[Depends(auth.require(Caller.GATEWAY))]) user_router = APIRouter(dependencies=[Depends(auth.require(Caller.GATEWAY))])
scheduler_router = APIRouter(dependencies=[Depends(auth.require(Caller.SCHEDULER))]) scheduler_router = APIRouter(dependencies=[Depends(auth.require(Caller.SCHEDULER))])