Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions copier.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 '' }}"
Expand Down
3 changes: 2 additions & 1 deletion python-ai-kit/app/api.py
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"])
5 changes: 5 additions & 0 deletions python-ai-kit/app/api/__init__.py.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down
17 changes: 5 additions & 12 deletions python-ai-kit/app/main.py.jinja
Original file line number Diff line number Diff line change
@@ -1,9 +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
{% 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
Expand Down Expand Up @@ -38,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 handle_exception
from app.utils.exception_handlers import add_exception_handlers
{% endif %}

basicConfig(level=INFO, format="[%(asctime)s - %(name)s] (%(levelname)s) %(message)s")
Expand All @@ -60,20 +57,16 @@ init_tracing()
{% endif %}

add_cors_middleware(api)
{% if project_type in ["api-monolith", "api-microservice"] %}
add_exception_handlers(api)
{% endif %}


@api.get("/")
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"])
{% endif %}

{% if project_type == "mcp-server" %}
mcp = FastMCP(name=settings.mcp_server_name)

Expand Down
4 changes: 1 addition & 3 deletions python-ai-kit/app/repositories.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
4 changes: 1 addition & 3 deletions python-ai-kit/app/repositories/repositories.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
12 changes: 12 additions & 0 deletions python-ai-kit/app/user/exceptions.py
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")

Copy link
Copy Markdown

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

17 changes: 16 additions & 1 deletion python-ai-kit/app/user/routes/v1/user_crud.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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(
Expand Down
4 changes: 4 additions & 0 deletions python-ai-kit/app/user/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 1 addition & 2 deletions python-ai-kit/app/user/services/activity_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
3 changes: 2 additions & 1 deletion python-ai-kit/app/user/services/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
42 changes: 42 additions & 0 deletions python-ai-kit/app/utils/exception_handlers.py
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()}),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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

)
129 changes: 105 additions & 24 deletions python-ai-kit/app/utils/exceptions.py
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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

non-blocking: we could make this explicit:

self.status_code = type(self).status_code if status_code is None else status_code

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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):
Expand All @@ -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])},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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])},
)


Expand Down