diff --git a/api/src/core/exception_handlers.py b/api/src/core/exception_handlers.py index 9047683..5518839 100644 --- a/api/src/core/exception_handlers.py +++ b/api/src/core/exception_handlers.py @@ -18,7 +18,7 @@ from src.core.CONSTANTS import ( PG_403_ERRORS, ) -def _error_payload(code: str | int, message: str, field: str | None = None) -> dict: +def _error_payload(code: int, message: str, field: str = "") -> dict: """Формирует структурированный payload ошибки по спеке.""" return { "code": code, @@ -27,13 +27,12 @@ def _error_payload(code: str | int, message: str, field: str | None = None) -> d } -def _parse_prefixed_validation_error(message: str) -> tuple[str, str | None]: +def _parse_prefixed_validation_error(message: str) -> str: for prefix in VALIDATION_PREFIXES: if message.lower().startswith(prefix): - code = prefix[:-1] - field = message.split(":", 1)[1].strip() if ":" in message else None - return code, field or None - return "validation_error", None + field = message.split(":", 1)[1].strip() if ":" in message else "" + return field or "" + return "" def _first_db_message_line(message: str) -> str: @@ -45,34 +44,42 @@ def _first_db_message_line(message: str) -> str: return message.strip() -def map_sqlalchemy_error(exc: SQLAlchemyError) -> tuple[int, str, str, str | None]: +def map_sqlalchemy_error(exc: SQLAlchemyError) -> tuple[int, int, str, str]: """Маппинг ошибок SQLAlchemy в HTTP-статус + code/message/field.""" raw = str(getattr(exc, "orig", exc) or exc).strip() message = _first_db_message_line(raw) or "Database error" low = message.lower() if any(low.startswith(prefix) for prefix in VALIDATION_PREFIXES): - code, field = _parse_prefixed_validation_error(message) - return status.HTTP_422_UNPROCESSABLE_ENTITY, code, message, field + status_code = status.HTTP_422_UNPROCESSABLE_ENTITY + field = _parse_prefixed_validation_error(message) + return status_code, status_code, message, field if any(error in low for error in PG_404_ERRORS): - return status.HTTP_404_NOT_FOUND, "not_found", message, None + status_code = status.HTTP_404_NOT_FOUND + return status_code, status_code, message, "" if any(error in low for error in PG_403_ERRORS): - return status.HTTP_403_FORBIDDEN, "access_denied", message, None + status_code = status.HTTP_403_FORBIDDEN + return status_code, status_code, message, "" if isinstance(exc, IntegrityError): if any(error in low for error in PG_409_ERRORS): - return status.HTTP_409_CONFLICT, "conflict", message, None + status_code = status.HTTP_409_CONFLICT + return status_code, status_code, message, "" if any(error in low for error in PG_400_ERRORS): - return status.HTTP_400_BAD_REQUEST, "db_error", message, None - return status.HTTP_400_BAD_REQUEST, "db_error", message, None + status_code = status.HTTP_400_BAD_REQUEST + return status_code, status_code, message, "" + status_code = status.HTTP_400_BAD_REQUEST + return status_code, status_code, message, "" if isinstance(exc, OperationalError): - return status.HTTP_503_SERVICE_UNAVAILABLE, "db_unavailable", "Database unavailable", None + status_code = status.HTTP_503_SERVICE_UNAVAILABLE + return status_code, status_code, "Database unavailable", "" - return status.HTTP_400_BAD_REQUEST, "db_error", message, None + status_code = status.HTTP_400_BAD_REQUEST + return status_code, status_code, message, "" -def map_http_exception(exc: HTTPException) -> tuple[int, str | int, str, str | None]: +def map_http_exception(exc: HTTPException) -> tuple[int, int, str, str]: """Маппинг HTTPException в единый формат ошибки API.""" status_code = int(exc.status_code) detail = exc.detail @@ -80,13 +87,11 @@ def map_http_exception(exc: HTTPException) -> tuple[int, str | int, str, str | N if isinstance(detail, dict): message = str(detail.get("message") or detail.get("detail") or "HTTP error") field = detail.get("field") - if field is not None: - field = str(field) - code = detail.get("code", status_code) - return status_code, code, message, field + field_str = str(field) if field is not None else "" + return status_code, status_code, message, field_str message = str(detail) if detail else "HTTP error" - return status_code, status_code, message, None + return status_code, status_code, message, "" def register_exception_handlers(app: FastAPI) -> None: @@ -101,38 +106,43 @@ def register_exception_handlers(app: FastAPI) -> None: @app.exception_handler(AccessDeniedException) async def access_denied_exception_handler(request, exc: AccessDeniedException): + status_code = status.HTTP_403_FORBIDDEN return JSONResponse( - status_code=status.HTTP_403_FORBIDDEN, - content=_error_payload("access_denied", exc.description), + status_code=status_code, + content=_error_payload(status_code, exc.description), ) @app.exception_handler(ValidationException) async def validation_exception_handler(request, exc: ValidationException): + status_code = status.HTTP_400_BAD_REQUEST return JSONResponse( - status_code=status.HTTP_400_BAD_REQUEST, - content=_error_payload("validation_error", exc.description, field=exc.field), + status_code=status_code, + content=_error_payload(status_code, exc.description, field=exc.field or ""), ) @app.exception_handler(UserNotFoundException) async def user_not_found_exception_handler(request, exc: UserNotFoundException): + status_code = status.HTTP_404_NOT_FOUND return JSONResponse( - status_code=status.HTTP_404_NOT_FOUND, - content=_error_payload("user_not_found", exc.description), + status_code=status_code, + content=_error_payload(status_code, exc.description), ) @app.exception_handler(UsernameConflictException) async def username_conflict_exception_handler(request, exc: UsernameConflictException): + status_code = status.HTTP_409_CONFLICT return JSONResponse( - status_code=status.HTTP_409_CONFLICT, - content=_error_payload("username_conflict", exc.description), + status_code=status_code, + content=_error_payload(status_code, exc.description), ) @app.exception_handler(BasicAppException) async def basic_app_exception_handler(request, exc: BasicAppException): message = exc.description or "Application error" + status_code = status.HTTP_400_BAD_REQUEST return JSONResponse( - status_code=status.HTTP_400_BAD_REQUEST, - content=_error_payload("application_error", message), + status_code=status_code, + content=_error_payload(status_code, message), ) @app.exception_handler(SQLAlchemyError) diff --git a/api/tests/unit/test_exception_handlers.py b/api/tests/unit/test_exception_handlers.py index 3887a49..679a2c2 100644 --- a/api/tests/unit/test_exception_handlers.py +++ b/api/tests/unit/test_exception_handlers.py @@ -18,9 +18,9 @@ def test_map_sqlalchemy_error_unique_conflict(): status_code, code, message, field = map_sqlalchemy_error(exc) assert status_code == status.HTTP_409_CONFLICT - assert code == "conflict" + assert code == status.HTTP_409_CONFLICT assert "duplicate key value" in message - assert field is None + assert field == "" def test_map_sqlalchemy_error_check_constraint_is_400(): @@ -33,9 +33,9 @@ def test_map_sqlalchemy_error_check_constraint_is_400(): status_code, code, message, field = map_sqlalchemy_error(exc) assert status_code == status.HTTP_400_BAD_REQUEST - assert code == "db_error" + assert code == status.HTTP_400_BAD_REQUEST assert "violates check constraint" in message - assert field is None + assert field == "" def test_map_sqlalchemy_error_validation_prefix(): @@ -45,7 +45,7 @@ def test_map_sqlalchemy_error_validation_prefix(): status_code, code, message, field = map_sqlalchemy_error(exc) assert status_code == status.HTTP_422_UNPROCESSABLE_ENTITY - assert code == "unknown_column" + assert code == status.HTTP_422_UNPROCESSABLE_ENTITY assert message == "unknown_column: q5.plan" assert field == "q5.plan" @@ -57,9 +57,9 @@ def test_map_sqlalchemy_error_not_found(): status_code, code, message, field = map_sqlalchemy_error(exc) assert status_code == status.HTTP_404_NOT_FOUND - assert code == "not_found" + assert code == status.HTTP_404_NOT_FOUND assert "не существует" in message - assert field is None + assert field == "" def test_map_sqlalchemy_error_role_window_prefixes(): @@ -67,17 +67,17 @@ def test_map_sqlalchemy_error_role_window_prefixes(): role_exc.orig = Exception("role_not_allowed: q1.m1") status_code, code, message, field = map_sqlalchemy_error(role_exc) assert status_code == status.HTTP_403_FORBIDDEN - assert code == "access_denied" + assert code == status.HTTP_403_FORBIDDEN assert message == "role_not_allowed: q1.m1" - assert field is None + assert field == "" window_exc = SQLAlchemyError() window_exc.orig = Exception("window_closed: q1.m1 (closed 2026-05-01T00:00:00Z)") status_code, code, message, field = map_sqlalchemy_error(window_exc) assert status_code == status.HTTP_403_FORBIDDEN - assert code == "access_denied" + assert code == status.HTTP_403_FORBIDDEN assert message.startswith("window_closed:") - assert field is None + assert field == "" def test_map_sqlalchemy_error_operational(): @@ -90,16 +90,20 @@ def test_map_sqlalchemy_error_operational(): status_code, code, message, field = map_sqlalchemy_error(exc) assert status_code == status.HTTP_503_SERVICE_UNAVAILABLE - assert code == "db_unavailable" + assert code == status.HTTP_503_SERVICE_UNAVAILABLE assert message == "Database unavailable" - assert field is None + assert field == "" def test_error_payload_matches_spec_shape(): - payload = _error_payload("unknown_column", "unknown_column: q5.plan", "q5.plan") + payload = _error_payload( + status.HTTP_422_UNPROCESSABLE_ENTITY, + "unknown_column: q5.plan", + "q5.plan", + ) assert payload == { - "code": "unknown_column", + "code": status.HTTP_422_UNPROCESSABLE_ENTITY, "field": "q5.plan", "message": "unknown_column: q5.plan", } @@ -116,7 +120,7 @@ def test_map_http_exception_validation_prefix(): assert status_code == status.HTTP_422_UNPROCESSABLE_ENTITY assert code == status.HTTP_422_UNPROCESSABLE_ENTITY assert message == "unknown_column: q5.plan" - assert field is None + assert field == "" def test_map_http_exception_from_structured_detail(): @@ -128,7 +132,7 @@ def test_map_http_exception_from_structured_detail(): status_code, code, message, field = map_http_exception(exc) assert status_code == status.HTTP_400_BAD_REQUEST - assert code == "validation_error" + assert code == status.HTTP_400_BAD_REQUEST assert message == "bad input" assert field == "username" @@ -141,7 +145,7 @@ def test_map_http_exception_unauthorized(): assert status_code == status.HTTP_401_UNAUTHORIZED assert code == status.HTTP_401_UNAUTHORIZED assert message == "Недействительный токен" - assert field is None + assert field == "" def test_map_http_exception_not_found_maps_code_from_status(): @@ -152,4 +156,4 @@ def test_map_http_exception_not_found_maps_code_from_status(): assert status_code == status.HTTP_404_NOT_FOUND assert code == status.HTTP_404_NOT_FOUND assert message == "not found" - assert field is None + assert field == "" diff --git a/api/tests/unit/test_exception_handlers_http_shape.py b/api/tests/unit/test_exception_handlers_http_shape.py index cde3af6..b6cb485 100644 --- a/api/tests/unit/test_exception_handlers_http_shape.py +++ b/api/tests/unit/test_exception_handlers_http_shape.py @@ -39,7 +39,7 @@ def test_http_exception_returns_structured_payload(): assert response.status_code == status.HTTP_401_UNAUTHORIZED assert response.json() == { "code": status.HTTP_401_UNAUTHORIZED, - "field": None, + "field": "", "message": "Недействительный токен", } @@ -51,7 +51,7 @@ def test_http_validation_exception_extracts_field(): assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY assert response.json() == { "code": status.HTTP_422_UNPROCESSABLE_ENTITY, - "field": None, + "field": "", "message": "unknown_column: q5.plan", } @@ -62,7 +62,7 @@ def test_sqlalchemy_exception_returns_structured_payload(): assert response.status_code == status.HTTP_404_NOT_FOUND assert response.json() == { - "code": "not_found", - "field": None, + "code": status.HTTP_404_NOT_FOUND, + "field": "", "message": "budget_form #99 не существует", }