Moved handlers to its own module, updated errors so they can propagate upstream if necessary
This commit is contained in:
1 parent
df3f9e147c
commit
00b1da5321
3 files changed
+87
-48
No files matched your search
+18
-2
@@ -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}")
|
||||||
@@ -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
@@ -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))])
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user