Поправил некорректное отбраженеи ошибок
This commit is contained in:
parent
4f7b4aaae5
commit
2f5cb21dc0
@ -18,7 +18,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 +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:
|
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 +44,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,13 +87,11 @@ 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 register_exception_handlers(app: FastAPI) -> None:
|
def register_exception_handlers(app: FastAPI) -> None:
|
||||||
@ -101,38 +106,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)
|
||||||
|
|||||||
@ -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 == ""
|
||||||
|
|||||||
@ -39,7 +39,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 +51,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 +62,7 @@ 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 не существует",
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user