Merge pull request 'Поправил некорректное отбраженеи ошибок' (#47) from fix_errors into test

Reviewed-on: #47
Reviewed-by: tsygankoviva <tsygankov.itis@gmail.com>
This commit is contained in:
Raykov-MS 2026-06-16 15:23:30 +03:00
commit 56cc1d6f87
3 changed files with 107 additions and 55 deletions

View File

@ -1,4 +1,5 @@
from fastapi import FastAPI, HTTPException, status from fastapi import FastAPI, HTTPException, status
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from sqlalchemy.exc import IntegrityError, OperationalError, SQLAlchemyError, DBAPIError from sqlalchemy.exc import IntegrityError, OperationalError, SQLAlchemyError, DBAPIError
@ -18,7 +19,7 @@ from src.core.CONSTANTS import (
PG_403_ERRORS, 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 ошибки по спеке.""" """Формирует структурированный payload ошибки по спеке."""
return { return {
"code": code, "code": code,
@ -27,13 +28,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: for prefix in VALIDATION_PREFIXES:
if message.lower().startswith(prefix): if message.lower().startswith(prefix):
code = prefix[:-1] field = message.split(":", 1)[1].strip() if ":" in message else ""
field = message.split(":", 1)[1].strip() if ":" in message else None return field or ""
return code, field or None return ""
return "validation_error", None
def _first_db_message_line(message: str) -> str: def _first_db_message_line(message: str) -> str:
@ -45,34 +45,42 @@ def _first_db_message_line(message: str) -> str:
return message.strip() 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.""" """Маппинг ошибок SQLAlchemy в HTTP-статус + code/message/field."""
raw = str(getattr(exc, "orig", exc) or exc).strip() raw = str(getattr(exc, "orig", exc) or exc).strip()
message = _first_db_message_line(raw) or "Database error" message = _first_db_message_line(raw) or "Database error"
low = message.lower() low = message.lower()
if any(low.startswith(prefix) for prefix in VALIDATION_PREFIXES): if any(low.startswith(prefix) for prefix in VALIDATION_PREFIXES):
code, field = _parse_prefixed_validation_error(message) status_code = status.HTTP_422_UNPROCESSABLE_ENTITY
return status.HTTP_422_UNPROCESSABLE_ENTITY, code, message, field field = _parse_prefixed_validation_error(message)
return status_code, status_code, message, field
if any(error in low for error in PG_404_ERRORS): 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): 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 isinstance(exc, IntegrityError):
if any(error in low for error in PG_409_ERRORS): 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): if any(error in low for error in PG_400_ERRORS):
return status.HTTP_400_BAD_REQUEST, "db_error", message, None status_code = status.HTTP_400_BAD_REQUEST
return status.HTTP_400_BAD_REQUEST, "db_error", message, None return status_code, status_code, message, ""
status_code = status.HTTP_400_BAD_REQUEST
return status_code, status_code, message, ""
if isinstance(exc, OperationalError): 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.""" """Маппинг HTTPException в единый формат ошибки API."""
status_code = int(exc.status_code) status_code = int(exc.status_code)
detail = exc.detail detail = exc.detail
@ -80,16 +88,35 @@ def map_http_exception(exc: HTTPException) -> tuple[int, str | int, str, str | N
if isinstance(detail, dict): if isinstance(detail, dict):
message = str(detail.get("message") or detail.get("detail") or "HTTP error") message = str(detail.get("message") or detail.get("detail") or "HTTP error")
field = detail.get("field") field = detail.get("field")
if field is not None: field_str = str(field) if field is not None else ""
field = str(field) return status_code, status_code, message, field_str
code = detail.get("code", status_code)
return status_code, code, message, field
message = str(detail) if detail else "HTTP error" message = str(detail) if detail else "HTTP error"
return status_code, status_code, message, None 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: 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) @app.exception_handler(HTTPException)
async def http_exception_handler(request, exc: HTTPException): async def http_exception_handler(request, exc: HTTPException):
status_code, code, message, field = map_http_exception(exc) status_code, code, message, field = map_http_exception(exc)
@ -101,38 +128,43 @@ def register_exception_handlers(app: FastAPI) -> None:
@app.exception_handler(AccessDeniedException) @app.exception_handler(AccessDeniedException)
async def access_denied_exception_handler(request, exc: AccessDeniedException): async def access_denied_exception_handler(request, exc: AccessDeniedException):
status_code = status.HTTP_403_FORBIDDEN
return JSONResponse( return JSONResponse(
status_code=status.HTTP_403_FORBIDDEN, status_code=status_code,
content=_error_payload("access_denied", exc.description), content=_error_payload(status_code, exc.description),
) )
@app.exception_handler(ValidationException) @app.exception_handler(ValidationException)
async def validation_exception_handler(request, exc: ValidationException): async def validation_exception_handler(request, exc: ValidationException):
status_code = status.HTTP_400_BAD_REQUEST
return JSONResponse( return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status_code,
content=_error_payload("validation_error", exc.description, field=exc.field), content=_error_payload(status_code, exc.description, field=exc.field or ""),
) )
@app.exception_handler(UserNotFoundException) @app.exception_handler(UserNotFoundException)
async def user_not_found_exception_handler(request, exc: UserNotFoundException): async def user_not_found_exception_handler(request, exc: UserNotFoundException):
status_code = status.HTTP_404_NOT_FOUND
return JSONResponse( return JSONResponse(
status_code=status.HTTP_404_NOT_FOUND, status_code=status_code,
content=_error_payload("user_not_found", exc.description), content=_error_payload(status_code, exc.description),
) )
@app.exception_handler(UsernameConflictException) @app.exception_handler(UsernameConflictException)
async def username_conflict_exception_handler(request, exc: UsernameConflictException): async def username_conflict_exception_handler(request, exc: UsernameConflictException):
status_code = status.HTTP_409_CONFLICT
return JSONResponse( return JSONResponse(
status_code=status.HTTP_409_CONFLICT, status_code=status_code,
content=_error_payload("username_conflict", exc.description), content=_error_payload(status_code, exc.description),
) )
@app.exception_handler(BasicAppException) @app.exception_handler(BasicAppException)
async def basic_app_exception_handler(request, exc: BasicAppException): async def basic_app_exception_handler(request, exc: BasicAppException):
message = exc.description or "Application error" message = exc.description or "Application error"
status_code = status.HTTP_400_BAD_REQUEST
return JSONResponse( return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status_code,
content=_error_payload("application_error", message), content=_error_payload(status_code, message),
) )
@app.exception_handler(SQLAlchemyError) @app.exception_handler(SQLAlchemyError)

View File

@ -18,9 +18,9 @@ def test_map_sqlalchemy_error_unique_conflict():
status_code, code, message, field = map_sqlalchemy_error(exc) status_code, code, message, field = map_sqlalchemy_error(exc)
assert status_code == status.HTTP_409_CONFLICT assert status_code == status.HTTP_409_CONFLICT
assert code == "conflict" assert code == status.HTTP_409_CONFLICT
assert "duplicate key value" in message assert "duplicate key value" in message
assert field is None assert field == ""
def test_map_sqlalchemy_error_check_constraint_is_400(): 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) status_code, code, message, field = map_sqlalchemy_error(exc)
assert status_code == status.HTTP_400_BAD_REQUEST 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 "violates check constraint" in message
assert field is None assert field == ""
def test_map_sqlalchemy_error_validation_prefix(): 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) status_code, code, message, field = map_sqlalchemy_error(exc)
assert status_code == status.HTTP_422_UNPROCESSABLE_ENTITY 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 message == "unknown_column: q5.plan"
assert field == "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) status_code, code, message, field = map_sqlalchemy_error(exc)
assert status_code == status.HTTP_404_NOT_FOUND assert status_code == status.HTTP_404_NOT_FOUND
assert code == "not_found" assert code == status.HTTP_404_NOT_FOUND
assert "не существует" in message assert "не существует" in message
assert field is None assert field == ""
def test_map_sqlalchemy_error_role_window_prefixes(): 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") role_exc.orig = Exception("role_not_allowed: q1.m1")
status_code, code, message, field = map_sqlalchemy_error(role_exc) status_code, code, message, field = map_sqlalchemy_error(role_exc)
assert status_code == status.HTTP_403_FORBIDDEN 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 message == "role_not_allowed: q1.m1"
assert field is None assert field == ""
window_exc = SQLAlchemyError() window_exc = SQLAlchemyError()
window_exc.orig = Exception("window_closed: q1.m1 (closed 2026-05-01T00:00:00Z)") window_exc.orig = Exception("window_closed: q1.m1 (closed 2026-05-01T00:00:00Z)")
status_code, code, message, field = map_sqlalchemy_error(window_exc) status_code, code, message, field = map_sqlalchemy_error(window_exc)
assert status_code == status.HTTP_403_FORBIDDEN assert status_code == status.HTTP_403_FORBIDDEN
assert code == "access_denied" assert code == status.HTTP_403_FORBIDDEN
assert message.startswith("window_closed:") assert message.startswith("window_closed:")
assert field is None assert field == ""
def test_map_sqlalchemy_error_operational(): 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) status_code, code, message, field = map_sqlalchemy_error(exc)
assert status_code == status.HTTP_503_SERVICE_UNAVAILABLE assert status_code == status.HTTP_503_SERVICE_UNAVAILABLE
assert code == "db_unavailable" assert code == status.HTTP_503_SERVICE_UNAVAILABLE
assert message == "Database unavailable" assert message == "Database unavailable"
assert field is None assert field == ""
def test_error_payload_matches_spec_shape(): 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 == { assert payload == {
"code": "unknown_column", "code": status.HTTP_422_UNPROCESSABLE_ENTITY,
"field": "q5.plan", "field": "q5.plan",
"message": "unknown_column: 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 status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
assert code == status.HTTP_422_UNPROCESSABLE_ENTITY assert code == status.HTTP_422_UNPROCESSABLE_ENTITY
assert message == "unknown_column: q5.plan" assert message == "unknown_column: q5.plan"
assert field is None assert field == ""
def test_map_http_exception_from_structured_detail(): 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) status_code, code, message, field = map_http_exception(exc)
assert status_code == status.HTTP_400_BAD_REQUEST assert status_code == status.HTTP_400_BAD_REQUEST
assert code == "validation_error" assert code == status.HTTP_400_BAD_REQUEST
assert message == "bad input" assert message == "bad input"
assert field == "username" assert field == "username"
@ -141,7 +145,7 @@ def test_map_http_exception_unauthorized():
assert status_code == status.HTTP_401_UNAUTHORIZED assert status_code == status.HTTP_401_UNAUTHORIZED
assert code == status.HTTP_401_UNAUTHORIZED assert code == status.HTTP_401_UNAUTHORIZED
assert message == "Недействительный токен" assert message == "Недействительный токен"
assert field is None assert field == ""
def test_map_http_exception_not_found_maps_code_from_status(): 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 status_code == status.HTTP_404_NOT_FOUND
assert code == status.HTTP_404_NOT_FOUND assert code == status.HTTP_404_NOT_FOUND
assert message == "not found" assert message == "not found"
assert field is None assert field == ""

View File

@ -29,6 +29,10 @@ def _make_app() -> FastAPI:
exc.orig = Exception("budget_form #99 не существует") exc.orig = Exception("budget_form #99 не существует")
raise exc raise exc
@app.get("/form/{form_id}")
async def form_by_id(form_id: int):
return {"form_id": form_id}
return app return app
@ -39,7 +43,7 @@ def test_http_exception_returns_structured_payload():
assert response.status_code == status.HTTP_401_UNAUTHORIZED assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert response.json() == { assert response.json() == {
"code": status.HTTP_401_UNAUTHORIZED, "code": status.HTTP_401_UNAUTHORIZED,
"field": None, "field": "",
"message": "Недействительный токен", "message": "Недействительный токен",
} }
@ -51,7 +55,7 @@ def test_http_validation_exception_extracts_field():
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
assert response.json() == { assert response.json() == {
"code": status.HTTP_422_UNPROCESSABLE_ENTITY, "code": status.HTTP_422_UNPROCESSABLE_ENTITY,
"field": None, "field": "",
"message": "unknown_column: q5.plan", "message": "unknown_column: q5.plan",
} }
@ -62,7 +66,19 @@ def test_sqlalchemy_exception_returns_structured_payload():
assert response.status_code == status.HTTP_404_NOT_FOUND assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.json() == { assert response.json() == {
"code": "not_found", "code": status.HTTP_404_NOT_FOUND,
"field": None, "field": "",
"message": "budget_form #99 не существует", "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",
}