refactor(api): unify typed error contract across draft pricing and publish flows

standardize typed error responses across draft, pricing and publish endpoints

reduce contract drift between related flows
keep client-side handling more predictable and consistent
This commit is contained in:
greebo
2026-03-19 19:54:42 +03:00
parent 64ec1c5180
commit af175d88dd
7 changed files with 89 additions and 38 deletions

View File

@@ -30,6 +30,7 @@ from app.schemas.pricing import (
PricingCategoryUpdateResponse, PricingCategoryUpdateResponse,
) )
from app.security.auth import require_api_key from app.security.auth import require_api_key
from app.services.api_errors import raise_unprocessable
from app.services.draft_guard import validate_expected_draft_version_if_provided from app.services.draft_guard import validate_expected_draft_version_if_provided
router = APIRouter() router = APIRouter()
@@ -213,9 +214,10 @@ async def create_price_rule_endpoint(
try: try:
amount = Decimal(payload.amount) amount = Decimal(payload.amount)
except Exception: except Exception:
raise HTTPException( raise_unprocessable(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, code="invalid_amount",
detail="Некорректная сумма", message="Некорректная сумма",
amount=payload.amount,
) )
price_rule_id = await create_price_rule( price_rule_id = await create_price_rule(
@@ -273,9 +275,10 @@ async def update_price_rule_endpoint(
try: try:
amount = Decimal(payload.amount) amount = Decimal(payload.amount)
except Exception: except Exception:
raise HTTPException( raise_unprocessable(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, code="invalid_amount",
detail="Некорректная сумма", message="Некорректная сумма",
amount=payload.amount,
) )
row = await update_price_rule( row = await update_price_rule(

View File

@@ -0,0 +1,39 @@
from __future__ import annotations
from fastapi import HTTPException, status
def raise_conflict(*, code: str, message: str, **extra) -> None:
detail = {
"code": code,
"message": message,
**extra,
}
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=detail,
)
def raise_unprocessable(*, code: str, message: str, **extra) -> None:
detail = {
"code": code,
"message": message,
**extra,
}
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=detail,
)
def raise_not_found(*, code: str, message: str, **extra) -> None:
detail = {
"code": code,
"message": message,
**extra,
}
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=detail,
)

View File

@@ -1,7 +1,6 @@
from fastapi import HTTPException, status
from app.repositories.scheme_versions import get_current_scheme_version from app.repositories.scheme_versions import get_current_scheme_version
from app.repositories.schemes import get_scheme_record_by_scheme_id from app.repositories.schemes import get_scheme_record_by_scheme_id
from app.services.api_errors import raise_conflict
def build_stale_draft_version_detail( def build_stale_draft_version_detail(
@@ -28,19 +27,19 @@ async def get_current_draft_context(
) )
if version.status != "draft" or scheme.status != "draft": if version.status != "draft" or scheme.status != "draft":
raise HTTPException( raise_conflict(
status_code=status.HTTP_409_CONFLICT, code="draft_not_editable",
detail="Current scheme version is not editable because it is not in draft state", message="Current scheme version is not editable because it is not in draft state",
scheme_status=scheme.status,
scheme_version_status=version.status,
actual_scheme_version_id=version.scheme_version_id,
) )
if expected_scheme_version_id and expected_scheme_version_id != version.scheme_version_id: if expected_scheme_version_id and expected_scheme_version_id != version.scheme_version_id:
raise HTTPException( raise_conflict(**build_stale_draft_version_detail(
status_code=status.HTTP_409_CONFLICT,
detail=build_stale_draft_version_detail(
expected_scheme_version_id=expected_scheme_version_id, expected_scheme_version_id=expected_scheme_version_id,
actual_scheme_version_id=version.scheme_version_id, actual_scheme_version_id=version.scheme_version_id,
), ))
)
return scheme, version return scheme, version

View File

@@ -1,24 +1,19 @@
from __future__ import annotations from __future__ import annotations
from fastapi import HTTPException, status
from app.repositories.scheme_groups import list_scheme_version_groups from app.repositories.scheme_groups import list_scheme_version_groups
from app.repositories.scheme_seats import list_scheme_version_seats from app.repositories.scheme_seats import list_scheme_version_seats
from app.repositories.scheme_sectors import list_scheme_version_sectors from app.repositories.scheme_sectors import list_scheme_version_sectors
from app.services.api_errors import raise_unprocessable
def _raise_uniqueness_error(message: str, detail: dict | None = None) -> None: def _raise_uniqueness_error(message: str, detail: dict | None = None) -> None:
raise HTTPException( payload = detail or {"code": "editor_uniqueness_error", "message": message}
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, raise_unprocessable(**payload)
detail=detail or {"code": "editor_uniqueness_error", "message": message},
)
def _raise_reference_error(message: str, detail: dict | None = None) -> None: def _raise_reference_error(message: str, detail: dict | None = None) -> None:
raise HTTPException( payload = detail or {"code": "editor_reference_error", "message": message}
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, raise_unprocessable(**payload)
detail=detail or {"code": "editor_reference_error", "message": message},
)
async def validate_single_seat_patch_uniqueness( async def validate_single_seat_patch_uniqueness(

View File

@@ -1,9 +1,8 @@
from fastapi import HTTPException, status
from app.repositories.audit import create_audit_event from app.repositories.audit import create_audit_event
from app.repositories.scheme_version_pricing import replace_scheme_version_pricing_snapshot from app.repositories.scheme_version_pricing import replace_scheme_version_pricing_snapshot
from app.services.draft_guard import get_current_draft_context from app.services.draft_guard import get_current_draft_context
from app.repositories.schemes import publish_scheme from app.repositories.schemes import publish_scheme
from app.services.api_errors import raise_conflict
from app.services.scheme_validation import build_scheme_validation_report from app.services.scheme_validation import build_scheme_validation_report
@@ -23,9 +22,11 @@ async def publish_current_draft_scheme(
) )
if not validation["summary"]["is_publishable"]: if not validation["summary"]["is_publishable"]:
raise HTTPException( raise_conflict(
status_code=status.HTTP_409_CONFLICT, code="publish_validation_failed",
detail="Scheme is not publishable in current state", message="Scheme is not publishable in current state",
scheme_version_id=version.scheme_version_id,
validation_summary=validation["summary"],
) )
snapshot = await replace_scheme_version_pricing_snapshot( snapshot = await replace_scheme_version_pricing_snapshot(

View File

@@ -1,11 +1,10 @@
from __future__ import annotations from __future__ import annotations
from fastapi import HTTPException, status
from app.repositories.scheme_seats import ( from app.repositories.scheme_seats import (
bulk_remap_scheme_version_seats, bulk_remap_scheme_version_seats,
list_scheme_version_seats, list_scheme_version_seats,
) )
from app.services.api_errors import raise_unprocessable
from app.services.editor_validation import validate_remap_target_references from app.services.editor_validation import validate_remap_target_references
@@ -35,9 +34,9 @@ async def preview_remap(
to_group_id: str | None, to_group_id: str | None,
) -> list[dict]: ) -> list[dict]:
if not any([seat_record_ids, from_sector_id, from_group_id]): if not any([seat_record_ids, from_sector_id, from_group_id]):
raise HTTPException( raise_unprocessable(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, code="remap_filter_required",
detail="At least one remap filter must be provided", message="At least one remap filter must be provided",
) )
await validate_remap_target_references( await validate_remap_target_references(

View File

@@ -164,3 +164,18 @@ Additional draft read-side stale checks:
- GET /api/v1/schemes/{scheme_id}/draft/groups/records/{group_record_id}?expected_scheme_version_id={current_version_id} -> 200 - GET /api/v1/schemes/{scheme_id}/draft/groups/records/{group_record_id}?expected_scheme_version_id={current_version_id} -> 200
- same endpoints with stale expected_scheme_version_id -> 409 - same endpoints with stale expected_scheme_version_id -> 409
## 11. Typed error contract
Validate representative failures return JSON detail objects with:
- code
- message
Recommended checks:
- stale_draft_version -> 409
- stale_current_version -> 409
- duplicate_sector_id / duplicate_group_id -> 422
- unknown_sector_id / unknown_group_id -> 422
- invalid_amount -> 422
- publish_validation_failed -> 409