Coverage for src/forge_dashboard/errors.py: 100%
23 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-10-11 22:25 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-10-11 22:25 +0000
1"""Typed errors for the forge-dashboard API.
3Every 4xx/5xx response forge-dashboard returns shares the same
4``{error}`` body (see ``openapi/openapi.yaml``'s ``Error`` schema).
5:func:`decode_error` turns that, plus the status code, into a single
6:class:`APIError` -- never a bare string or a raw ``httpx.Response``
7(rules/sdk-generation.md's "Client shape").
8"""
10from __future__ import annotations
12import json
13from collections.abc import Mapping
14from typing import Protocol
17class _ErrorResponse(Protocol):
18 """What :func:`decode_error` needs from a response.
20 Both ``httpx.Response`` and the generated per-operation
21 ``forge_dashboard._generated.types.Response`` satisfy this
22 structurally -- decoding works the same whether you call a wrapper
23 method or drop down to a generated ``sync_detailed``/``asyncio_detailed``
24 call directly. Declared as read-only properties (rather than plain
25 attributes) so a subtype's more specific attribute types --
26 ``http.HTTPStatus`` for ``status_code``, a ``MutableMapping`` for
27 ``headers`` -- still satisfy the protocol; Protocol attribute matching
28 is otherwise invariant.
29 """
31 @property
32 def status_code(self) -> int: ...
34 @property
35 def content(self) -> bytes: ...
37 @property
38 def headers(self) -> Mapping[str, str]: ...
41class APIError(Exception):
42 """Raised for any forge-dashboard response carrying an error body.
44 Attributes:
45 status_code: The HTTP status code.
46 message: The API's own error message (empty string if the body
47 wasn't the documented ``{error}`` shape).
48 """
50 def __init__(self, status_code: int, message: str) -> None:
51 self.status_code = status_code
52 self.message = message
53 super().__init__(str(self))
55 def __str__(self) -> str:
56 return f"forge-dashboard: {self.status_code}: {self.message}"
59def decode_error(response: _ErrorResponse) -> APIError | None:
60 """Build an :class:`APIError` from *response*, or return ``None`` if
61 its status code isn't an error.
63 Decodes ``response.content`` directly rather than trusting the
64 generated per-operation ``.parsed`` field, which is only populated for
65 status codes the spec documents on that specific operation -- a 500 or
66 429 that survived every retry attempt still needs an ``APIError``, and
67 the API returns the same ``{error}`` shape for those too.
69 Usage::
71 response = sync_detailed(client=client.raw, ...)
72 if error := decode_error(response):
73 raise error
74 """
75 if response.status_code < 400:
76 return None
78 message = ""
79 try:
80 body = json.loads(response.content)
81 if isinstance(body, dict):
82 message = str(body.get("error", ""))
83 except ValueError:
84 pass
86 return APIError(int(response.status_code), message)