From 2597cbbbdac41d6ad3dad943ca117ab5506c8a14 Mon Sep 17 00:00:00 2001 From: Tomasz Zawiszowski Date: Fri, 21 Aug 2026 15:38:03 +0200 Subject: [PATCH 1/3] feat: add AppError base with UserInactiveError example --- python-ai-kit/app/main.py.jinja | 10 +++++- python-ai-kit/app/user/exceptions.py | 12 +++++++ python-ai-kit/app/user/routes/v1/user_crud.py | 17 ++++++++- python-ai-kit/app/user/schemas.py | 4 +++ .../app/user/services/activity_mixin.py | 3 +- python-ai-kit/app/user/services/services.py | 3 +- python-ai-kit/app/utils/exceptions.py | 35 ++++++++++++++++++- 7 files changed, 78 insertions(+), 6 deletions(-) create mode 100644 python-ai-kit/app/user/exceptions.py diff --git a/python-ai-kit/app/main.py.jinja b/python-ai-kit/app/main.py.jinja index bf5e360..668b6a9 100644 --- a/python-ai-kit/app/main.py.jinja +++ b/python-ai-kit/app/main.py.jinja @@ -3,6 +3,7 @@ from logging import INFO, basicConfig {% if project_type in ["api-monolith", "api-microservice"] %} from fastapi import FastAPI, Request from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse {% elif project_type == "agent" %} from fastapi import FastAPI {% elif project_type == "mcp-server" %} @@ -38,7 +39,7 @@ from app.integrations.sqladmin import admin_authentication_backend from app.middlewares import add_cors_middleware {% endif %} {% if project_type in ["api-monolith", "api-microservice"] %} -from app.utils.exceptions import handle_exception +from app.utils.exceptions import AppError, AppErrorResponse, handle_exception {% endif %} basicConfig(level=INFO, format="[%(asctime)s - %(name)s] (%(levelname)s) %(message)s") @@ -72,6 +73,13 @@ async def root() -> dict[str, str]: @api.exception_handler(RequestValidationError) async def request_validation_exception_handler(_: Request, exc: RequestValidationError) -> None: raise handle_exception(exc, err_msg=exc.args[0][0]["msg"]) + + +@api.exception_handler(AppError) +async def app_error_handler(_: Request, exc: AppError) -> JSONResponse: + """Serialize any domain AppError to a consistent JSON body (+ its status/headers).""" + body = AppErrorResponse(message=exc.message, status_code=exc.status_code, errors=exc.errors) + return JSONResponse(status_code=exc.status_code, content=body.model_dump(exclude_none=True), headers=exc.headers) {% endif %} {% if project_type == "mcp-server" %} diff --git a/python-ai-kit/app/user/exceptions.py b/python-ai-kit/app/user/exceptions.py new file mode 100644 index 0000000..bd3e960 --- /dev/null +++ b/python-ai-kit/app/user/exceptions.py @@ -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 (403).""" + + status_code: int = 403 + + def __init__(self, user_id: UUID) -> None: + super().__init__(message=f"User {user_id} is inactive", status_code=self.status_code) diff --git a/python-ai-kit/app/user/routes/v1/user_crud.py b/python-ai-kit/app/user/routes/v1/user_crud.py index a564d4e..8cceb4f 100644 --- a/python-ai-kit/app/user/routes/v1/user_crud.py +++ b/python-ai-kit/app/user/routes/v1/user_crud.py @@ -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): + raise UserInactiveError(user_id) + return UserActivity(active=True) + + @router.get("/", response_model=list[UserRead]) @format_response(extra_rels=user_rels) async def get_users( diff --git a/python-ai-kit/app/user/schemas.py b/python-ai-kit/app/user/schemas.py index 4e9f6a1..4ac37aa 100644 --- a/python-ai-kit/app/user/schemas.py +++ b/python-ai-kit/app/user/schemas.py @@ -24,3 +24,7 @@ class UserUpdate(BaseModel): username: str | None = None email: EmailStr | None = None updated_at: datetime | None = Field(default_factory=lambda: datetime.now(timezone.utc)) + + +class UserActivity(BaseModel): + active: bool diff --git a/python-ai-kit/app/user/services/activity_mixin.py b/python-ai-kit/app/user/services/activity_mixin.py index 3ad220d..79f788a 100644 --- a/python-ai-kit/app/user/services/activity_mixin.py +++ b/python-ai-kit/app/user/services/activity_mixin.py @@ -4,14 +4,13 @@ from app.database import AsyncDbSession from app.user.repositories import ActivityRepository from app.utils.exceptions import handle_exceptions -from fastapi import Depends if TYPE_CHECKING: from app.user.services import UserService class ActivityMixin: - def __init__(self, activity_repository: ActivityRepository = Depends(), **kwargs): + def __init__(self, activity_repository: ActivityRepository, **kwargs): self.activity_repository = activity_repository super().__init__(**kwargs) diff --git a/python-ai-kit/app/user/services/services.py b/python-ai-kit/app/user/services/services.py index 5b84242..badc486 100644 --- a/python-ai-kit/app/user/services/services.py +++ b/python-ai-kit/app/user/services/services.py @@ -3,6 +3,7 @@ from app.repositories import CrudRepository from app.services import AppService from app.user.models import User +from app.user.repositories import ActivityRepository from app.user.schemas import UserCreate, UserUpdate from .activity_mixin import ActivityMixin @@ -25,7 +26,7 @@ def __init__( log: Logger, **kwargs, ) -> None: - super().__init__(crud_model, model, log, **kwargs) + super().__init__(crud_model, model, log, activity_repository=ActivityRepository(), **kwargs) user_service = UserService(UserRepository, User, logger) diff --git a/python-ai-kit/app/utils/exceptions.py b/python-ai-kit/app/utils/exceptions.py index 28344ca..8a1188d 100644 --- a/python-ai-kit/app/utils/exceptions.py +++ b/python-ai-kit/app/utils/exceptions.py @@ -1,14 +1,47 @@ import asyncio from collections.abc import Callable +from dataclasses import dataclass 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 sqlalchemy.exc import IntegrityError as SQLAIntegrityError +@dataclass +class AppError(Exception): + """Base application error — presentation-ready: it carries its own HTTP status, optional + field-level `errors`, and response `headers`. + + Domain modules subclass this. It is serialized by the global `AppError` handler in + `app/main.py`, NOT translated by `handle_exception` below — the default `handle_exception` + simply re-raises unknown types, so an AppError raised inside a `@handle_exceptions`-wrapped + service still propagates untouched to that global handler. + """ + + message: str + status_code: int = 400 + errors: dict[str, Any] | None = None + headers: dict[str, str] | None = None + + 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 + errors: dict[str, Any] | None = None + + class MultipleResultsFoundError(Exception): pass From bfb8eb9c5ac1468563fa636a6a17445a8051333a Mon Sep 17 00:00:00 2001 From: Tomasz Zawiszowski Date: Fri, 21 Aug 2026 15:38:03 +0200 Subject: [PATCH 2/3] fix: unify error responses and repair 422 handling --- copier.yaml | 1 + python-ai-kit/app/api.py | 3 +- python-ai-kit/app/api/__init__.py.jinja | 5 + python-ai-kit/app/main.py.jinja | 25 +---- python-ai-kit/app/user/exceptions.py | 6 +- python-ai-kit/app/utils/exception_handlers.py | 31 ++++++ python-ai-kit/app/utils/exceptions.py | 100 ++++++++++++------ 7 files changed, 117 insertions(+), 54 deletions(-) create mode 100644 python-ai-kit/app/utils/exception_handlers.py diff --git a/copier.yaml b/copier.yaml index e4972b6..8671d02 100644 --- a/copier.yaml +++ b/copier.yaml @@ -148,6 +148,7 @@ _exclude: - "{{ 'app/utils/conversion.py' if project_type != 'api-monolith' else '' }}" - "{{ 'app/utils/healthcheck.py' if project_type != 'api-monolith' else '' }}" - "{{ 'app/utils/exceptions.py' if project_type not in ['api-monolith', 'api-microservice'] else '' }}" + - "{{ 'app/utils/exception_handlers.py' if project_type not in ['api-monolith', 'api-microservice'] else '' }}" - "{{ 'app/middlewares.py' if project_type == 'mcp-server' else '' }}" - "{{ 'scripts/healthchecks' if project_type not in ['api-monolith', 'api-microservice'] else '' }}" - "{{ 'scripts/start' if project_type == 'mcp-server' else '' }}" diff --git a/python-ai-kit/app/api.py b/python-ai-kit/app/api.py index f065a13..40fafd8 100644 --- a/python-ai-kit/app/api.py +++ b/python-ai-kit/app/api.py @@ -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"]) diff --git a/python-ai-kit/app/api/__init__.py.jinja b/python-ai-kit/app/api/__init__.py.jinja index a2ac547..d5f280a 100644 --- a/python-ai-kit/app/api/__init__.py.jinja +++ b/python-ai-kit/app/api/__init__.py.jinja @@ -5,9 +5,14 @@ from app.api.routes.v1.chat import router as chat_router_v1 {% endif %} {% if project_type == "api-microservice" %} from app.api.routes.v1.user import router as user_router_v1 +from app.utils.exceptions import VALIDATION_ERROR_RESPONSE {% endif %} +{% if project_type == "api-microservice" %} +head_router = APIRouter(responses=VALIDATION_ERROR_RESPONSE) +{% else %} head_router = APIRouter() +{% endif %} {% if project_type == "agent" %} head_router.include_router(chat_router_v1, prefix="/chat", tags=["chat"]) diff --git a/python-ai-kit/app/main.py.jinja b/python-ai-kit/app/main.py.jinja index 668b6a9..d8abda4 100644 --- a/python-ai-kit/app/main.py.jinja +++ b/python-ai-kit/app/main.py.jinja @@ -1,10 +1,6 @@ from logging import INFO, basicConfig -{% if project_type in ["api-monolith", "api-microservice"] %} -from fastapi import FastAPI, Request -from fastapi.exceptions import RequestValidationError -from fastapi.responses import JSONResponse -{% elif project_type == "agent" %} +{% if project_type in ["api-monolith", "api-microservice", "agent"] %} from fastapi import FastAPI {% elif project_type == "mcp-server" %} from fastmcp import FastMCP @@ -39,7 +35,7 @@ from app.integrations.sqladmin import admin_authentication_backend from app.middlewares import add_cors_middleware {% endif %} {% if project_type in ["api-monolith", "api-microservice"] %} -from app.utils.exceptions import AppError, AppErrorResponse, handle_exception +from app.utils.exception_handlers import add_exception_handlers {% endif %} basicConfig(level=INFO, format="[%(asctime)s - %(name)s] (%(levelname)s) %(message)s") @@ -61,6 +57,9 @@ init_tracing() {% endif %} add_cors_middleware(api) +{% if project_type in ["api-monolith", "api-microservice"] %} +add_exception_handlers(api) +{% endif %} @api.get("/") @@ -68,20 +67,6 @@ async def root() -> dict[str, str]: return {"message": "Server is running!"} {% endif %} -{% if project_type in ["api-monolith", "api-microservice"] %} - -@api.exception_handler(RequestValidationError) -async def request_validation_exception_handler(_: Request, exc: RequestValidationError) -> None: - raise handle_exception(exc, err_msg=exc.args[0][0]["msg"]) - - -@api.exception_handler(AppError) -async def app_error_handler(_: Request, exc: AppError) -> JSONResponse: - """Serialize any domain AppError to a consistent JSON body (+ its status/headers).""" - body = AppErrorResponse(message=exc.message, status_code=exc.status_code, errors=exc.errors) - return JSONResponse(status_code=exc.status_code, content=body.model_dump(exclude_none=True), headers=exc.headers) -{% endif %} - {% if project_type == "mcp-server" %} mcp = FastMCP(name=settings.mcp_server_name) diff --git a/python-ai-kit/app/user/exceptions.py b/python-ai-kit/app/user/exceptions.py index bd3e960..91969a4 100644 --- a/python-ai-kit/app/user/exceptions.py +++ b/python-ai-kit/app/user/exceptions.py @@ -4,9 +4,9 @@ class UserInactiveError(AppError): - """User has had no activity in the last 30 days (403).""" + """User has had no activity in the last 30 days.""" - status_code: int = 403 + status_code = 403 def __init__(self, user_id: UUID) -> None: - super().__init__(message=f"User {user_id} is inactive", status_code=self.status_code) + super().__init__(message=f"User {user_id} is inactive") diff --git a/python-ai-kit/app/utils/exception_handlers.py b/python-ai-kit/app/utils/exception_handlers.py new file mode 100644 index 0000000..e893576 --- /dev/null +++ b/python-ai-kit/app/utils/exception_handlers.py @@ -0,0 +1,31 @@ +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 + + +def to_error_response(exc: AppError) -> JSONResponse: + """Serialize an AppError to the one error body shape used across the API.""" + 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()}), + ) diff --git a/python-ai-kit/app/utils/exceptions.py b/python-ai-kit/app/utils/exceptions.py index 8a1188d..de788bd 100644 --- a/python-ai-kit/app/utils/exceptions.py +++ b/python-ai-kit/app/utils/exceptions.py @@ -1,31 +1,42 @@ import asyncio from collections.abc import Callable -from dataclasses import dataclass from functools import singledispatch, wraps 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 -@dataclass class AppError(Exception): """Base application error — presentation-ready: it carries its own HTTP status, optional field-level `errors`, and response `headers`. - Domain modules subclass this. It is serialized by the global `AppError` handler in - `app/main.py`, NOT translated by `handle_exception` below — the default `handle_exception` - simply re-raises unknown types, so an AppError raised inside a `@handle_exceptions`-wrapped - service still propagates untouched to that global handler. + 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. """ - message: str status_code: int = 400 - errors: dict[str, Any] | None = None - headers: dict[str, str] | None = None + + def __init__( + self, + message: str, + 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: + self.status_code = status_code + self.errors = errors + self.headers = headers def __str__(self) -> str: return self.message @@ -42,6 +53,44 @@ class AppErrorResponse(BaseModel): 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 + + +VALIDATION_ERROR_RESPONSE: dict[int | str, dict[str, Any]] = { + UnprocessableEntityError.status_code: { + "model": ValidationErrorResponse, + "description": UnprocessableEntityError.__doc__, + }, +} + + class MultipleResultsFoundError(Exception): pass @@ -56,37 +105,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])}, ) @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])}, ) From 416a61253745f9c78b88c4fe175dc0863ec8fa9a Mon Sep 17 00:00:00 2001 From: Tomasz Zawiszowski Date: Fri, 21 Aug 2026 15:38:03 +0200 Subject: [PATCH 3/3] fix: add 5xx AppError example --- python-ai-kit/app/repositories.py | 4 +--- python-ai-kit/app/repositories/repositories.py | 4 +--- python-ai-kit/app/utils/exception_handlers.py | 13 ++++++++++++- python-ai-kit/app/utils/exceptions.py | 16 ++++++++++++---- 4 files changed, 26 insertions(+), 11 deletions(-) diff --git a/python-ai-kit/app/repositories.py b/python-ai-kit/app/repositories.py index cd90351..cdd9317 100644 --- a/python-ai-kit/app/repositories.py +++ b/python-ai-kit/app/repositories.py @@ -112,9 +112,7 @@ async def get_filtered_scalar( if results: if len(results) == 1: return results[0] - raise MultipleResultsFoundError( - f"Found {len(results)} instances of {self.model} when only one was expected", - ) + raise MultipleResultsFoundError(self.model, len(results)) return None async def update( diff --git a/python-ai-kit/app/repositories/repositories.py b/python-ai-kit/app/repositories/repositories.py index cd90351..cdd9317 100644 --- a/python-ai-kit/app/repositories/repositories.py +++ b/python-ai-kit/app/repositories/repositories.py @@ -112,9 +112,7 @@ async def get_filtered_scalar( if results: if len(results) == 1: return results[0] - raise MultipleResultsFoundError( - f"Found {len(results)} instances of {self.model} when only one was expected", - ) + raise MultipleResultsFoundError(self.model, len(results)) return None async def update( diff --git a/python-ai-kit/app/utils/exception_handlers.py b/python-ai-kit/app/utils/exception_handlers.py index e893576..d91fb19 100644 --- a/python-ai-kit/app/utils/exception_handlers.py +++ b/python-ai-kit/app/utils/exception_handlers.py @@ -1,3 +1,5 @@ +from logging import getLogger + from fastapi import FastAPI, Request from fastapi.encoders import jsonable_encoder from fastapi.exceptions import RequestValidationError @@ -5,10 +7,19 @@ 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.""" - body = AppErrorResponse(message=exc.message, status_code=exc.status_code, errors=exc.errors) + # 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), diff --git a/python-ai-kit/app/utils/exceptions.py b/python-ai-kit/app/utils/exceptions.py index de788bd..d96f89d 100644 --- a/python-ai-kit/app/utils/exceptions.py +++ b/python-ai-kit/app/utils/exceptions.py @@ -83,6 +83,18 @@ class UnprocessableEntityError(AppError): 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, @@ -91,10 +103,6 @@ class UnprocessableEntityError(AppError): } -class MultipleResultsFoundError(Exception): - pass - - class ResourceNotFoundError(Exception): def __init__(self, entity_name: str, entity_id: int | UUID | None = None): self.entity_name = entity_name