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

1"""Typed errors for the forge-dashboard API. 

2 

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""" 

9 

10from __future__ import annotations 

11 

12import json 

13from collections.abc import Mapping 

14from typing import Protocol 

15 

16 

17class _ErrorResponse(Protocol): 

18 """What :func:`decode_error` needs from a response. 

19 

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 """ 

30 

31 @property 

32 def status_code(self) -> int: ... 

33 

34 @property 

35 def content(self) -> bytes: ... 

36 

37 @property 

38 def headers(self) -> Mapping[str, str]: ... 

39 

40 

41class APIError(Exception): 

42 """Raised for any forge-dashboard response carrying an error body. 

43 

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 """ 

49 

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)) 

54 

55 def __str__(self) -> str: 

56 return f"forge-dashboard: {self.status_code}: {self.message}" 

57 

58 

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. 

62 

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. 

68 

69 Usage:: 

70 

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 

77 

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 

85 

86 return APIError(int(response.status_code), message)