mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-10 18:07:07 +01:00
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
106 lines
3.9 KiB
Python
106 lines
3.9 KiB
Python
"""The web API's error type and how every lower-level error maps onto it.
|
|
|
|
Every refusal leaves the server as ``{"error": "<code>", "message": "…"}``
|
|
with the status below (``DOCS-web-control-api.md``, "Endpoints"). The job
|
|
store raises :class:`~steam_backlog_enforcer.jobs._errors.JobError` and the
|
|
daemon client raises :class:`~steam_backlog_enforcer._ctl_protocol.CtlError`
|
|
/ ``DaemonUnreachableError``; the converters here are the only place their codes
|
|
become HTTP statuses, so one code always means one status.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from http import HTTPStatus
|
|
import math
|
|
from typing import TYPE_CHECKING, Final
|
|
|
|
if TYPE_CHECKING:
|
|
from steam_backlog_enforcer._ctl_protocol import CtlError
|
|
from steam_backlog_enforcer.jobs._errors import JobError
|
|
|
|
STATUS_BY_CODE: Final[dict[str, HTTPStatus]] = {
|
|
"bad_token": HTTPStatus.FORBIDDEN,
|
|
"bad_origin": HTTPStatus.FORBIDDEN,
|
|
"bad_host": HTTPStatus.FORBIDDEN,
|
|
"unknown_command": HTTPStatus.BAD_REQUEST,
|
|
"invalid_params": HTTPStatus.BAD_REQUEST,
|
|
"wrong_phrase": HTTPStatus.BAD_REQUEST,
|
|
"not_found": HTTPStatus.NOT_FOUND,
|
|
"locked": HTTPStatus.CONFLICT,
|
|
"busy": HTTPStatus.CONFLICT,
|
|
"countdown_running": HTTPStatus.CONFLICT,
|
|
"not_cancellable": HTTPStatus.CONFLICT,
|
|
"pending_lapsed": HTTPStatus.GONE,
|
|
"rate_limited": HTTPStatus.TOO_MANY_REQUESTS,
|
|
"daemon_unreachable": HTTPStatus.SERVICE_UNAVAILABLE,
|
|
"server_stale": HTTPStatus.SERVICE_UNAVAILABLE,
|
|
"op_failed": HTTPStatus.BAD_GATEWAY,
|
|
"unsupported": HTTPStatus.NOT_IMPLEMENTED,
|
|
}
|
|
|
|
|
|
class ApiError(Exception):
|
|
"""A request the server answers with a contract error body.
|
|
|
|
Attributes:
|
|
code: The contract's ``ApiErrorCode``.
|
|
message: Human text for the body.
|
|
status: HTTP status (defaults to the code's entry in the table).
|
|
retry_after: Seconds the client should wait (``Retry-After``), if any.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
message: str,
|
|
*,
|
|
code: str,
|
|
status: HTTPStatus | None = None,
|
|
retry_after: float | None = None,
|
|
) -> None:
|
|
"""Store the code, message, status and optional retry delay."""
|
|
super().__init__(f"{code}: {message}")
|
|
self.code = code
|
|
self.message = message
|
|
self.status = status or STATUS_BY_CODE.get(code, HTTPStatus.BAD_GATEWAY)
|
|
self.retry_after = retry_after
|
|
|
|
def body(self) -> dict[str, object]:
|
|
"""The JSON object sent to the client."""
|
|
out: dict[str, object] = {"error": self.code, "message": self.message}
|
|
if self.retry_after is not None:
|
|
out["retry_after_seconds"] = self.retry_after
|
|
return out
|
|
|
|
def headers(self) -> dict[str, str]:
|
|
"""Extra response headers (``Retry-After`` when a delay is known)."""
|
|
if self.retry_after is None:
|
|
return {}
|
|
return {"Retry-After": str(max(1, math.ceil(self.retry_after)))}
|
|
|
|
|
|
def not_found(message: str) -> ApiError:
|
|
"""A ``not_found`` 404 for an unknown job, backup or endpoint."""
|
|
return ApiError(message, code="not_found")
|
|
|
|
|
|
def from_job_error(exc: JobError) -> ApiError:
|
|
"""Convert a job-store refusal (an unknown job id is ``not_found``)."""
|
|
return ApiError(exc.message, code=exc.code)
|
|
|
|
|
|
def from_ctl_error(exc: CtlError) -> ApiError:
|
|
"""Convert a daemon refusal; codes the contract lacks become ``op_failed``."""
|
|
code = exc.code if exc.code in STATUS_BY_CODE else "op_failed"
|
|
retry = exc.data.get("retry_after_seconds") if exc.data else None
|
|
return ApiError(
|
|
exc.message or exc.code,
|
|
code=code,
|
|
retry_after=float(retry) if isinstance(retry, int | float) else None,
|
|
)
|
|
|
|
|
|
def daemon_unreachable(detail: str) -> ApiError:
|
|
"""The 503 for a daemon that did not answer on its socket."""
|
|
msg = f"The enforcer daemon is not answering on its control socket ({detail})."
|
|
return ApiError(msg, code="daemon_unreachable")
|