lorebase-client 0.2.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,242 @@
1
+ """Typed exceptions raised for failed requests.
2
+
3
+ Every Keeper error is a problem detail (RFC 9457, media type `application/problem+json`).
4
+ `error_from_response` maps one to a `LorebaseError` subclass: first by the problem `code`
5
+ (`stale-head`, `protected-branch`, ...), then by HTTP status. Members beyond the standard ones
6
+ stay available in `extra` (`issues`, `errors`, `current_head`, `permission`, ...) and through
7
+ the convenience properties.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import time
14
+ from collections.abc import Mapping
15
+ from email.utils import parsedate_to_datetime
16
+ from typing import Any
17
+
18
+ import httpx
19
+
20
+ _STANDARD = frozenset(
21
+ {"type", "title", "status", "detail", "instance", "code", "request_id", "trace_id"}
22
+ )
23
+ _SNIPPET_CHARS = 300
24
+
25
+
26
+ class LorebaseError(Exception):
27
+ """Base class of every error raised by the client."""
28
+
29
+ def __init__(
30
+ self,
31
+ message: str,
32
+ *,
33
+ status: int | None = None,
34
+ type: str | None = None,
35
+ title: str | None = None,
36
+ detail: str | None = None,
37
+ code: str | None = None,
38
+ instance: str | None = None,
39
+ request_id: str | None = None,
40
+ trace_id: str | None = None,
41
+ extra: Mapping[str, Any] | None = None,
42
+ retry_after: float | None = None,
43
+ ) -> None:
44
+ super().__init__(message)
45
+ self.message = message
46
+ self.status = status
47
+ self.type = type
48
+ self.title = title
49
+ self.detail = detail
50
+ self.code = code
51
+ self.instance = instance
52
+ self.request_id = request_id
53
+ self.trace_id = trace_id
54
+ self.extra: dict[str, Any] = dict(extra or {})
55
+ self.retry_after = retry_after
56
+
57
+ @property
58
+ def issues(self) -> list[dict[str, Any]]:
59
+ """Validation findings of a rejected write (`issues`), each with code, path, message."""
60
+ return list(self.extra.get("issues") or [])
61
+
62
+ @property
63
+ def errors(self) -> list[dict[str, Any]]:
64
+ """Invalid request members (`errors`): `{pointer, message, code}` each."""
65
+ return list(self.extra.get("errors") or [])
66
+
67
+ @property
68
+ def current_head(self) -> str | None:
69
+ """Branch head reported by a `stale-head` conflict."""
70
+ value = self.extra.get("current_head")
71
+ return None if value is None else str(value)
72
+
73
+ def __str__(self) -> str:
74
+ if self.request_id:
75
+ return f"{self.message} (request {self.request_id})"
76
+ return self.message
77
+
78
+
79
+ class LorebaseConnectionError(LorebaseError):
80
+ """The server could not be reached or the connection failed (no HTTP response)."""
81
+
82
+
83
+ class LorebaseConfigError(LorebaseError):
84
+ """The client is not configured (for example no base URL)."""
85
+
86
+
87
+ class ApiError(LorebaseError):
88
+ """The server answered with an error status."""
89
+
90
+
91
+ class BadRequestError(ApiError):
92
+ """400."""
93
+
94
+
95
+ class AuthenticationError(ApiError):
96
+ """401: no valid credential."""
97
+
98
+
99
+ class PermissionDeniedError(ApiError):
100
+ """403: `extra['permission']` names the missing permission."""
101
+
102
+
103
+ class NotFoundError(ApiError):
104
+ """404."""
105
+
106
+
107
+ class ConflictError(ApiError):
108
+ """409: the state of the server does not allow the request."""
109
+
110
+
111
+ class StaleHeadError(ConflictError):
112
+ """409 `stale-head`: the branch moved; `current_head` is where it is now."""
113
+
114
+
115
+ class ProtectedBranchError(ConflictError):
116
+ """409 `protected-branch`: changes reach the branch through a Proposal."""
117
+
118
+
119
+ class ReviewRequiredError(ConflictError):
120
+ """409 `review-required`: the change needs a Proposal."""
121
+
122
+
123
+ class AlreadyExistsError(ConflictError):
124
+ """409 `already-exists`."""
125
+
126
+
127
+ class IdempotencyConflictError(ConflictError):
128
+ """409 `idempotency-conflict`: the key was used with other parameters."""
129
+
130
+
131
+ class PayloadTooLargeError(ApiError):
132
+ """413."""
133
+
134
+
135
+ class ValidationFailedError(ApiError):
136
+ """422: invalid request (`errors`) or data that breaks the schema (`issues`)."""
137
+
138
+
139
+ class RateLimitedError(ApiError):
140
+ """429; `retry_after` holds the wait the server asked for, in seconds."""
141
+
142
+
143
+ class ServerError(ApiError):
144
+ """5xx."""
145
+
146
+
147
+ class UnavailableError(ServerError):
148
+ """503."""
149
+
150
+
151
+ class BranchBusyError(UnavailableError):
152
+ """503 `branch-busy`: another writer holds the branch; retry later."""
153
+
154
+
155
+ _BY_CODE: dict[str, type[ApiError]] = {
156
+ "stale-head": StaleHeadError,
157
+ "protected-branch": ProtectedBranchError,
158
+ "review-required": ReviewRequiredError,
159
+ "already-exists": AlreadyExistsError,
160
+ "idempotency-conflict": IdempotencyConflictError,
161
+ "branch-busy": BranchBusyError,
162
+ }
163
+ _BY_STATUS: dict[int, type[ApiError]] = {
164
+ 400: BadRequestError,
165
+ 401: AuthenticationError,
166
+ 403: PermissionDeniedError,
167
+ 404: NotFoundError,
168
+ 409: ConflictError,
169
+ 413: PayloadTooLargeError,
170
+ 422: ValidationFailedError,
171
+ 429: RateLimitedError,
172
+ 503: UnavailableError,
173
+ }
174
+
175
+
176
+ def parse_retry_after(value: str | None, *, now: float | None = None) -> float | None:
177
+ """Seconds to wait from a `Retry-After` header (delta-seconds or HTTP date)."""
178
+ if not value:
179
+ return None
180
+ value = value.strip()
181
+ if value.isdigit():
182
+ return float(value)
183
+ try:
184
+ moment = parsedate_to_datetime(value)
185
+ except (TypeError, ValueError):
186
+ return None
187
+ return max(0.0, moment.timestamp() - (time.time() if now is None else now))
188
+
189
+
190
+ def _problem_of(response: httpx.Response) -> dict[str, Any] | None:
191
+ try:
192
+ body = json.loads(response.content)
193
+ except ValueError:
194
+ return None
195
+ return body if isinstance(body, dict) and "status" in body else None
196
+
197
+
198
+ def is_problem(response: httpx.Response) -> bool:
199
+ """True when the response is a problem document (so the server itself rejected the call)."""
200
+ return _problem_of(response) is not None
201
+
202
+
203
+ def error_from_response(response: httpx.Response) -> ApiError:
204
+ """The exception for an error response."""
205
+ status = response.status_code
206
+ retry_after = parse_retry_after(response.headers.get("retry-after"))
207
+ problem = _problem_of(response)
208
+ if problem is None:
209
+ snippet = response.text.strip()[:_SNIPPET_CHARS]
210
+ reason = response.reason_phrase or "error"
211
+ message = f"HTTP {status} {reason}" + (f": {snippet}" if snippet else "")
212
+ return _class_for(None, status)(
213
+ message, status=status, title=reason, detail=snippet or None, retry_after=retry_after
214
+ )
215
+ code = _text(problem.get("code"))
216
+ detail = _text(problem.get("detail"))
217
+ title = _text(problem.get("title"))
218
+ return _class_for(code, status)(
219
+ detail or title or f"HTTP {status}",
220
+ status=status,
221
+ type=_text(problem.get("type")),
222
+ title=title,
223
+ detail=detail,
224
+ code=code,
225
+ instance=_text(problem.get("instance")),
226
+ request_id=_text(problem.get("request_id")),
227
+ trace_id=_text(problem.get("trace_id")),
228
+ extra={k: v for k, v in problem.items() if k not in _STANDARD},
229
+ retry_after=retry_after,
230
+ )
231
+
232
+
233
+ def _text(value: object) -> str | None:
234
+ return value if isinstance(value, str) else None
235
+
236
+
237
+ def _class_for(code: str | None, status: int) -> type[ApiError]:
238
+ if code is not None and code in _BY_CODE:
239
+ return _BY_CODE[code]
240
+ if status in _BY_STATUS:
241
+ return _BY_STATUS[status]
242
+ return ServerError if status >= 500 else ApiError