axonio 0.2.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.
axonio/__init__.py ADDED
@@ -0,0 +1,62 @@
1
+ """Axonio Python SDK: persistent memory for AI agents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from typing import Awaitable
7
+
8
+ from axonio.async_client import AsyncClawDB
9
+ from axonio.client import ClawDB
10
+ from axonio.errors import (
11
+ ClawDBAccessDeniedError,
12
+ ClawDBAuthError,
13
+ ClawDBError,
14
+ ClawDBInternalError,
15
+ ClawDBNotFoundError,
16
+ ClawDBRateLimitError,
17
+ ClawDBProvisionError,
18
+ ClawDBTimeoutError,
19
+ ClawDBUnavailableError,
20
+ ClawDBValidationError,
21
+ )
22
+ from axonio.models import BranchInfo, DiffResult, MemoryRecord, MergeResult, ReflectJob, SearchResult, SyncActionResult, SyncResult, SyncStatusResult, TxInfo
23
+ from axonio.transactions import TxClient
24
+
25
+
26
+ def axonio() -> ClawDB | Awaitable[AsyncClawDB]:
27
+ """Return sync client in sync code, or awaitable async client inside an event loop."""
28
+ try:
29
+ asyncio.get_running_loop()
30
+ return AsyncClawDB.auto_provision()
31
+ except RuntimeError:
32
+ return ClawDB.auto_provision()
33
+
34
+ # `Axonio` / `AsyncAxonio` are the same classes as `ClawDB` / `AsyncClawDB`;
35
+ # `clawdb()` remains as an alias of `axonio()`.
36
+ Axonio = ClawDB
37
+ AsyncAxonio = AsyncClawDB
38
+ clawdb = axonio
39
+
40
+ __all__ = [
41
+ "axonio",
42
+ "Axonio",
43
+ "AsyncAxonio",
44
+ "clawdb",
45
+ "ClawDB",
46
+ "AsyncClawDB",
47
+ "MemoryRecord",
48
+ "SearchResult",
49
+ "BranchInfo",
50
+ "SyncResult",
51
+ "ReflectJob",
52
+ "ClawDBError",
53
+ "ClawDBAuthError",
54
+ "ClawDBAccessDeniedError",
55
+ "ClawDBNotFoundError",
56
+ "ClawDBRateLimitError",
57
+ "ClawDBProvisionError",
58
+ "ClawDBUnavailableError",
59
+ "ClawDBTimeoutError",
60
+ "ClawDBValidationError",
61
+ "ClawDBInternalError",
62
+ ]
axonio/_http.py ADDED
@@ -0,0 +1,474 @@
1
+ """HTTP transport for the ClawDB Python SDK.
2
+
3
+ Every client talks to the server's HTTP API (``/v1/...``): the hosted Axonio
4
+ gateway, or a self-hosted clawdb-server's HTTP port. One route table maps each
5
+ operation to a request and reshapes the reply, and both the sync and async
6
+ cores use it, so they can't drift apart.
7
+
8
+ Sessions are opened on first use with a one-hour TTL, renewed a minute before
9
+ they expire, and renewed once (then the call retried) if the server rejects
10
+ the token. Network errors and 502/503/504 are retried with backoff; 429 waits
11
+ for ``Retry-After``.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import hashlib
17
+ import json
18
+ import re
19
+ import threading
20
+ import time
21
+ import uuid
22
+ from dataclasses import dataclass, field
23
+ from typing import Any, Callable
24
+ from urllib.parse import quote, urlsplit, urlunsplit
25
+
26
+ import httpx
27
+
28
+ from axonio.errors import (
29
+ ClawDBAccessDeniedError,
30
+ ClawDBAuthError,
31
+ ClawDBError,
32
+ ClawDBInternalError,
33
+ ClawDBNotFoundError,
34
+ ClawDBRateLimitError,
35
+ ClawDBTimeoutError,
36
+ ClawDBUnavailableError,
37
+ ClawDBValidationError,
38
+ )
39
+
40
+ #: The hosted cloud. Requests carry the API key; the gateway routes them to the
41
+ #: workspace's own ClawDB instance.
42
+ CLOUD_ENDPOINT = "https://api.axonio.xyz/api/v1/gateway"
43
+ #: Retired default cloud hosts; requests to them are sent to CLOUD_ENDPOINT.
44
+ _LEGACY_CLOUD_HOSTS = {"cloud.clawdb.dev"}
45
+
46
+ SESSION_TTL_SECS = 3600
47
+ _RENEW_MARGIN_SECS = 60
48
+ _MAX_ATTEMPTS = 3
49
+ _UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", re.I)
50
+
51
+
52
+ def http_base_url(endpoint: str) -> str:
53
+ """The HTTP API base for a configured endpoint.
54
+
55
+ - A retired cloud host becomes the current cloud gateway.
56
+ - A URL with a path (such as the cloud gateway) is used as-is.
57
+ - A bare server address on the gRPC port 50050 maps to its HTTP port 8080,
58
+ so the long-standing default ``http://localhost:50050`` keeps working.
59
+ - Any other bare address is assumed to be the HTTP API already.
60
+ """
61
+ endpoint = (endpoint or "").strip() or "http://localhost:50050"
62
+ if "://" not in endpoint:
63
+ endpoint = f"http://{endpoint}"
64
+ parts = urlsplit(endpoint)
65
+ if parts.hostname in _LEGACY_CLOUD_HOSTS:
66
+ return CLOUD_ENDPOINT
67
+ path = parts.path.rstrip("/")
68
+ if path:
69
+ return urlunsplit((parts.scheme, parts.netloc, path, "", ""))
70
+ netloc = parts.netloc
71
+ if parts.port == 50050:
72
+ netloc = netloc.rsplit(":", 1)[0] + ":8080"
73
+ return urlunsplit((parts.scheme, netloc, "", "", ""))
74
+
75
+
76
+ def stable_agent_id(agent_id: str) -> str:
77
+ """The server needs a UUID agent id. Any other id maps to a stable UUID,
78
+ byte-for-byte the same as the TypeScript SDK, so one agent name means one
79
+ agent across both SDKs."""
80
+ if _UUID_RE.match(agent_id):
81
+ return agent_id
82
+ digest = list(hashlib.sha256(agent_id.encode()).hexdigest()[:32])
83
+ digest[12] = "5"
84
+ digest[16] = format((int(digest[16], 16) & 0x3) | 0x8, "x")
85
+ h = "".join(digest)
86
+ return f"{h[0:8]}-{h[8:12]}-{h[12:16]}-{h[16:20]}-{h[20:32]}"
87
+
88
+
89
+ # ── Routes ───────────────────────────────────────────────────────────────────
90
+
91
+
92
+ @dataclass
93
+ class _Route:
94
+ method: str
95
+ path: str
96
+ params: dict[str, Any] = field(default_factory=dict)
97
+ body: Any = None
98
+ reply: Callable[[Any], dict[str, Any]] | None = None
99
+
100
+
101
+ def _q(value: Any) -> str:
102
+ return quote(str(value), safe="")
103
+
104
+
105
+ def _as_list(value: Any) -> list[Any]:
106
+ return value if isinstance(value, list) else []
107
+
108
+
109
+ def _as_dict(value: Any) -> dict[str, Any]:
110
+ return value if isinstance(value, dict) else {}
111
+
112
+
113
+ def _branch(value: Any) -> dict[str, Any]:
114
+ b = _as_dict(value)
115
+ return {"branch_id": str(b.get("id") or b.get("branch_id") or ""), "name": str(b.get("name") or ""), "branch_json": json.dumps(b)}
116
+
117
+
118
+ def _route(op: str, p: dict[str, Any]) -> _Route:
119
+ if op == "Health":
120
+ return _Route("GET", "/v1/health")
121
+ if op == "CreateSession":
122
+ return _Route("POST", "/v1/sessions", body={k: p[k] for k in ("agent_id", "role", "scopes", "ttl_secs")})
123
+ if op == "ValidateSession":
124
+ return _Route("GET", "/v1/sessions/me")
125
+ if op == "RevokeSession":
126
+ return _Route("DELETE", f"/v1/sessions/{_q(p['session_id'])}", reply=lambda _: {"revoked": True})
127
+ if op == "ActiveSessionCount":
128
+ return _Route("GET", "/v1/sessions/active/count")
129
+
130
+ if op == "Remember":
131
+ body = {"content": p["content"], "type": p.get("type") or "context", "tags": p.get("tags") or [], "metadata": p.get("metadata") or {}}
132
+ return _Route("POST", "/v1/memories", body=body)
133
+ if op == "UpdateMemory":
134
+ return _Route("PATCH", f"/v1/memories/{_q(p['memory_id'])}", body={"content": p["content"]})
135
+ if op == "DeleteMemory":
136
+ return _Route("DELETE", f"/v1/memories/{_q(p['memory_id'])}", reply=lambda _: {"deleted": True})
137
+ if op == "GetMemory":
138
+ return _Route("GET", f"/v1/memories/{_q(p['memory_id'])}")
139
+ if op == "Search":
140
+ params = {"q": p["query"], "top_k": p.get("top_k", 5), "semantic": "true" if p.get("semantic", True) else "false"}
141
+ return _Route("GET", "/v1/memories/search", params=params, reply=lambda j: {"hits": _as_list(j)})
142
+ if op == "ListMemories":
143
+ params = {"limit": p.get("limit", 50)}
144
+ if p.get("type"):
145
+ params["type"] = p["type"]
146
+ return _Route("GET", "/v1/memories", params=params, reply=lambda j: {"memories": _as_list(j)})
147
+
148
+ if op == "Branch":
149
+ body = {"name": p["name"]}
150
+ if p.get("from"):
151
+ body["from"] = p["from"]
152
+ return _Route("POST", "/v1/branches", body=body, reply=lambda j: {"branch_id": str(_as_dict(j).get("branch_id") or _as_dict(j).get("id") or ""), "name": str(_as_dict(j).get("name") or "")})
153
+ if op == "ListBranches":
154
+ return _Route("GET", "/v1/branches", reply=lambda j: {"branches": [_branch(b) for b in _as_list(j)]})
155
+ if op == "GetBranch":
156
+ return _Route("GET", f"/v1/branches/{_q(p['branch_id'])}", reply=_branch)
157
+ if op == "GetBranchByName":
158
+ return _Route("GET", f"/v1/branches/by-name/{_q(p['name'])}", reply=_branch)
159
+ if op == "GetTrunkBranch":
160
+ return _Route("GET", "/v1/branches/trunk", reply=_branch)
161
+ if op == "Diff":
162
+ def diff_reply(j: Any) -> dict[str, Any]:
163
+ d = _as_dict(j)
164
+ stats = _as_dict(d.get("stats"))
165
+ return {
166
+ **{k: int(stats.get(k, d.get(k, 0)) or 0) for k in ("added", "removed", "modified", "unchanged")},
167
+ "divergence_score": float(d.get("divergence_score") or 0.0),
168
+ "diff_json": json.dumps(d),
169
+ }
170
+ return _Route("GET", f"/v1/branches/{_q(p['branch_id'])}/diff", params={"target": p.get("target") or ""}, reply=diff_reply)
171
+ if op == "Merge":
172
+ def merge_reply(j: Any) -> dict[str, Any]:
173
+ r = _as_dict(j)
174
+ conflicts = r.get("conflicts")
175
+ return {
176
+ "success": bool(r.get("success", True)),
177
+ "applied": int(r.get("applied") or 0),
178
+ "skipped": int(r.get("skipped") or 0),
179
+ "conflicts": len(conflicts) if isinstance(conflicts, list) else int(conflicts or 0),
180
+ "duration_ms": int(r.get("duration_ms") or 0),
181
+ "conflicts_json": json.dumps(conflicts if isinstance(conflicts, list) else []),
182
+ }
183
+ body = {"target": p.get("target") or ""}
184
+ if p.get("strategy"):
185
+ body["strategy"] = p["strategy"]
186
+ return _Route("POST", f"/v1/branches/{_q(p['source'])}/merge", body=body, reply=merge_reply)
187
+ if op == "DiscardBranch":
188
+ return _Route("DELETE", f"/v1/branches/{_q(p['branch_id'])}", reply=lambda _: {"discarded": True})
189
+ if op == "ArchiveBranch":
190
+ return _Route("POST", f"/v1/branches/{_q(p['branch_id'])}/archive", reply=lambda _: {"archived": True})
191
+
192
+ if op == "Sync":
193
+ return _Route("POST", "/v1/sync")
194
+ if op in ("PushSync", "PullSync", "ReconcileSync"):
195
+ path = {"PushSync": "/v1/sync/push", "PullSync": "/v1/sync/pull", "ReconcileSync": "/v1/sync/reconcile"}[op]
196
+ return _Route("POST", path, reply=lambda j: {"summary_json": json.dumps(j if j is not None else {})})
197
+ if op == "SyncStatus":
198
+ return _Route("GET", "/v1/sync/status", reply=lambda j: {"status_json": json.dumps(j if j is not None else {})})
199
+
200
+ wrap = lambda j: {"json": j} # noqa: E731
201
+ if op == "Reflect":
202
+ return _Route("POST", "/v1/reflect")
203
+ if op == "ReflectGetJob":
204
+ return _Route("GET", f"/v1/reflect/jobs/{_q(p['job_id'])}", reply=wrap)
205
+ if op == "ReflectListJobs":
206
+ params = {k: v for k, v in {"agent_id": p.get("agent_id"), "status": p.get("status"), "limit": p.get("limit"), "offset": p.get("offset")}.items() if v not in (None, "")}
207
+ return _Route("GET", "/v1/reflect/jobs", params=params, reply=wrap)
208
+ if op in ("ReflectGetFacts", "ReflectGetPreferences", "ReflectGetContradictions"):
209
+ kind = {"ReflectGetFacts": "facts", "ReflectGetPreferences": "preferences", "ReflectGetContradictions": "contradictions"}[op]
210
+ return _Route("GET", f"/v1/reflect/{kind}/{_q(p['agent_id'])}", reply=wrap)
211
+ if op == "ReflectResolveContradiction":
212
+ body = {"strategy": p.get("strategy") or "", "merged_value": p.get("merged_value")}
213
+ return _Route("POST", f"/v1/reflect/contradictions/{_q(p['agent_id'])}/{_q(p['contradiction_id'])}/resolve", body=body, reply=wrap)
214
+
215
+ if op == "BeginTx":
216
+ return _Route("POST", "/v1/tx")
217
+ if op == "TxRemember":
218
+ return _Route("POST", f"/v1/tx/{_q(p['tx_id'])}/memories", body={"content": p["content"]})
219
+ if op == "TxRememberTyped":
220
+ body = {"content": p["content"], "type": p.get("type") or "context", "tags": p.get("tags") or [], "metadata": p.get("metadata") or {}}
221
+ return _Route("POST", f"/v1/tx/{_q(p['tx_id'])}/memories/typed", body=body)
222
+ if op == "CommitTx":
223
+ return _Route("POST", f"/v1/tx/{_q(p['tx_id'])}/commit")
224
+ if op == "RollbackTx":
225
+ return _Route("POST", f"/v1/tx/{_q(p['tx_id'])}/rollback")
226
+ raise ClawDBValidationError(f"Unknown operation: {op}")
227
+
228
+
229
+ # ── Errors ───────────────────────────────────────────────────────────────────
230
+
231
+
232
+ class _SessionRejected(Exception):
233
+ """The instance rejected the session token; a new session may fix it."""
234
+
235
+
236
+ def _error_for(response: httpx.Response, op: str) -> ClawDBError:
237
+ request_id = response.headers.get("x-request-id")
238
+ try:
239
+ body = _as_dict(response.json())
240
+ except ValueError:
241
+ body = {"error": response.text}
242
+ reason = str(body.get("error") or body.get("message") or response.reason_phrase or f"HTTP {response.status_code}")
243
+ detail = body.get("detail") or (f"requires {body['required']}" if body.get("required") else "")
244
+ message = f"{reason}: {detail}" if detail else reason
245
+ status = response.status_code
246
+ if status == 401:
247
+ return ClawDBAuthError(message, request_id=request_id, details=body)
248
+ if status in (402, 403):
249
+ return ClawDBAccessDeniedError(message, resource=op, action="call", request_id=request_id, details=body)
250
+ if status == 404:
251
+ return ClawDBNotFoundError(message, entity_type=op, entity_id="unknown", request_id=request_id, details=body)
252
+ if status in (400, 409, 422):
253
+ return ClawDBValidationError(message, request_id=request_id, details=body)
254
+ if status == 429:
255
+ retry_after = response.headers.get("retry-after")
256
+ retry_ms = int(float(retry_after) * 1000) if retry_after and retry_after.replace(".", "", 1).isdigit() else 1000
257
+ return ClawDBRateLimitError(message, retry_after_ms=retry_ms, request_id=request_id, details=body)
258
+ if status in (502, 503):
259
+ return ClawDBUnavailableError(message, request_id=request_id, details=body)
260
+ if status in (408, 504):
261
+ return ClawDBTimeoutError(message, request_id=request_id, details=body)
262
+ return ClawDBInternalError(message, request_id=request_id, details=body)
263
+
264
+
265
+ def _is_session_rejection(response: httpx.Response) -> bool:
266
+ # The instance answers a bad session with {"error":"unauthorized"}; the
267
+ # gateway answers a bad API key with "invalid_api_key".
268
+ if response.status_code != 401:
269
+ return False
270
+ try:
271
+ return _as_dict(response.json()).get("error") == "unauthorized"
272
+ except ValueError:
273
+ return False
274
+
275
+
276
+ # ── Cores ────────────────────────────────────────────────────────────────────
277
+
278
+
279
+ class _CoreBase:
280
+ def __init__(self, endpoint: str, *, api_key: str = "", agent_id: str = "default-agent", role: str = "assistant", timeout_ms: int = 30000) -> None:
281
+ self.base_url = http_base_url(endpoint)
282
+ self.api_key = api_key or ""
283
+ self.agent_id = stable_agent_id(agent_id or "default-agent")
284
+ self.role = role or "assistant"
285
+ self.timeout = max(timeout_ms, 1) / 1000.0
286
+ self.session_id: str | None = None
287
+ self.token: str | None = None
288
+ self._expires_at: float | None = None
289
+
290
+ def _session_fresh(self) -> bool:
291
+ if not self.token:
292
+ return False
293
+ return self._expires_at is None or time.time() < self._expires_at - _RENEW_MARGIN_SECS
294
+
295
+ def _remember_session(self, body: dict[str, Any]) -> dict[str, Any]:
296
+ token = str(body.get("token") or "")
297
+ if not token:
298
+ raise ClawDBAuthError("Server returned a session without a token", details=body)
299
+ self.token = token
300
+ self.session_id = str(body.get("session_id") or body.get("id") or "") or None
301
+ expires = str(body.get("expires_at") or "")
302
+ try:
303
+ from datetime import datetime
304
+
305
+ self._expires_at = datetime.fromisoformat(expires.replace("Z", "+00:00")).timestamp()
306
+ except ValueError:
307
+ self._expires_at = None
308
+ return body
309
+
310
+ def forget_session(self) -> None:
311
+ self.token = None
312
+ self.session_id = None
313
+ self._expires_at = None
314
+
315
+ def _session_payload(self) -> dict[str, Any]:
316
+ return {"agent_id": self.agent_id, "role": self.role, "scopes": ["*"], "ttl_secs": SESSION_TTL_SECS}
317
+
318
+ def _request_kwargs(self, route: _Route, with_session: bool) -> dict[str, Any]:
319
+ headers = {"accept": "application/json", "x-agent-id": self.agent_id}
320
+ if self.api_key:
321
+ headers["authorization"] = f"Bearer {self.api_key}"
322
+ # x-claw-session, not Authorization: the gateway replaces Authorization
323
+ # (the API key) before forwarding to the instance.
324
+ if with_session and self.token:
325
+ headers["x-claw-session"] = self.token
326
+ kwargs: dict[str, Any] = {"headers": headers, "params": {k: v for k, v in route.params.items() if v is not None}}
327
+ if route.body is not None:
328
+ kwargs["json"] = route.body
329
+ return kwargs
330
+
331
+ @staticmethod
332
+ def _decode(response: httpx.Response, route: _Route) -> dict[str, Any]:
333
+ data: Any = None
334
+ if response.content:
335
+ try:
336
+ data = response.json()
337
+ except ValueError:
338
+ data = {"message": response.text}
339
+ result = route.reply(data) if route.reply else _as_dict(data)
340
+ request_id = response.headers.get("x-request-id")
341
+ if request_id and "request_id" not in result:
342
+ result = {**result, "request_id": request_id}
343
+ return result
344
+
345
+ @staticmethod
346
+ def _backoff(attempt: int) -> float:
347
+ return min(0.2 * (2**attempt), 2.0)
348
+
349
+
350
+ class HttpCore(_CoreBase):
351
+ """Synchronous HTTP core."""
352
+
353
+ def __init__(self, endpoint: str, **kwargs: Any) -> None:
354
+ super().__init__(endpoint, **kwargs)
355
+ self._client = httpx.Client(timeout=self.timeout)
356
+ self._lock = threading.Lock()
357
+
358
+ def close(self) -> None:
359
+ self._client.close()
360
+
361
+ def open_session(self) -> dict[str, Any]:
362
+ with self._lock:
363
+ return self._remember_session(self._send("CreateSession", self._session_payload(), with_session=False))
364
+
365
+ def _ensure_session(self) -> None:
366
+ if not self._session_fresh():
367
+ self.open_session()
368
+
369
+ def _send(self, op: str, payload: dict[str, Any], *, with_session: bool) -> dict[str, Any]:
370
+ route = _route(op, payload)
371
+ url = f"{self.base_url}{route.path}"
372
+ for attempt in range(_MAX_ATTEMPTS):
373
+ try:
374
+ response = self._client.request(route.method, url, **self._request_kwargs(route, with_session))
375
+ except httpx.TimeoutException as exc:
376
+ if attempt == _MAX_ATTEMPTS - 1:
377
+ raise ClawDBTimeoutError(f"{op} timed out", timeout_ms=int(self.timeout * 1000)) from exc
378
+ except httpx.HTTPError as exc:
379
+ if attempt == _MAX_ATTEMPTS - 1:
380
+ raise ClawDBUnavailableError(f"Could not reach {self.base_url}: {exc}") from exc
381
+ else:
382
+ if response.is_success:
383
+ return self._decode(response, route)
384
+ if with_session and _is_session_rejection(response):
385
+ raise _SessionRejected()
386
+ error = _error_for(response, op)
387
+ if isinstance(error, ClawDBRateLimitError) and attempt < _MAX_ATTEMPTS - 1:
388
+ time.sleep(error.retry_after_ms / 1000.0)
389
+ continue
390
+ if not isinstance(error, (ClawDBUnavailableError, ClawDBTimeoutError)) or attempt == _MAX_ATTEMPTS - 1:
391
+ raise error
392
+ time.sleep(self._backoff(attempt))
393
+ raise ClawDBUnavailableError(f"{op}: retries exhausted")
394
+
395
+ def call(self, op: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
396
+ payload = payload or {}
397
+ if op in ("Health", "CreateSession"):
398
+ return self._send(op, payload, with_session=False)
399
+ self._ensure_session()
400
+ try:
401
+ return self._send(op, payload, with_session=True)
402
+ except _SessionRejected:
403
+ # Expired or revoked: open a new session and retry once.
404
+ self.forget_session()
405
+ self._ensure_session()
406
+ try:
407
+ return self._send(op, payload, with_session=True)
408
+ except _SessionRejected:
409
+ raise ClawDBAuthError("The server rejected a freshly opened session") from None
410
+
411
+
412
+ class AsyncHttpCore(_CoreBase):
413
+ """Asynchronous HTTP core."""
414
+
415
+ def __init__(self, endpoint: str, **kwargs: Any) -> None:
416
+ super().__init__(endpoint, **kwargs)
417
+ self._client = httpx.AsyncClient(timeout=self.timeout)
418
+ self._lock = asyncio.Lock()
419
+
420
+ async def close(self) -> None:
421
+ await self._client.aclose()
422
+
423
+ async def open_session(self) -> dict[str, Any]:
424
+ async with self._lock:
425
+ return self._remember_session(await self._send("CreateSession", self._session_payload(), with_session=False))
426
+
427
+ async def _ensure_session(self) -> None:
428
+ if not self._session_fresh():
429
+ await self.open_session()
430
+
431
+ async def _send(self, op: str, payload: dict[str, Any], *, with_session: bool) -> dict[str, Any]:
432
+ route = _route(op, payload)
433
+ url = f"{self.base_url}{route.path}"
434
+ for attempt in range(_MAX_ATTEMPTS):
435
+ try:
436
+ response = await self._client.request(route.method, url, **self._request_kwargs(route, with_session))
437
+ except httpx.TimeoutException as exc:
438
+ if attempt == _MAX_ATTEMPTS - 1:
439
+ raise ClawDBTimeoutError(f"{op} timed out", timeout_ms=int(self.timeout * 1000)) from exc
440
+ except httpx.HTTPError as exc:
441
+ if attempt == _MAX_ATTEMPTS - 1:
442
+ raise ClawDBUnavailableError(f"Could not reach {self.base_url}: {exc}") from exc
443
+ else:
444
+ if response.is_success:
445
+ return self._decode(response, route)
446
+ if with_session and _is_session_rejection(response):
447
+ raise _SessionRejected()
448
+ error = _error_for(response, op)
449
+ if isinstance(error, ClawDBRateLimitError) and attempt < _MAX_ATTEMPTS - 1:
450
+ await asyncio.sleep(error.retry_after_ms / 1000.0)
451
+ continue
452
+ if not isinstance(error, (ClawDBUnavailableError, ClawDBTimeoutError)) or attempt == _MAX_ATTEMPTS - 1:
453
+ raise error
454
+ await asyncio.sleep(self._backoff(attempt))
455
+ raise ClawDBUnavailableError(f"{op}: retries exhausted")
456
+
457
+ async def call(self, op: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
458
+ payload = payload or {}
459
+ if op in ("Health", "CreateSession"):
460
+ return await self._send(op, payload, with_session=False)
461
+ await self._ensure_session()
462
+ try:
463
+ return await self._send(op, payload, with_session=True)
464
+ except _SessionRejected:
465
+ self.forget_session()
466
+ await self._ensure_session()
467
+ try:
468
+ return await self._send(op, payload, with_session=True)
469
+ except _SessionRejected:
470
+ raise ClawDBAuthError("The server rejected a freshly opened session") from None
471
+
472
+
473
+ def new_uuid() -> str:
474
+ return str(uuid.uuid4())