memydev-base-sdk 0.1.0__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,411 @@
1
+ """
2
+ @fileoverview Async Collection handle — selector→handle pattern for data-plane operations.
3
+ @module memybase._collection
4
+ @description Implements the data-plane operations (list, create, get, update, softDelete, restore,
5
+ hardDelete, versions, audit, bulk) plus listAll auto-pagination and the full read surface
6
+ (fields projection, expand populate, search FTS, after cursor, count opt-out, includeDeleted).
7
+ Supports both multi-db (/p/{project}/d/{database}/e/{slug}) and legacy
8
+ (/projects/{project}/entities/{slug}).
9
+ @created 2026-07-04
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from typing import Any, AsyncIterator, Callable, Optional
15
+ from urllib.parse import quote
16
+
17
+ # Shared/pure modules use ABSOLUTE imports so the generated memybase/_sync/_collection.py twin resolves
18
+ # the single parent module (there is no _sync/_errors etc.); sibling modules with a twin (_transport)
19
+ # stay RELATIVE so they follow into _sync/. See scripts/gen_sync.py.
20
+ from memybase._errors import ReservedFieldError
21
+ from memybase._filter import FilterBuilder, serialize_filter
22
+ from memybase._mapping import guard_reserved_fields
23
+ from memybase._mapping import omit_none as _omit_none
24
+ from ._transport import Transport
25
+
26
+ __all__ = ["Collection"]
27
+
28
+
29
+ def _mutation_headers(
30
+ expected_version: Optional[int] = None,
31
+ idempotency_key: Optional[str] = None,
32
+ ) -> Optional[dict[str, str]]:
33
+ headers: dict[str, str] = {}
34
+ if expected_version is not None:
35
+ headers["If-Match"] = str(expected_version)
36
+ if idempotency_key is not None:
37
+ headers["Idempotency-Key"] = idempotency_key
38
+ return headers or None
39
+
40
+
41
+ class Collection:
42
+ """Async handle for one entity collection on a specific project/database."""
43
+
44
+ def __init__(
45
+ self,
46
+ transport: Transport,
47
+ slug: str,
48
+ *,
49
+ project: str,
50
+ database: Optional[str] = None,
51
+ ) -> None:
52
+ self._transport = transport
53
+ self._slug = slug
54
+ self._project = project
55
+ self._database = database
56
+
57
+ if database:
58
+ self._base = f"/api/v1/p/{quote(project, safe='')}/d/{quote(database, safe='')}/e/{quote(slug, safe='')}"
59
+ else:
60
+ self._base = f"/api/v1/projects/{quote(project, safe='')}/entities/{quote(slug, safe='')}"
61
+
62
+ def _list_params(
63
+ self,
64
+ *,
65
+ page: Optional[int],
66
+ page_size: Optional[int],
67
+ sort: Optional[str],
68
+ filter: Optional[list[dict[str, Any]] | FilterBuilder],
69
+ include_deleted: bool,
70
+ fields: Optional[list[str]],
71
+ expand: Optional[list[str]],
72
+ search: Optional[str],
73
+ after: Optional[str],
74
+ count: Optional[bool],
75
+ ) -> dict[str, str]:
76
+ """Serialize list/read query params exactly as the server's parseListQuery expects (rest/router.ts).
77
+
78
+ page/pageSize are emitted ONLY when supplied so a no-arg list() lets the server apply its DEFAULT
79
+ (25) — matching JS. Advanced options (fields/expand/search/after cursor/count) round-trip the full
80
+ read surface: projection, relation populate, full-text search, keyset pagination, count opt-out.
81
+ """
82
+ params: dict[str, str] = {}
83
+ if page is not None:
84
+ params["page"] = str(page)
85
+ if page_size is not None:
86
+ params["pageSize"] = str(page_size)
87
+ if sort:
88
+ params["sort"] = sort
89
+ if filter is not None:
90
+ conditions = filter.build() if isinstance(filter, FilterBuilder) else filter
91
+ if conditions:
92
+ params["filter"] = serialize_filter(conditions)
93
+ if include_deleted:
94
+ params["includeDeleted"] = "true"
95
+ if fields:
96
+ params["fields"] = ",".join(fields)
97
+ if expand:
98
+ params["expand"] = ",".join(expand)
99
+ if search:
100
+ params["search"] = search
101
+ if after:
102
+ params["after"] = after
103
+ if count is not None:
104
+ params["count"] = "true" if count else "false"
105
+ return params
106
+
107
+ async def list(
108
+ self,
109
+ *,
110
+ page: Optional[int] = None,
111
+ page_size: Optional[int] = None,
112
+ sort: Optional[str] = None,
113
+ filter: Optional[list[dict[str, Any]] | FilterBuilder] = None,
114
+ include_deleted: bool = False,
115
+ fields: Optional[list[str]] = None,
116
+ expand: Optional[list[str]] = None,
117
+ search: Optional[str] = None,
118
+ after: Optional[str] = None,
119
+ count: Optional[bool] = None,
120
+ ) -> dict[str, Any]:
121
+ """Fetch a page → {items, page, pageSize, total?, hasMore, nextCursor?}."""
122
+ params = self._list_params(
123
+ page=page, page_size=page_size, sort=sort, filter=filter,
124
+ include_deleted=include_deleted, fields=fields, expand=expand,
125
+ search=search, after=after, count=count,
126
+ )
127
+ return await self._transport.request("GET", self._base, params=params)
128
+
129
+ async def query(
130
+ self,
131
+ *,
132
+ page: Optional[int] = None,
133
+ page_size: Optional[int] = None,
134
+ sort: Optional[str] = None,
135
+ filter: Optional[list[dict[str, Any]] | FilterBuilder] = None,
136
+ include_deleted: bool = False,
137
+ fields: Optional[list[str]] = None,
138
+ expand: Optional[list[str]] = None,
139
+ search: Optional[str] = None,
140
+ after: Optional[str] = None,
141
+ count: Optional[bool] = None,
142
+ ) -> dict[str, Any]:
143
+ """RFC 10008 QUERY list request with a JSON body.
144
+
145
+ It is safe and idempotent like ``list()``, but keeps rich query content out of the URI.
146
+ The server applies the identical scope, field-ABAC, query-safety, and pagination contract.
147
+ """
148
+ body: dict[str, Any] = {}
149
+ if page is not None:
150
+ body["page"] = page
151
+ if page_size is not None:
152
+ body["pageSize"] = page_size
153
+ if sort:
154
+ body["sort"] = sort
155
+ if filter is not None:
156
+ conditions = filter.build() if isinstance(filter, FilterBuilder) else filter
157
+ if conditions:
158
+ body["filter"] = conditions
159
+ if include_deleted:
160
+ body["includeDeleted"] = True
161
+ if fields:
162
+ body["fields"] = fields
163
+ if expand:
164
+ body["expand"] = expand
165
+ if search:
166
+ body["search"] = search
167
+ if after:
168
+ body["after"] = after
169
+ if count is not None:
170
+ body["count"] = count
171
+ return await self._transport.request("QUERY", self._base, json_body=body)
172
+
173
+ async def list_all(
174
+ self,
175
+ *,
176
+ page_size: int = 100,
177
+ sort: Optional[str] = None,
178
+ filter: Optional[list[dict[str, Any]] | FilterBuilder] = None,
179
+ include_deleted: bool = False,
180
+ fields: Optional[list[str]] = None,
181
+ expand: Optional[list[str]] = None,
182
+ search: Optional[str] = None,
183
+ max_pages: int = 1000,
184
+ ) -> AsyncIterator[dict[str, Any]]:
185
+ """Auto-paginate through all records, yielding each item.
186
+
187
+ ``max_pages`` bounds the walk; if it is exhausted while the server still reports ``hasMore=True``
188
+ a RuntimeError is raised rather than SILENTLY truncating (an ETL/export footgun). Raise the bound
189
+ (or set it very high) to walk larger collections.
190
+ """
191
+ page = 1
192
+ while True:
193
+ result = await self.list(
194
+ page=page, page_size=page_size, sort=sort, filter=filter,
195
+ include_deleted=include_deleted, fields=fields, expand=expand, search=search,
196
+ )
197
+ for item in result.get("items", []):
198
+ yield item
199
+ if not result.get("hasMore", False):
200
+ return
201
+ if page >= max_pages:
202
+ raise RuntimeError(
203
+ f"list_all exhausted max_pages={max_pages} while the server still reports "
204
+ f"hasMore=True — increase max_pages to page deeper (truncation is never silent)."
205
+ )
206
+ page += 1
207
+
208
+ async def get(
209
+ self,
210
+ record_id: str,
211
+ *,
212
+ fields: Optional[list[str]] = None,
213
+ expand: Optional[list[str]] = None,
214
+ ) -> dict[str, Any]:
215
+ """GET one record by id. Optional `fields` (projection) + `expand` (relation populate) mirror the
216
+ server getById query params (rest/router.ts)."""
217
+ path = f"{self._base}/{quote(record_id, safe='')}"
218
+ params: dict[str, str] = {}
219
+ if fields:
220
+ params["fields"] = ",".join(fields)
221
+ if expand:
222
+ params["expand"] = ",".join(expand)
223
+ return await self._transport.request("GET", path, params=params or None)
224
+
225
+ async def create(
226
+ self,
227
+ body: dict[str, Any],
228
+ *,
229
+ omit_none: bool = True,
230
+ idempotency_key: Optional[str] = None,
231
+ ) -> dict[str, Any]:
232
+ """POST a new record (201).
233
+
234
+ By default None-valued keys are stripped (``omit_none=True``): the server injects defaults, so a
235
+ null on create is never a meaningful clear. Pass ``omit_none=False`` to send the body verbatim —
236
+ for a consumer that stores explicit nulls on create (e.g. a fixed-shape record whose optional
237
+ fields must round-trip as null rather than be absent).
238
+ """
239
+ # Guard the ORIGINAL body BEFORE stripping None so {"version": None} raises ReservedFieldError
240
+ # instead of being silently omitted to a no-op (rank 24).
241
+ guard_reserved_fields(body)
242
+ payload = _omit_none(body) if omit_none else dict(body)
243
+ return await self._transport.request(
244
+ "POST", self._base, json_body=payload, headers=_mutation_headers(idempotency_key=idempotency_key)
245
+ )
246
+
247
+ async def update(
248
+ self,
249
+ record_id: str,
250
+ body: dict[str, Any],
251
+ *,
252
+ expected_version: Optional[int] = None,
253
+ idempotency_key: Optional[str] = None,
254
+ ) -> dict[str, Any]:
255
+ """PATCH a record.
256
+
257
+ Sends the body AS-IS (no None-stripping): the server treats a PATCH ``null`` as a first-class
258
+ sentinel — a non-nullable optional field null → ``$unset`` (clear), a nullable field null →
259
+ ``$set: null``. Stripping None (as create() does) would make field-clear unreachable and resolve
260
+ 200 while the field silently stays set. ``expected_version`` sends If-Match and stale writes fail
261
+ with 409. ``idempotency_key`` enables durable safe retry/replay. Reserved keys are still rejected.
262
+ """
263
+ guard_reserved_fields(body)
264
+ path = f"{self._base}/{quote(record_id, safe='')}"
265
+ return await self._transport.request(
266
+ "PATCH", path, json_body=body, headers=_mutation_headers(expected_version, idempotency_key)
267
+ )
268
+
269
+ async def bulk(self, operations: list[dict[str, Any]], *, idempotency_key: Optional[str] = None) -> dict[str, Any]:
270
+ """Atomic per-entity bulk write → POST {base}/_bulk.
271
+
272
+ ``operations`` is an ordered list of ``{op, id?, data?, reason?}`` (op ∈ create|update|delete),
273
+ all targeting THIS collection's entity, applied all-or-nothing in one placement transaction (the
274
+ first failing op aborts the batch, its error naming the op index). See MemyBase.batch for the
275
+ cross-entity variant.
276
+ """
277
+ return await self._transport.request(
278
+ "POST", f"{self._base}/_bulk", json_body={"operations": operations},
279
+ headers=_mutation_headers(idempotency_key=idempotency_key),
280
+ )
281
+
282
+ async def soft_delete(
283
+ self,
284
+ record_id: str,
285
+ *,
286
+ reason: Optional[str] = None,
287
+ expected_version: Optional[int] = None,
288
+ idempotency_key: Optional[str] = None,
289
+ ) -> None:
290
+ """DELETE (soft, 204)."""
291
+ path = f"{self._base}/{quote(record_id, safe='')}"
292
+ params: Optional[dict[str, str]] = None
293
+ if reason:
294
+ params = {"reason": reason}
295
+ await self._transport.request(
296
+ "DELETE", path, params=params, headers=_mutation_headers(expected_version, idempotency_key)
297
+ )
298
+
299
+ async def restore(
300
+ self,
301
+ record_id: str,
302
+ *,
303
+ expected_version: Optional[int] = None,
304
+ idempotency_key: Optional[str] = None,
305
+ ) -> dict[str, Any]:
306
+ """POST /{id}/restore — un-delete a soft-deleted record."""
307
+ path = f"{self._base}/{quote(record_id, safe='')}/restore"
308
+ return await self._transport.request(
309
+ "POST", path, headers=_mutation_headers(expected_version, idempotency_key)
310
+ )
311
+
312
+ async def hard_delete(
313
+ self,
314
+ record_id: str,
315
+ *,
316
+ expected_version: Optional[int] = None,
317
+ idempotency_key: Optional[str] = None,
318
+ ) -> None:
319
+ """DELETE /{id}/hard — permanent removal (requires elevated key)."""
320
+ path = f"{self._base}/{quote(record_id, safe='')}/hard"
321
+ await self._transport.request(
322
+ "DELETE", path, headers=_mutation_headers(expected_version, idempotency_key)
323
+ )
324
+
325
+ async def versions(
326
+ self,
327
+ record_id: str,
328
+ *,
329
+ page: Optional[int] = None,
330
+ page_size: Optional[int] = None,
331
+ ) -> dict[str, Any]:
332
+ """GET /{id}/versions — immutable version snapshots. page/pageSize sent only when supplied
333
+ (the server applies its own default cap), mirroring JS."""
334
+ path = f"{self._base}/{quote(record_id, safe='')}/versions"
335
+ params: dict[str, str] = {}
336
+ if page is not None:
337
+ params["page"] = str(page)
338
+ if page_size is not None:
339
+ params["pageSize"] = str(page_size)
340
+ return await self._transport.request("GET", path, params=params)
341
+
342
+ async def version(self, record_id: str, version: int) -> dict[str, Any]:
343
+ """GET /{id}/versions/{version} — one immutable version snapshot."""
344
+ path = f"{self._base}/{quote(record_id, safe='')}/versions/{version}"
345
+ return await self._transport.request("GET", path)
346
+
347
+ async def preview_version_restore(self, record_id: str, version: int) -> dict[str, Any]:
348
+ """GET /{id}/versions/{version}/restore-preview — dry-run a historical-content restore."""
349
+ path = f"{self._base}/{quote(record_id, safe='')}/versions/{version}/restore-preview"
350
+ return await self._transport.request("GET", path)
351
+
352
+ async def restore_version(
353
+ self,
354
+ record_id: str,
355
+ version: int,
356
+ *,
357
+ expected_version: Optional[int] = None,
358
+ idempotency_key: Optional[str] = None,
359
+ ) -> dict[str, Any]:
360
+ """POST /{id}/versions/{version}/restore — restore historical content as a new version."""
361
+ path = f"{self._base}/{quote(record_id, safe='')}/versions/{version}/restore"
362
+ return await self._transport.request(
363
+ "POST", path, headers=_mutation_headers(expected_version, idempotency_key)
364
+ )
365
+
366
+ async def audit(
367
+ self,
368
+ record_id: str,
369
+ *,
370
+ page: Optional[int] = None,
371
+ page_size: Optional[int] = None,
372
+ ) -> dict[str, Any]:
373
+ """GET /{id}/audit — before/after/diff audit trail. page/pageSize sent only when supplied."""
374
+ path = f"{self._base}/{quote(record_id, safe='')}/audit"
375
+ params: dict[str, str] = {}
376
+ if page is not None:
377
+ params["page"] = str(page)
378
+ if page_size is not None:
379
+ params["pageSize"] = str(page_size)
380
+ return await self._transport.request("GET", path, params=params)
381
+
382
+ # <sync:exclude> realtime is async-only — the sync SDK does not expose subscribe (SSE is inherently
383
+ # async); test_sync_parity.py documents this asymmetry and gen_sync.py strips this block from _sync/.
384
+ async def subscribe(
385
+ self,
386
+ on_event: Callable[[Any], None],
387
+ *,
388
+ on_resync: Optional[Callable[[str], None]] = None,
389
+ last_event_id: Optional[str] = None,
390
+ signal: Optional["asyncio.Event"] = None,
391
+ ) -> "Subscription":
392
+ """Subscribe to realtime change events for THIS entity via SSE.
393
+
394
+ Returns immediately with a live Subscription (``await subscription.close()`` stops it). The
395
+ callback fires for ready/change/heartbeat/resync StreamEvents; the ticket is scoped to this
396
+ collection's slug (opts.entity). See memybase._realtime for event shapes.
397
+ """
398
+ from ._realtime import RealtimeClient, SubscribeOptions
399
+
400
+ return await RealtimeClient(self._transport).subscribe(
401
+ self._project,
402
+ self._database,
403
+ on_event,
404
+ SubscribeOptions(
405
+ entity=self._slug,
406
+ on_resync=on_resync,
407
+ last_event_id=last_event_id,
408
+ signal=signal,
409
+ ),
410
+ )
411
+ # </sync:exclude>
memybase/_errors.py ADDED
@@ -0,0 +1,232 @@
1
+ """
2
+ @fileoverview SDK error hierarchy — typed exceptions decoded from the MemyBase wire format.
3
+ @module memybase._errors
4
+ @description Maps the server's {error:{code,message,details?}} envelope into a typed exception
5
+ hierarchy. Every error carries code, status, message, and optional details/body.
6
+ Mirrors the JS SDK's MemyBaseError hierarchy and MemySwarm's errors.py taxonomy.
7
+ @created 2026-07-04
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from typing import Any, Optional
12
+
13
+ __all__ = [
14
+ "MemyBaseError",
15
+ "ValidationError",
16
+ "NotFoundError",
17
+ "ConflictError",
18
+ "UnauthorizedError",
19
+ "ForbiddenError",
20
+ "RateLimitedError",
21
+ "ServiceUnavailableError",
22
+ "QuotaExceededError",
23
+ "SchemaError",
24
+ "QueryTimeoutError",
25
+ "NetworkError",
26
+ "TimeoutError",
27
+ "ReservedFieldError",
28
+ "decode_error",
29
+ ]
30
+
31
+
32
+ class MemyBaseError(Exception):
33
+ """Base for all MemyBase SDK errors."""
34
+
35
+ def __init__(
36
+ self,
37
+ message: str = "",
38
+ *,
39
+ code: str = "internal",
40
+ status: int = 0,
41
+ details: Optional[dict[str, Any]] = None,
42
+ body: Any = None,
43
+ retry_after: Optional[float] = None,
44
+ ) -> None:
45
+ self.code = code
46
+ self.status = status
47
+ self.details = details
48
+ self.body = body
49
+ self.retry_after = retry_after
50
+ super().__init__(message or f"HTTP {status}")
51
+
52
+ @property
53
+ def retryable(self) -> bool | None:
54
+ """Wire ``details.retryable`` when the server sent a boolean."""
55
+ value = (self.details or {}).get("retryable")
56
+ return value if isinstance(value, bool) else None
57
+
58
+
59
+ class ValidationError(MemyBaseError):
60
+ """422 — field validation failed."""
61
+
62
+ @property
63
+ def issues(self) -> list[dict[str, Any]]:
64
+ return (self.details or {}).get("issues", [])
65
+
66
+ def __init__(self, message: str = "Validation failed", **kw: Any) -> None:
67
+ kw.setdefault("code", "validation_failed")
68
+ kw.setdefault("status", 422)
69
+ super().__init__(message, **kw)
70
+
71
+
72
+ class NotFoundError(MemyBaseError):
73
+ """404 — resource not found."""
74
+
75
+ def __init__(self, message: str = "Not found", **kw: Any) -> None:
76
+ kw.setdefault("code", "not_found")
77
+ kw.setdefault("status", 404)
78
+ super().__init__(message, **kw)
79
+
80
+
81
+ class ConflictError(MemyBaseError):
82
+ """409 — conflict (OCC race, duplicate, or relationship violation)."""
83
+
84
+ def __init__(self, message: str = "Conflict", **kw: Any) -> None:
85
+ kw.setdefault("code", "conflict")
86
+ kw.setdefault("status", 409)
87
+ super().__init__(message, **kw)
88
+
89
+
90
+ class UnauthorizedError(MemyBaseError):
91
+ """401 — authentication required."""
92
+
93
+ def __init__(self, message: str = "Unauthorized", **kw: Any) -> None:
94
+ kw.setdefault("code", "unauthorized")
95
+ kw.setdefault("status", 401)
96
+ super().__init__(message, **kw)
97
+
98
+
99
+ class ForbiddenError(MemyBaseError):
100
+ """403 — forbidden."""
101
+
102
+ def __init__(self, message: str = "Forbidden", **kw: Any) -> None:
103
+ kw.setdefault("code", "forbidden")
104
+ kw.setdefault("status", 403)
105
+ super().__init__(message, **kw)
106
+
107
+
108
+ class RateLimitedError(MemyBaseError):
109
+ """429 — rate limited; honor retry_after."""
110
+
111
+ def __init__(self, message: str = "Rate limited", *, retry_after: Optional[float] = None, **kw: Any) -> None:
112
+ kw.setdefault("code", "rate_limited")
113
+ kw.setdefault("status", 429)
114
+ kw.setdefault("retry_after", retry_after or 1)
115
+ super().__init__(message, **kw)
116
+
117
+
118
+ class ServiceUnavailableError(MemyBaseError):
119
+ """503 — service unavailable or placement error; retryable."""
120
+
121
+ def __init__(self, message: str = "Service unavailable", *, retry_after: Optional[float] = None, **kw: Any) -> None:
122
+ kw.setdefault("code", "service_unavailable")
123
+ kw.setdefault("status", 503)
124
+ kw.setdefault("retry_after", retry_after or 1)
125
+ super().__init__(message, **kw)
126
+
127
+
128
+ class QuotaExceededError(MemyBaseError):
129
+ """402 — plan quota exceeded."""
130
+
131
+ def __init__(self, message: str = "Quota exceeded", **kw: Any) -> None:
132
+ kw.setdefault("code", "quota_exceeded")
133
+ kw.setdefault("status", 402)
134
+ super().__init__(message, **kw)
135
+
136
+
137
+ class SchemaError(MemyBaseError):
138
+ """400 — schema error or unsafe query."""
139
+
140
+ def __init__(self, message: str = "Schema error", **kw: Any) -> None:
141
+ kw.setdefault("code", "schema_error")
142
+ kw.setdefault("status", 400)
143
+ super().__init__(message, **kw)
144
+
145
+
146
+ class QueryTimeoutError(MemyBaseError):
147
+ """408 — query timeout."""
148
+
149
+ def __init__(self, message: str = "Query timeout", **kw: Any) -> None:
150
+ kw.setdefault("code", "query_timeout")
151
+ kw.setdefault("status", 408)
152
+ super().__init__(message, **kw)
153
+
154
+
155
+ class NetworkError(MemyBaseError):
156
+ """Network-level failure (DNS, connection refused, etc.)."""
157
+
158
+ def __init__(self, message: str = "Network error") -> None:
159
+ super().__init__(message, code="network_error", status=0)
160
+
161
+
162
+ class TimeoutError(MemyBaseError):
163
+ """Request timed out."""
164
+
165
+ def __init__(self, message: str = "Request timed out") -> None:
166
+ super().__init__(message, code="timeout", status=0)
167
+
168
+
169
+ class ReservedFieldError(ValueError):
170
+ """Raised when a write body contains engine-reserved keys."""
171
+
172
+ def __init__(self, fields: list[str]) -> None:
173
+ self.fields = sorted(fields)
174
+ super().__init__(
175
+ f"Body contains reserved system field(s): {', '.join(self.fields)}. "
176
+ "Remove them — the server rejects the entire request with 422."
177
+ )
178
+
179
+
180
+ _BY_CODE: dict[str, type[MemyBaseError]] = {
181
+ "validation_failed": ValidationError,
182
+ "not_found": NotFoundError,
183
+ "conflict": ConflictError,
184
+ "relationship_violation": ConflictError,
185
+ "unauthorized": UnauthorizedError,
186
+ "forbidden": ForbiddenError,
187
+ "tenant_isolation_violation": ForbiddenError,
188
+ "schema_error": SchemaError,
189
+ "unsafe_query": SchemaError,
190
+ "query_timeout": QueryTimeoutError,
191
+ "quota_exceeded": QuotaExceededError,
192
+ "not_implemented": MemyBaseError,
193
+ }
194
+
195
+
196
+ def _parse_retry_after(value: Optional[str]) -> Optional[float]:
197
+ if not value:
198
+ return None
199
+ try:
200
+ return max(0.0, float(value))
201
+ except ValueError:
202
+ return None
203
+
204
+
205
+ def decode_error(
206
+ status: int,
207
+ body: Any = None,
208
+ retry_after_header: Optional[str] = None,
209
+ ) -> MemyBaseError:
210
+ """Build the right MemyBaseError for a non-2xx response."""
211
+ code: Optional[str] = None
212
+ message: Optional[str] = None
213
+ details: Optional[dict[str, Any]] = None
214
+
215
+ if isinstance(body, dict):
216
+ err = body.get("error", body)
217
+ if isinstance(err, dict):
218
+ code = err.get("code")
219
+ message = err.get("message")
220
+ details = err.get("details")
221
+
222
+ eff_code = code or "internal"
223
+ eff_msg = message or f"HTTP {status}"
224
+ retry_after = _parse_retry_after(retry_after_header)
225
+
226
+ if eff_code == "rate_limited":
227
+ return RateLimitedError(eff_msg, retry_after=retry_after, details=details, body=body)
228
+ if eff_code in ("service_unavailable", "placement_error"):
229
+ return ServiceUnavailableError(eff_msg, retry_after=retry_after, details=details, body=body)
230
+
231
+ cls = _BY_CODE.get(eff_code, MemyBaseError)
232
+ return cls(eff_msg, code=eff_code, status=status, details=details, body=body)