Skip to content

Unify Error Handling - #124

Open
Zawiszowski wants to merge 3 commits into
the-momentum:mainfrom
Zawiszowski:fix/error-handling
Open

Unify Error Handling#124
Zawiszowski wants to merge 3 commits into
the-momentum:mainfrom
Zawiszowski:fix/error-handling

Conversation

@Zawiszowski

Copy link
Copy Markdown

Summary

Every API error now returns one JSON shape.
AppError is now the single currency: domain modules subclass it, handle_exception converts infrastructure exceptions into it and one handler serializes all of them to AppErrorResponse.

Changes

  • AppError as base error, carrying status, errors and headers; subclasses only override status_code
  • Ready-made: BadRequestError, NotFoundError, UnprocessableEntityError, MultipleResultsFoundError
  • handle_exception returns AppError instead of HTTPException, status codes unchanged
  • New app/utils/exception_handlers.py, mirroring add_cors_middleware; main.py.jinja stays thin
  • 5xx detail goes to the log at ERROR level, not to the client
  • VALIDATION_ERROR_RESPONSE on the head router replaces FastAPI's HTTPValidationError in docs
  • UserInactiveError and GET /users/{user_id}/activity as the example in monolith

Verified

  • Rendered and exercised api-monolith and api-microservice
  • HTTPValidationError gone from components.schemas, all endpoints reference AppErrorResponse for 422

@Zawiszowski
Zawiszowski marked this pull request as ready for review August 25, 2026 12:09
@FBegiello
FBegiello self-requested a review August 26, 2026 08:56

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

) -> 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

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

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

},
)
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.

"""

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.

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants