Files
gps-denied-onboard/src/gps_denied_onboard/replay_api/errors.py
T
Oleksandr Bezdieniezhnykh 7d53cef0cf
ci/woodpecker/push/02-build-push Pipeline failed
[AZ-701] HTTP replay API service (FastAPI + magic-byte upload validation)
New replay_api component: FastAPI service wrapping the offline
gps-denied-replay pipeline. POST tlog+video (multipart) → either
sync 200 with result/map/report URLs, or async 202 + job id with
/jobs/{id} polling. Magic-byte validation, bearer auth, in-memory
JobRegistry with concurrency + queue caps (429 on overflow).

Helper accuracy_report.py promoted from tests/ to src/ because the
API needs the Markdown report writer at runtime; all AZ-699 imports
re-pointed. OpenAPI spec exported to docs.

18/18 unit tests pass (AC-1 sync, AC-2 async, AC-3 state machine,
AC-5 auth, AC-6 health, AC-8 concurrency, AC-9 magic-byte). Full
unit suite: 2251 pass, 86 skip, 1 pre-existing C12 cold-start flake
(unchanged). mypy --strict clean on the new surface.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 17:30:26 +03:00

87 lines
2.2 KiB
Python

"""AZ-701 — typed HTTP error families for the replay_api service.
Every error has a stable ``error_code`` (string) the contract pins
in ``_docs/02_document/contracts/replay_api/replay_api_protocol.md``.
The handler layer translates these into JSON responses; the
business layer raises them without knowing about HTTP.
"""
from __future__ import annotations
from typing import Any
__all__ = [
"ConcurrencyLimitReachedError",
"JobNotCompleteError",
"JobNotFoundError",
"MultipartMissingFieldError",
"PayloadTooLargeError",
"ReplayApiError",
"ReplayRunnerError",
"UnauthorizedError",
"UnsupportedFileKindError",
]
class ReplayApiError(Exception):
"""Base for every typed replay_api error.
Subclasses pin a stable ``error_code`` and HTTP ``status_code``;
the handler layer reads both to build a JSON response.
"""
error_code: str = "replay_api_error"
status_code: int = 500
def __init__(self, message: str, details: dict[str, Any] | None = None) -> None:
super().__init__(message)
self.message = message
self.details = details or {}
class UnsupportedFileKindError(ReplayApiError):
error_code = "unsupported_file_kind"
status_code = 400
class MultipartMissingFieldError(ReplayApiError):
error_code = "multipart_missing_field"
status_code = 400
class UnauthorizedError(ReplayApiError):
error_code = "unauthorized"
status_code = 401
class JobNotFoundError(ReplayApiError):
error_code = "job_not_found"
status_code = 404
class JobNotCompleteError(ReplayApiError):
error_code = "job_not_complete"
status_code = 409
class PayloadTooLargeError(ReplayApiError):
error_code = "payload_too_large"
status_code = 413
class ConcurrencyLimitReachedError(ReplayApiError):
"""Raised when the queue is full.
Note: per-spec, hitting just the running-job concurrency limit
does NOT raise this — those jobs queue normally. The 429 case is
"queue itself is full" only.
"""
error_code = "concurrency_limit_reached"
status_code = 429
class ReplayRunnerError(ReplayApiError):
error_code = "replay_runner_failed"
status_code = 500