-
Notifications
You must be signed in to change notification settings - Fork 13
Unify Error Handling #124
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Unify Error Handling #124
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,9 +1,10 @@ | ||
| from fastapi import APIRouter | ||
|
|
||
| from app.user.routes import user_crud_router_v1 | ||
| from app.utils.exceptions import VALIDATION_ERROR_RESPONSE | ||
| from app.utils.healthcheck import healthcheck_router | ||
|
|
||
| head_router = APIRouter() | ||
| head_router = APIRouter(responses=VALIDATION_ERROR_RESPONSE) | ||
|
|
||
| head_router.include_router(healthcheck_router, prefix="/health", tags=["health"]) | ||
| head_router.include_router(user_crud_router_v1, prefix="/users", tags=["users"]) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| from uuid import UUID | ||
|
|
||
| from app.utils.exceptions import AppError | ||
|
|
||
|
|
||
| class UserInactiveError(AppError): | ||
| """User has had no activity in the last 30 days.""" | ||
|
|
||
| status_code = 403 | ||
|
|
||
| def __init__(self, user_id: UUID) -> None: | ||
| super().__init__(message=f"User {user_id} is inactive") | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,9 +4,11 @@ | |
| from app.config import settings | ||
| from app.database import AsyncDbSession | ||
| from app.schemas import FilterParams | ||
| from app.user.schemas import UserCreate, UserRead, UserUpdate | ||
| from app.user.exceptions import UserInactiveError | ||
| from app.user.schemas import UserActivity, UserCreate, UserRead, UserUpdate | ||
| from app.user.services import user_service | ||
| from app.utils.api_utils import format_response | ||
| from app.utils.exceptions import AppErrorResponse | ||
| from fastapi import APIRouter, Query, Request, status | ||
|
|
||
| router = APIRouter() | ||
|
|
@@ -27,6 +29,19 @@ async def get_user(request: Request, user_id: UUID, session: AsyncDbSession): | |
| return await user_service.get(session, user_id) | ||
|
|
||
|
|
||
| @router.get( | ||
| "/{user_id}/activity", | ||
| response_model=UserActivity, | ||
| responses={ | ||
| UserInactiveError.status_code: {"model": AppErrorResponse, "description": UserInactiveError.__doc__} | ||
| }, | ||
| ) | ||
| async def get_user_activity(request: Request, user_id: UUID, session: AsyncDbSession): | ||
| if not await user_service.is_user_active(session, user_id): | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. could we distinguish a missing user from an inactive one? is_user_active returns False for both, so an unknown ID currently receives 403 instead of 404. |
||
| raise UserInactiveError(user_id) | ||
| return UserActivity(active=True) | ||
|
|
||
|
|
||
| @router.get("/", response_model=list[UserRead]) | ||
| @format_response(extra_rels=user_rels) | ||
| async def get_users( | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| from logging import getLogger | ||
|
|
||
| from fastapi import FastAPI, Request | ||
| from fastapi.encoders import jsonable_encoder | ||
| from fastapi.exceptions import RequestValidationError | ||
| from fastapi.responses import JSONResponse | ||
|
|
||
| from app.utils.exceptions import AppError, AppErrorResponse, UnprocessableEntityError | ||
|
|
||
| logger = getLogger(__name__) | ||
|
|
||
| SERVER_ERROR_MESSAGE = "Internal server error" | ||
|
|
||
|
|
||
| def to_error_response(exc: AppError) -> JSONResponse: | ||
| """Serialize an AppError to the one error body shape used across the API.""" | ||
| # 5xx detail may name tables or models, so it is logged (and picked up by Sentry) not returned. | ||
| if exc.status_code >= 500: | ||
| logger.error("Server error: %s", exc.message, exc_info=exc) | ||
| body = AppErrorResponse(message=SERVER_ERROR_MESSAGE, status_code=exc.status_code) | ||
| else: | ||
| body = AppErrorResponse(message=exc.message, status_code=exc.status_code, errors=exc.errors) | ||
| return JSONResponse( | ||
| status_code=exc.status_code, | ||
| content=jsonable_encoder(body, exclude_none=True), | ||
| headers=exc.headers, | ||
| ) | ||
|
|
||
|
|
||
| def add_exception_handlers(api: FastAPI) -> None: | ||
| """Register the handlers that give every API error the same JSON body.""" | ||
|
|
||
| @api.exception_handler(AppError) | ||
| async def app_error_handler(_: Request, exc: AppError) -> JSONResponse: | ||
| return to_error_response(exc) | ||
|
|
||
| @api.exception_handler(RequestValidationError) | ||
| async def request_validation_handler(_: Request, exc: RequestValidationError) -> JSONResponse: | ||
| # jsonable_encoder is required here: pydantic puts raw exception objects in `ctx`. | ||
| return to_error_response( | ||
| UnprocessableEntityError(message="Request validation failed", errors={"fields": exc.errors()}), | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. could we avoid passing exc.errors() through unchanged? it may contain PII in input/msg and non-serialisable exceptions in ctx. perhaps we could expose only safe loc and type values |
||
| ) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,16 +1,106 @@ | ||
| import asyncio | ||
| from collections.abc import Callable | ||
| from functools import singledispatch, wraps | ||
| from typing import ParamSpec, TypeVar | ||
| from typing import Any, ParamSpec, TypeVar | ||
| from uuid import UUID | ||
|
|
||
| from fastapi.exceptions import HTTPException, RequestValidationError | ||
| from psycopg.errors import IntegrityError as PsycopgIntegrityError | ||
| from pydantic import BaseModel | ||
| from pydantic_core import ErrorDetails | ||
| from sqlalchemy.exc import IntegrityError as SQLAIntegrityError | ||
|
|
||
|
|
||
| class MultipleResultsFoundError(Exception): | ||
| pass | ||
| class AppError(Exception): | ||
| """Base application error — presentation-ready: it carries its own HTTP status, optional | ||
| field-level `errors`, and response `headers`. | ||
|
|
||
| Every error this API returns is an AppError: domain modules subclass it, and | ||
| `handle_exception` below converts infrastructure exceptions into it. A single handler | ||
| (see `app/utils/exception_handlers.py`) serializes all of them to `AppErrorResponse`, so | ||
| clients only ever parse one error shape. | ||
|
|
||
| Subclasses with a fixed status only need to override the `status_code` class attribute. | ||
| """ | ||
|
|
||
| status_code: int = 400 | ||
|
|
||
| def __init__( | ||
| self, | ||
| message: str, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. could we add '*' after 'message' so that the remaining parameters are keyword-only? This makes call sites clearer and prevents accidentally passing status_code, errors, or headers in the wrong order. We should check and update any existing positional usages as part of this change |
||
| status_code: int | None = None, | ||
| errors: dict[str, Any] | None = None, | ||
| headers: dict[str, str] | None = None, | ||
| ) -> None: | ||
| super().__init__(message) | ||
| self.message = message | ||
| if status_code is not None: | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. non-blocking: we could make this explicit:
current code also works-it falls back to the subclass’s class attribute when no instance value is assigned |
||
| self.status_code = status_code | ||
| self.errors = errors | ||
| self.headers = headers | ||
|
|
||
| def __str__(self) -> str: | ||
| return self.message | ||
|
|
||
|
|
||
| class AppErrorResponse(BaseModel): | ||
| """JSON body for an `AppError` (also documents the error shape in OpenAPI). | ||
|
|
||
| Headers live on the HTTP response, not in the body, so they are intentionally omitted here. | ||
| """ | ||
|
|
||
| message: str | ||
| status_code: int | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. non-blocking: could we omit status_code from AppErrorResponse? AppError still needs it to set the HTTP status, but serialising the same value in the body is redundant and could drift in other response paths. |
||
| errors: dict[str, Any] | None = None | ||
|
|
||
|
|
||
| class ValidationErrors(BaseModel): | ||
| """`errors` payload of a 422 — pydantic's own report, passed through untouched.""" | ||
|
|
||
| fields: list[ErrorDetails] | ||
|
|
||
|
|
||
| class ValidationErrorResponse(AppErrorResponse): | ||
| """422 body: the same envelope as any other error, with `errors` typed for OpenAPI.""" | ||
|
|
||
| errors: ValidationErrors | ||
|
|
||
|
|
||
| class BadRequestError(AppError): | ||
| """Request cannot be fulfilled as sent.""" | ||
|
|
||
| status_code = 400 | ||
|
|
||
|
|
||
| class NotFoundError(AppError): | ||
| """Requested resource does not exist.""" | ||
|
|
||
| status_code = 404 | ||
|
|
||
|
|
||
| class UnprocessableEntityError(AppError): | ||
| """Request body or parameters failed validation.""" | ||
|
|
||
| status_code = 422 | ||
|
|
||
|
|
||
| class MultipleResultsFoundError(AppError): | ||
| """More rows matched than the single result expected — a data integrity problem, not a | ||
| client mistake, hence 5xx. Its message names the model and row count, so it is logged | ||
| rather than returned; see `to_error_response`. | ||
| """ | ||
|
|
||
| status_code = 500 | ||
|
|
||
| def __init__(self, model: type, count: int) -> None: | ||
| super().__init__(message=f"Found {count} instances of {model.__name__} when only one was expected") | ||
|
|
||
|
|
||
| VALIDATION_ERROR_RESPONSE: dict[int | str, dict[str, Any]] = { | ||
| UnprocessableEntityError.status_code: { | ||
| "model": ValidationErrorResponse, | ||
| "description": UnprocessableEntityError.__doc__, | ||
| }, | ||
| } | ||
|
|
||
|
|
||
| class ResourceNotFoundError(Exception): | ||
|
|
@@ -23,37 +113,28 @@ def __init__(self, entity_name: str, entity_id: int | UUID | None = None): | |
|
|
||
|
|
||
| @singledispatch | ||
| def handle_exception(exc: Exception, _: str) -> HTTPException: | ||
| def handle_exception(exc: Exception, _: str) -> AppError: | ||
| raise exc | ||
|
|
||
|
|
||
| @handle_exception.register | ||
| def _(exc: SQLAIntegrityError | PsycopgIntegrityError, entity: str) -> HTTPException: | ||
| return HTTPException( | ||
| status_code=400, | ||
| detail=f"{entity.capitalize()} entity already exists. Details: {exc.args[0]}", | ||
| def _(exc: SQLAIntegrityError | PsycopgIntegrityError, entity: str) -> AppError: | ||
| return BadRequestError( | ||
| message=f"{entity.capitalize()} entity already exists.", | ||
| errors={"detail": str(exc.args[0])}, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. could we avoid returning the raw database exception? it may expose constraint names and submitted values. a fixed error code would be safer |
||
| ) | ||
|
|
||
|
|
||
| @handle_exception.register | ||
| def _(exc: ResourceNotFoundError, _: str) -> HTTPException: | ||
| return HTTPException(status_code=404, detail=exc.detail) | ||
|
|
||
|
|
||
| @handle_exception.register | ||
| def _(exc: AttributeError, entity: str) -> HTTPException: | ||
| return HTTPException( | ||
| status_code=400, | ||
| detail=f"{entity.capitalize()} doesn't support attribute or method. Details: {exc.args[0]} ", | ||
| ) | ||
| def _(exc: ResourceNotFoundError, _: str) -> AppError: | ||
| return NotFoundError(message=exc.detail) | ||
|
|
||
|
|
||
| @handle_exception.register | ||
| def _(exc: RequestValidationError, _: str) -> HTTPException: | ||
| err_args = exc.args[0][0] | ||
| return HTTPException( | ||
| status_code=400, | ||
| detail=f"{err_args['msg']} - {err_args['ctx']['error']}", | ||
| def _(exc: AttributeError, entity: str) -> AppError: | ||
| return BadRequestError( | ||
| message=f"{entity.capitalize()} doesn't support attribute or method.", | ||
| errors={"detail": str(exc.args[0])}, | ||
| ) | ||
|
|
||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
could we avoid including user_id in the exception message? exception messages may be captured by logs or Sentry, so this unnecessarily propagates a user identifier. a fixed message with a stable user_inactive code would be safer