From e448cf154231c3e21040168e2b0637843547b1a2 Mon Sep 17 00:00:00 2001 From: wate <110754234+r0liveir@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:22:30 -0300 Subject: [PATCH 1/2] refactor(errors): centralize HTTP application errors Co-authored-by: Codex --- backend/app/access/service.py | 18 ++--- backend/app/access/views.py | 16 ++--- backend/app/core/exceptions.py | 28 ++++++++ backend/app/main.py | 9 ++- backend/app/session/service.py | 40 ++++++----- backend/app/session/views.py | 90 +++++++----------------- backend/app/tests/access/test_service.py | 13 ++-- 7 files changed, 103 insertions(+), 111 deletions(-) create mode 100644 backend/app/core/exceptions.py diff --git a/backend/app/access/service.py b/backend/app/access/service.py index 12e4fc4..91c28f6 100644 --- a/backend/app/access/service.py +++ b/backend/app/access/service.py @@ -4,13 +4,11 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.access.models import CommitteeAssignment +from app.core.exceptions import AccessDeniedError from .repository import get_committee_assignment, get_session_assignment -class AccessDenied(Exception): ... - - async def resolve_committee_assignment( session: AsyncSession, user_id: UUID, @@ -21,10 +19,10 @@ async def resolve_committee_assignment( ) if assignment is None: - raise AccessDenied("User has no committee assignment") + raise AccessDeniedError("User has no committee assignment") if assignment.role == "delegate" and assignment.representation_id is None: - raise AccessDenied("Delegate role has no delegation id") + raise AccessDeniedError("Delegate role has no delegation id") return assignment @@ -38,10 +36,10 @@ async def resolve_session_assignment( assignment = await get_session_assignment(session, user_id, session_id) if assignment is None: - raise AccessDenied("User has no assignment for this session") + raise AccessDeniedError("User has no assignment for this session") if assignment.role == "delegate" and assignment.representation_id is None: - raise AccessDenied("Delegate role has no delegation id") + raise AccessDeniedError("Delegate role has no delegation id") return assignment @@ -56,9 +54,11 @@ async def verify_user_role( assignment = await get_committee_assignment(session, user_id, committee_id) if assignment is None: - raise AccessDenied("User has no committee assignment") + raise AccessDeniedError("User has no committee assignment") if assignment.role != required_role: - raise AccessDenied(f"User requires the {required_role} role for this committee") + raise AccessDeniedError( + f"User requires the {required_role} role for this committee" + ) return assignment diff --git a/backend/app/access/views.py b/backend/app/access/views.py index 91975ac..702e5fe 100644 --- a/backend/app/access/views.py +++ b/backend/app/access/views.py @@ -1,6 +1,6 @@ from typing import Annotated -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends from sqlalchemy.ext.asyncio import AsyncSession from app.auth.dep import get_current_user @@ -8,7 +8,7 @@ from app.core.database import get_db_session from .schemas import SessionRepresentation -from .service import AccessDenied, resolve_session_assignment +from .service import resolve_session_assignment router = APIRouter() @@ -20,15 +20,9 @@ async def get_my_session_access( current_user: Annotated[AuthUser, Depends(get_current_user)], ) -> SessionRepresentation: """Return the authenticated user's actor context for a session.""" - try: - assignment = await resolve_session_assignment( - db_session, current_user.user_id, session_id - ) - except AccessDenied as exc: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=str(exc), - ) from exc + assignment = await resolve_session_assignment( + db_session, current_user.user_id, session_id + ) return SessionRepresentation( role=assignment.role, representation_id=assignment.representation_id diff --git a/backend/app/core/exceptions.py b/backend/app/core/exceptions.py new file mode 100644 index 0000000..0f1d188 --- /dev/null +++ b/backend/app/core/exceptions.py @@ -0,0 +1,28 @@ +class AppException(Exception): + """Base exception for expected application failures.""" + + status_code = 500 + + def __init__(self, message: str): + self.message = message + super().__init__(message) + + +class BadRequest(AppException): + status_code = 400 + + +class AccessDeniedError(AppException): + status_code = 403 + + +class NotFoundError(AppException): + status_code = 404 + + +class ConflictError(AppException): + status_code = 409 + + +class InternalServerError(AppException): + pass diff --git a/backend/app/main.py b/backend/app/main.py index 191882c..e139dd7 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,12 +1,14 @@ from contextlib import asynccontextmanager -from fastapi import FastAPI +from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.openapi.utils import get_openapi +from fastapi.responses import JSONResponse from app.access.views import router as access_router from app.core.config import get_settings from app.core.database import create_db +from app.core.exceptions import AppException from app.core.openapi import add_websocket_message_schemas from app.session.engine import SessionEngine from app.session.manager import ConnectionManager @@ -37,6 +39,11 @@ async def lifespan(app: FastAPI): lifespan=lifespan, ) + +@app.exception_handler(AppException) +async def app_exception_handler(request: Request, exc: AppException): + return JSONResponse(status_code=exc.status_code, content={"detail": exc.message}) + # CORS config for Vite app.add_middleware( CORSMiddleware, diff --git a/backend/app/session/service.py b/backend/app/session/service.py index cc2e997..d77628f 100644 --- a/backend/app/session/service.py +++ b/backend/app/session/service.py @@ -13,6 +13,12 @@ import app.session.repository as repository import app.session.schemas as schemas from app.access.models import CommitteeAssignment +from app.core.exceptions import ( + BadRequest, + ConflictError, + InternalServerError, + NotFoundError, +) from app.session.engine import EventRejectedError, SessionEngine from .manager import ConnectionManager @@ -28,18 +34,10 @@ class ActorResolutionError(Exception): pass -class SessionCreationError(Exception): - pass - - class SessionFetchError(Exception): pass -class SessionUpdateError(Exception): - pass - - def build_actor( user_id: UUID, manager: ConnectionManager, @@ -91,34 +89,40 @@ async def create_session_service( ) if session_id is None: - raise SessionCreationError("Could not create session with given schema") + raise BadRequest("Could not create session with given schema") await session.commit() return session_id +async def get_session_for_activation( + session: AsyncSession, committee_session_id: int +): + stored = await repository.get_session_info( + session=session, committee_session_id=committee_session_id + ) + if stored is None: + raise NotFoundError("Session not found") + return stored + + async def activate_session( session: AsyncSession, manager: ConnectionManager, committee_session_id: int, ): """Activate a planned session""" - stored = await repository.get_session_info( - session=session, committee_session_id=committee_session_id - ) - - if stored is None: - raise SessionFetchError("Could not fetch session info") + stored = await get_session_for_activation(session, committee_session_id) if stored.status != "planned": - raise SessionFetchError("Session already started") + raise ConflictError("Session already started") delegations = await repository.bulk_get_delegation_context( session=session, committee_id=stored.committee_id ) if delegations is None: - raise SessionFetchError("Could not fetch session delegations info") + raise ConflictError("Session delegations are unavailable") live_state = SessionLiveState( session_id=stored.id, @@ -139,7 +143,7 @@ async def activate_session( try: await repository.update_session_info(session=session, session_info=updated) except repository.RepositoryError: - raise SessionUpdateError("Could not update session info") from None + raise InternalServerError("Could not update session info") from None await session.commit() diff --git a/backend/app/session/views.py b/backend/app/session/views.py index cbe7f1b..547fe13 100644 --- a/backend/app/session/views.py +++ b/backend/app/session/views.py @@ -12,14 +12,11 @@ WebSocketDisconnect, status, ) -from fastapi.exceptions import HTTPException from pydantic import ValidationError from sqlalchemy.ext.asyncio import AsyncSession import app.access.service as access -import app.session.repository as repository import app.session.service as service -from app.access.service import AccessDenied from app.auth.dep import get_current_user from app.auth.service import ( AuthUser, @@ -30,6 +27,7 @@ from app.core.config import Settings, get_settings from app.core.database import get_db_session from app.core.dep import get_connection_manager, get_logger, get_session_engine +from app.core.exceptions import AccessDeniedError from app.session.engine import EventRejectedError, SessionEngine from app.session.enums import EventErrorCode from app.session.manager import ConnectionManager @@ -58,30 +56,17 @@ async def create_session_endpoint( current_user: Annotated[AuthUser, Depends(get_current_user)], ): """POST endpoint to create a new session""" - try: - await access.verify_user_role( - session=session, - user_id=current_user.user_id, - committee_id=session_schema.committee_id, - required_role="chair", - ) - - res = await service.create_session_service( - session=session, - session_schema=session_schema, - ) - return {"id": res, "status": "Created"} - - except AccessDenied as exc: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=str(exc), - ) from exc - except service.SessionCreationError as exc: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=str(exc), - ) from exc + await access.verify_user_role( + session=session, + user_id=current_user.user_id, + committee_id=session_schema.committee_id, + required_role="chair", + ) + session_id = await service.create_session_service( + session=session, + session_schema=session_schema, + ) + return {"id": session_id, "status": "Created"} @router.post("/{session_id}/activate", status_code=status.HTTP_204_NO_CONTENT) @@ -92,41 +77,18 @@ async def activate_session_endpoint( current_user: Annotated[AuthUser, Depends(get_current_user)], ): """Endpoint to activate a planned session""" - try: - stored = await repository.get_session_info( - session=db_session, - committee_session_id=session_id, - ) - if stored is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Session not found", - ) - - await access.verify_user_role( - session=db_session, - user_id=current_user.user_id, - committee_id=stored.committee_id, - required_role="chair", - ) - - await service.activate_session( - session=db_session, manager=manager, committee_session_id=session_id - ) - except AccessDenied as exc: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=str(exc), - ) from exc - except service.SessionFetchError as exc: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=str(exc), - ) from exc - except service.SessionUpdateError as exc: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc) - ) from exc + stored = await service.get_session_for_activation( + session=db_session, committee_session_id=session_id + ) + await access.verify_user_role( + session=db_session, + user_id=current_user.user_id, + committee_id=stored.committee_id, + required_role="chair", + ) + await service.activate_session( + session=db_session, manager=manager, committee_session_id=session_id + ) @router.websocket("/ws/{session_id}") @@ -221,7 +183,7 @@ async def websocket_endpoint( except ( TokenExpiredError, TokenInvalidError, - AccessDenied, + AccessDeniedError, service.ActorResolutionError, service.SessionFetchError, ValidationError, @@ -230,7 +192,7 @@ async def websocket_endpoint( reason = "token_expired" elif isinstance(exc, TokenInvalidError): reason = "token_invalid" - elif isinstance(exc, AccessDenied): + elif isinstance(exc, AccessDeniedError): reason = "access_denied" elif isinstance(exc, service.SessionFetchError): reason = "session_unavailable" diff --git a/backend/app/tests/access/test_service.py b/backend/app/tests/access/test_service.py index e3ab09d..a7a342a 100644 --- a/backend/app/tests/access/test_service.py +++ b/backend/app/tests/access/test_service.py @@ -3,11 +3,8 @@ import pytest from app.access.models import CommitteeAssignment -from app.access.service import ( - AccessDenied, - resolve_committee_assignment, - verify_user_role, -) +from app.access.service import resolve_committee_assignment, verify_user_role +from app.core.exceptions import AccessDeniedError @pytest.mark.anyio @@ -17,7 +14,7 @@ async def no_assignment(*_args, **_kwargs): monkeypatch.setattr("app.access.service.get_committee_assignment", no_assignment) - with pytest.raises(AccessDenied, match="no committee assignment"): + with pytest.raises(AccessDeniedError, match="no committee assignment"): await resolve_committee_assignment(object(), uuid4(), 1) @@ -35,7 +32,7 @@ async def invalid_assignment(*_args, **_kwargs): "app.access.service.get_committee_assignment", invalid_assignment ) - with pytest.raises(AccessDenied, match="no delegation id"): + with pytest.raises(AccessDeniedError, match="no delegation id"): await resolve_committee_assignment(object(), uuid4(), 1) @@ -74,7 +71,7 @@ async def delegate_assignment(*_args, **_kwargs): "app.access.service.get_committee_assignment", delegate_assignment ) - with pytest.raises(AccessDenied, match="requires the chair role"): + with pytest.raises(AccessDeniedError, match="requires the chair role"): await verify_user_role(object(), assignment.user_id, 1, "chair") From 6d4a892523d59a8d05b8a0db7fd3a8fb4ae6f7b5 Mon Sep 17 00:00:00 2001 From: wate <110754234+r0liveir@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:28:16 -0300 Subject: [PATCH 2/2] style: format style --- backend/app/main.py | 1 + backend/app/session/service.py | 4 +--- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/backend/app/main.py b/backend/app/main.py index e139dd7..921c259 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -44,6 +44,7 @@ async def lifespan(app: FastAPI): async def app_exception_handler(request: Request, exc: AppException): return JSONResponse(status_code=exc.status_code, content={"detail": exc.message}) + # CORS config for Vite app.add_middleware( CORSMiddleware, diff --git a/backend/app/session/service.py b/backend/app/session/service.py index d77628f..ff69ac4 100644 --- a/backend/app/session/service.py +++ b/backend/app/session/service.py @@ -96,9 +96,7 @@ async def create_session_service( return session_id -async def get_session_for_activation( - session: AsyncSession, committee_session_id: int -): +async def get_session_for_activation(session: AsyncSession, committee_session_id: int): stored = await repository.get_session_info( session=session, committee_session_id=committee_session_id )