From 2f5cb21dc0d80da0bdb8fc092307d66590a8c232 Mon Sep 17 00:00:00 2001 From: Raykov-MS Date: Tue, 16 Jun 2026 12:52:39 +0300 Subject: [PATCH 1/2] =?UTF-8?q?=D0=9F=D0=BE=D0=BF=D1=80=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20=D0=BD=D0=B5=D0=BA=D0=BE=D1=80=D1=80=D0=B5=D0=BA=D1=82?= =?UTF-8?q?=D0=BD=D0=BE=D0=B5=20=D0=BE=D1=82=D0=B1=D1=80=D0=B0=D0=B6=D0=B5?= =?UTF-8?q?=D0=BD=D0=B5=D0=B8=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/src/core/exception_handlers.py | 74 +++++++++++-------- api/tests/unit/test_exception_handlers.py | 42 ++++++----- .../test_exception_handlers_http_shape.py | 8 +- 3 files changed, 69 insertions(+), 55 deletions(-) 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 не существует", } From 5210f1d6f7c335a26389fa636362047af19fd0fd Mon Sep 17 00:00:00 2001 From: Raykov-MS Date: Tue, 16 Jun 2026 15:22:39 +0300 Subject: [PATCH 2/2] =?UTF-8?q?=D0=B5=D1=89=D0=B5=20=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/src/core/exception_handlers.py | 22 +++++++++++++++++++ .../test_exception_handlers_http_shape.py | 16 ++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/api/src/core/exception_handlers.py b/api/src/core/exception_handlers.py index 5518839..6c2cfa2 100644 --- a/api/src/core/exception_handlers.py +++ b/api/src/core/exception_handlers.py @@ -1,4 +1,5 @@ from fastapi import FastAPI, HTTPException, status +from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse from sqlalchemy.exc import IntegrityError, OperationalError, SQLAlchemyError, DBAPIError @@ -94,7 +95,28 @@ def map_http_exception(exc: HTTPException) -> tuple[int, int, str, str]: return status_code, status_code, message, "" +def map_request_validation_error(exc: RequestValidationError) -> tuple[int, int, str, str]: + status_code = status.HTTP_422_UNPROCESSABLE_ENTITY + errors = exc.errors() + if not errors: + return status_code, status_code, "Validation error", "" + + first_error = errors[0] + message = str(first_error.get("msg") or "Validation error") + loc = first_error.get("loc") or () + field = ".".join(str(part) for part in loc if part not in ("body", "query", "path", "header", "cookie")) + return status_code, status_code, message, field + + def register_exception_handlers(app: FastAPI) -> None: + @app.exception_handler(RequestValidationError) + async def request_validation_exception_handler(request, exc: RequestValidationError): + status_code, code, message, field = map_request_validation_error(exc) + return JSONResponse( + status_code=status_code, + content=_error_payload(code, message, field=field), + ) + @app.exception_handler(HTTPException) async def http_exception_handler(request, exc: HTTPException): status_code, code, message, field = map_http_exception(exc) diff --git a/api/tests/unit/test_exception_handlers_http_shape.py b/api/tests/unit/test_exception_handlers_http_shape.py index b6cb485..3a90a87 100644 --- a/api/tests/unit/test_exception_handlers_http_shape.py +++ b/api/tests/unit/test_exception_handlers_http_shape.py @@ -29,6 +29,10 @@ def _make_app() -> FastAPI: exc.orig = Exception("budget_form #99 не существует") raise exc + @app.get("/form/{form_id}") + async def form_by_id(form_id: int): + return {"form_id": form_id} + return app @@ -66,3 +70,15 @@ def test_sqlalchemy_exception_returns_structured_payload(): "field": "", "message": "budget_form #99 не существует", } + + +def test_request_validation_422_returns_structured_payload(): + client = TestClient(_make_app()) + response = client.get("/form/not-int") + + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + assert response.json() == { + "code": status.HTTP_422_UNPROCESSABLE_ENTITY, + "field": "form_id", + "message": "Input should be a valid integer, unable to parse string as an integer", + }