muninn-python 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.
- muninn/__init__.py +57 -0
- muninn/client.py +499 -0
- muninn/errors.py +47 -0
- muninn/langchain.py +184 -0
- muninn/sse.py +122 -0
- muninn/types.py +132 -0
- muninn_python-0.1.0.dist-info/METADATA +373 -0
- muninn_python-0.1.0.dist-info/RECORD +9 -0
- muninn_python-0.1.0.dist-info/WHEEL +4 -0
muninn/__init__.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""MuninnDB Python SDK - Async client for cognitive memory database."""
|
|
2
|
+
|
|
3
|
+
from .client import MuninnClient
|
|
4
|
+
from .errors import (
|
|
5
|
+
MuninnAuthError,
|
|
6
|
+
MuninnConflict,
|
|
7
|
+
MuninnConnectionError,
|
|
8
|
+
MuninnError,
|
|
9
|
+
MuninnNotFound,
|
|
10
|
+
MuninnServerError,
|
|
11
|
+
MuninnTimeoutError,
|
|
12
|
+
)
|
|
13
|
+
from .types import (
|
|
14
|
+
ActivateRequest,
|
|
15
|
+
ActivateResponse,
|
|
16
|
+
ActivationItem,
|
|
17
|
+
BriefSentence,
|
|
18
|
+
CoherenceResult,
|
|
19
|
+
Push,
|
|
20
|
+
ReadResponse,
|
|
21
|
+
StatResponse,
|
|
22
|
+
WriteRequest,
|
|
23
|
+
WriteResponse,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
__version__ = "0.1.0"
|
|
27
|
+
|
|
28
|
+
# LangChain integration — only imported if langchain-core is installed.
|
|
29
|
+
# Usage: from muninn.langchain import MuninnDBMemory
|
|
30
|
+
def __getattr__(name: str):
|
|
31
|
+
if name == "MuninnDBMemory":
|
|
32
|
+
from .langchain import MuninnDBMemory
|
|
33
|
+
return MuninnDBMemory
|
|
34
|
+
raise AttributeError(f"module 'muninn' has no attribute {name!r}")
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"MuninnClient",
|
|
38
|
+
"MuninnError",
|
|
39
|
+
"MuninnAuthError",
|
|
40
|
+
"MuninnConnectionError",
|
|
41
|
+
"MuninnNotFound",
|
|
42
|
+
"MuninnConflict",
|
|
43
|
+
"MuninnServerError",
|
|
44
|
+
"MuninnTimeoutError",
|
|
45
|
+
"WriteRequest",
|
|
46
|
+
"WriteResponse",
|
|
47
|
+
"ActivateRequest",
|
|
48
|
+
"ActivateResponse",
|
|
49
|
+
"ActivationItem",
|
|
50
|
+
"BriefSentence",
|
|
51
|
+
"ReadResponse",
|
|
52
|
+
"StatResponse",
|
|
53
|
+
"CoherenceResult",
|
|
54
|
+
"Push",
|
|
55
|
+
# Optional (requires langchain-core):
|
|
56
|
+
"MuninnDBMemory",
|
|
57
|
+
]
|
muninn/client.py
ADDED
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
"""Async MuninnDB client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import random
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from .errors import (
|
|
12
|
+
MuninnAuthError,
|
|
13
|
+
MuninnConnectionError,
|
|
14
|
+
MuninnConflict,
|
|
15
|
+
MuninnError,
|
|
16
|
+
MuninnNotFound,
|
|
17
|
+
MuninnServerError,
|
|
18
|
+
MuninnTimeoutError,
|
|
19
|
+
)
|
|
20
|
+
from .sse import SSEStream
|
|
21
|
+
from .types import (
|
|
22
|
+
ActivateResponse,
|
|
23
|
+
ActivationItem,
|
|
24
|
+
BriefSentence,
|
|
25
|
+
CoherenceResult,
|
|
26
|
+
ReadResponse,
|
|
27
|
+
StatResponse,
|
|
28
|
+
WriteResponse,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class MuninnClient:
|
|
33
|
+
"""Async client for MuninnDB REST API.
|
|
34
|
+
|
|
35
|
+
The client uses httpx for async HTTP and supports automatic retry with
|
|
36
|
+
exponential backoff for transient failures.
|
|
37
|
+
|
|
38
|
+
Usage:
|
|
39
|
+
async with MuninnClient("http://localhost:8476") as client:
|
|
40
|
+
eng_id = await client.write(
|
|
41
|
+
vault="default",
|
|
42
|
+
concept="memory concept",
|
|
43
|
+
content="memory content"
|
|
44
|
+
)
|
|
45
|
+
results = await client.activate(
|
|
46
|
+
vault="default",
|
|
47
|
+
context=["search query"]
|
|
48
|
+
)
|
|
49
|
+
async for push in client.subscribe(vault="default"):
|
|
50
|
+
print(f"New engram: {push.engram_id}")
|
|
51
|
+
break
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
base_url: Base URL of MuninnDB server (default: http://localhost:8476)
|
|
55
|
+
token: Optional Bearer token for authentication
|
|
56
|
+
timeout: Request timeout in seconds (default: 5.0)
|
|
57
|
+
max_retries: Maximum retry attempts for transient errors (default: 3)
|
|
58
|
+
retry_backoff: Initial backoff multiplier for retries (default: 0.5)
|
|
59
|
+
max_connections: Max concurrent connections (default: 20)
|
|
60
|
+
keepalive_connections: Max keepalive connections (default: 10)
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
base_url: str = "http://localhost:8476",
|
|
66
|
+
token: str | None = None,
|
|
67
|
+
timeout: float = 5.0,
|
|
68
|
+
max_retries: int = 3,
|
|
69
|
+
retry_backoff: float = 0.5,
|
|
70
|
+
max_connections: int = 20,
|
|
71
|
+
keepalive_connections: int = 10,
|
|
72
|
+
):
|
|
73
|
+
self._base_url = base_url.rstrip("/")
|
|
74
|
+
self._token = token
|
|
75
|
+
self._timeout = timeout
|
|
76
|
+
self._max_retries = max_retries
|
|
77
|
+
self._retry_backoff = retry_backoff
|
|
78
|
+
self._max_connections = max_connections
|
|
79
|
+
self._keepalive_connections = keepalive_connections
|
|
80
|
+
self._http: httpx.AsyncClient | None = None
|
|
81
|
+
|
|
82
|
+
async def __aenter__(self):
|
|
83
|
+
"""Enter async context."""
|
|
84
|
+
self._http = httpx.AsyncClient(
|
|
85
|
+
base_url=self._base_url,
|
|
86
|
+
timeout=self._timeout,
|
|
87
|
+
limits=httpx.Limits(
|
|
88
|
+
max_connections=self._max_connections,
|
|
89
|
+
max_keepalive_connections=self._keepalive_connections,
|
|
90
|
+
),
|
|
91
|
+
headers=self._default_headers(),
|
|
92
|
+
)
|
|
93
|
+
return self
|
|
94
|
+
|
|
95
|
+
async def __aexit__(self, *args):
|
|
96
|
+
"""Exit async context."""
|
|
97
|
+
if self._http:
|
|
98
|
+
await self._http.aclose()
|
|
99
|
+
|
|
100
|
+
async def write(
|
|
101
|
+
self,
|
|
102
|
+
vault: str = "default",
|
|
103
|
+
concept: str = "",
|
|
104
|
+
content: str = "",
|
|
105
|
+
tags: list[str] | None = None,
|
|
106
|
+
confidence: float = 0.9,
|
|
107
|
+
stability: float = 0.5,
|
|
108
|
+
) -> str:
|
|
109
|
+
"""Write an engram to the database.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
vault: Vault name (default: "default")
|
|
113
|
+
concept: Concept/title for this engram
|
|
114
|
+
content: Main content/body
|
|
115
|
+
tags: Optional list of tags for categorization
|
|
116
|
+
confidence: Confidence score 0-1 (default: 0.9)
|
|
117
|
+
stability: Stability score 0-1 (default: 0.5)
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
ULID string ID of the created engram
|
|
121
|
+
|
|
122
|
+
Raises:
|
|
123
|
+
MuninnError: If write fails
|
|
124
|
+
"""
|
|
125
|
+
body = {
|
|
126
|
+
"vault": vault,
|
|
127
|
+
"concept": concept,
|
|
128
|
+
"content": content,
|
|
129
|
+
"confidence": confidence,
|
|
130
|
+
"stability": stability,
|
|
131
|
+
}
|
|
132
|
+
if tags:
|
|
133
|
+
body["tags"] = tags
|
|
134
|
+
|
|
135
|
+
response = await self._request("POST", "/api/engrams", json=body)
|
|
136
|
+
return response.get("id", "")
|
|
137
|
+
|
|
138
|
+
async def activate(
|
|
139
|
+
self,
|
|
140
|
+
vault: str = "default",
|
|
141
|
+
context: list[str] | None = None,
|
|
142
|
+
max_results: int = 10,
|
|
143
|
+
threshold: float = 0.1,
|
|
144
|
+
max_hops: int = 0,
|
|
145
|
+
include_why: bool = False,
|
|
146
|
+
brief_mode: str = "auto",
|
|
147
|
+
) -> ActivateResponse:
|
|
148
|
+
"""Activate memory using semantic search and graph traversal.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
vault: Vault name (default: "default")
|
|
152
|
+
context: List of query terms/context
|
|
153
|
+
max_results: Max results to return (default: 10)
|
|
154
|
+
threshold: Min activation score threshold (default: 0.1)
|
|
155
|
+
max_hops: Max graph hops to traverse (default: 0)
|
|
156
|
+
include_why: Include reasoning/why field (default: False)
|
|
157
|
+
brief_mode: Brief extraction mode - "auto", "extractive", "abstractive" (default: "auto")
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
ActivateResponse with activations and optional brief
|
|
161
|
+
|
|
162
|
+
Raises:
|
|
163
|
+
MuninnError: If activation fails
|
|
164
|
+
"""
|
|
165
|
+
if context is None:
|
|
166
|
+
context = []
|
|
167
|
+
|
|
168
|
+
body = {
|
|
169
|
+
"vault": vault,
|
|
170
|
+
"context": context,
|
|
171
|
+
"max_results": max_results,
|
|
172
|
+
"threshold": threshold,
|
|
173
|
+
"max_hops": max_hops,
|
|
174
|
+
"include_why": include_why,
|
|
175
|
+
"brief_mode": brief_mode,
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
response = await self._request("POST", "/api/activate", json=body)
|
|
179
|
+
|
|
180
|
+
# Support both snake_case (new) and PascalCase (old) field names for
|
|
181
|
+
# backward compatibility with servers that have not yet been updated.
|
|
182
|
+
raw_activations = response.get("activations") or response.get("Activations") or []
|
|
183
|
+
activations = [
|
|
184
|
+
ActivationItem(
|
|
185
|
+
id=item.get("id") or item.get("ID", ""),
|
|
186
|
+
concept=item.get("concept") or item.get("Concept", ""),
|
|
187
|
+
content=item.get("content") or item.get("Content", ""),
|
|
188
|
+
score=item.get("score") or item.get("Score", 0.0),
|
|
189
|
+
confidence=item.get("confidence") or item.get("Confidence", 0.0),
|
|
190
|
+
why=item.get("why") or item.get("Why"),
|
|
191
|
+
hop_path=item.get("hop_path") or item.get("HopPath"),
|
|
192
|
+
dormant=item.get("dormant") or item.get("Dormant", False),
|
|
193
|
+
)
|
|
194
|
+
for item in raw_activations
|
|
195
|
+
]
|
|
196
|
+
|
|
197
|
+
brief = None
|
|
198
|
+
raw_brief = response.get("brief") or response.get("Brief")
|
|
199
|
+
if raw_brief:
|
|
200
|
+
brief = [
|
|
201
|
+
BriefSentence(
|
|
202
|
+
engram_id=sent.get("engram_id") or sent.get("EngramID", ""),
|
|
203
|
+
text=sent.get("text") or sent.get("Text", ""),
|
|
204
|
+
score=sent.get("score") or sent.get("Score", 0.0),
|
|
205
|
+
)
|
|
206
|
+
for sent in raw_brief
|
|
207
|
+
]
|
|
208
|
+
|
|
209
|
+
return ActivateResponse(
|
|
210
|
+
query_id=response.get("query_id") or response.get("QueryID", ""),
|
|
211
|
+
total_found=response.get("total_found") or response.get("TotalFound", 0),
|
|
212
|
+
activations=activations,
|
|
213
|
+
latency_ms=response.get("latency_ms") or response.get("LatencyMs", 0.0),
|
|
214
|
+
brief=brief,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
async def read(self, id: str, vault: str = "default") -> ReadResponse:
|
|
218
|
+
"""Read a specific engram by ID.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
id: Engram ULID
|
|
222
|
+
vault: Vault name (default: "default")
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
ReadResponse with engram details
|
|
226
|
+
|
|
227
|
+
Raises:
|
|
228
|
+
MuninnNotFound: If engram doesn't exist
|
|
229
|
+
MuninnError: If read fails
|
|
230
|
+
"""
|
|
231
|
+
response = await self._request("GET", f"/api/engrams/{id}", params={"vault": vault})
|
|
232
|
+
|
|
233
|
+
coherence = response.get("coherence")
|
|
234
|
+
return ReadResponse(
|
|
235
|
+
id=response.get("id", ""),
|
|
236
|
+
concept=response.get("concept", ""),
|
|
237
|
+
content=response.get("content", ""),
|
|
238
|
+
confidence=response.get("confidence", 0.0),
|
|
239
|
+
relevance=response.get("relevance", 0.0),
|
|
240
|
+
stability=response.get("stability", 0.0),
|
|
241
|
+
access_count=response.get("access_count", 0),
|
|
242
|
+
tags=response.get("tags", []),
|
|
243
|
+
state=response.get("state", ""),
|
|
244
|
+
created_at=response.get("created_at", 0),
|
|
245
|
+
updated_at=response.get("updated_at", 0),
|
|
246
|
+
last_access=response.get("last_access"),
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
async def forget(self, id: str, vault: str = "default", hard: bool = False) -> bool:
|
|
250
|
+
"""Delete an engram (soft or hard delete).
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
id: Engram ULID
|
|
254
|
+
vault: Vault name (default: "default")
|
|
255
|
+
hard: If True, hard delete (cannot recover). If False, soft delete (default: False)
|
|
256
|
+
|
|
257
|
+
Returns:
|
|
258
|
+
True if deletion successful
|
|
259
|
+
|
|
260
|
+
Raises:
|
|
261
|
+
MuninnNotFound: If engram doesn't exist
|
|
262
|
+
MuninnError: If deletion fails
|
|
263
|
+
"""
|
|
264
|
+
if hard:
|
|
265
|
+
await self._request(
|
|
266
|
+
"POST",
|
|
267
|
+
f"/api/engrams/{id}/forget",
|
|
268
|
+
params={"vault": vault, "hard": "true"},
|
|
269
|
+
)
|
|
270
|
+
else:
|
|
271
|
+
await self._request(
|
|
272
|
+
"DELETE",
|
|
273
|
+
f"/api/engrams/{id}",
|
|
274
|
+
params={"vault": vault},
|
|
275
|
+
)
|
|
276
|
+
return True
|
|
277
|
+
|
|
278
|
+
async def link(
|
|
279
|
+
self,
|
|
280
|
+
source_id: str,
|
|
281
|
+
target_id: str,
|
|
282
|
+
vault: str = "default",
|
|
283
|
+
rel_type: int = 5,
|
|
284
|
+
weight: float = 1.0,
|
|
285
|
+
) -> bool:
|
|
286
|
+
"""Create an association/link between two engrams.
|
|
287
|
+
|
|
288
|
+
Args:
|
|
289
|
+
source_id: Source engram ULID
|
|
290
|
+
target_id: Target engram ULID
|
|
291
|
+
vault: Vault name (default: "default")
|
|
292
|
+
rel_type: Relationship type code (default: 5)
|
|
293
|
+
weight: Link weight/strength (default: 1.0)
|
|
294
|
+
|
|
295
|
+
Returns:
|
|
296
|
+
True if link created successfully
|
|
297
|
+
|
|
298
|
+
Raises:
|
|
299
|
+
MuninnError: If link creation fails
|
|
300
|
+
"""
|
|
301
|
+
body = {
|
|
302
|
+
"vault": vault,
|
|
303
|
+
"source_id": source_id,
|
|
304
|
+
"target_id": target_id,
|
|
305
|
+
"rel_type": rel_type,
|
|
306
|
+
"weight": weight,
|
|
307
|
+
}
|
|
308
|
+
await self._request("POST", "/api/link", json=body)
|
|
309
|
+
return True
|
|
310
|
+
|
|
311
|
+
async def stats(self) -> StatResponse:
|
|
312
|
+
"""Get database statistics including coherence scores.
|
|
313
|
+
|
|
314
|
+
Returns:
|
|
315
|
+
StatResponse with engram count, vault count, storage bytes, and coherence
|
|
316
|
+
|
|
317
|
+
Raises:
|
|
318
|
+
MuninnError: If stats request fails
|
|
319
|
+
"""
|
|
320
|
+
response = await self._request("GET", "/api/stats")
|
|
321
|
+
|
|
322
|
+
coherence = None
|
|
323
|
+
if response.get("coherence"):
|
|
324
|
+
coherence = {
|
|
325
|
+
vault_name: CoherenceResult(
|
|
326
|
+
score=data.get("score", 0.0),
|
|
327
|
+
orphan_ratio=data.get("orphan_ratio", 0.0),
|
|
328
|
+
contradiction_density=data.get("contradiction_density", 0.0),
|
|
329
|
+
duplication_pressure=data.get("duplication_pressure", 0.0),
|
|
330
|
+
decay_variance=data.get("decay_variance", 0.0),
|
|
331
|
+
total_engrams=data.get("total_engrams", 0),
|
|
332
|
+
)
|
|
333
|
+
for vault_name, data in response["coherence"].items()
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return StatResponse(
|
|
337
|
+
engram_count=response.get("engram_count", 0),
|
|
338
|
+
vault_count=response.get("vault_count", 0),
|
|
339
|
+
storage_bytes=response.get("storage_bytes", 0),
|
|
340
|
+
coherence=coherence,
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
def subscribe(
|
|
344
|
+
self,
|
|
345
|
+
vault: str = "default",
|
|
346
|
+
push_on_write: bool = True,
|
|
347
|
+
threshold: float = 0.0,
|
|
348
|
+
) -> SSEStream:
|
|
349
|
+
"""Subscribe to vault events via Server-Sent Events (SSE).
|
|
350
|
+
|
|
351
|
+
This returns an async iterable that yields Push events when engrams are
|
|
352
|
+
written to the vault. The stream automatically reconnects on network errors.
|
|
353
|
+
|
|
354
|
+
Usage:
|
|
355
|
+
stream = client.subscribe(vault="default")
|
|
356
|
+
async for push in stream:
|
|
357
|
+
print(f"New engram: {push.engram_id}")
|
|
358
|
+
if condition:
|
|
359
|
+
await stream.close()
|
|
360
|
+
|
|
361
|
+
Args:
|
|
362
|
+
vault: Vault to subscribe to (default: "default")
|
|
363
|
+
push_on_write: Emit push events on new writes (default: True)
|
|
364
|
+
threshold: Min activation threshold for push events (default: 0.0)
|
|
365
|
+
|
|
366
|
+
Returns:
|
|
367
|
+
SSEStream async iterable
|
|
368
|
+
|
|
369
|
+
Raises:
|
|
370
|
+
MuninnError: If subscription fails
|
|
371
|
+
"""
|
|
372
|
+
params = {
|
|
373
|
+
"vault": vault,
|
|
374
|
+
"push_on_write": str(push_on_write).lower(),
|
|
375
|
+
}
|
|
376
|
+
if threshold:
|
|
377
|
+
params["threshold"] = str(threshold)
|
|
378
|
+
|
|
379
|
+
return SSEStream(self, "/api/subscribe", params)
|
|
380
|
+
|
|
381
|
+
async def health(self) -> bool:
|
|
382
|
+
"""Check if MuninnDB server is healthy.
|
|
383
|
+
|
|
384
|
+
Returns:
|
|
385
|
+
True if server responds with 200 OK
|
|
386
|
+
|
|
387
|
+
Raises:
|
|
388
|
+
MuninnError: If health check fails
|
|
389
|
+
"""
|
|
390
|
+
try:
|
|
391
|
+
response = await self._request("GET", "/health")
|
|
392
|
+
return response.get("status") == "ok"
|
|
393
|
+
except MuninnError:
|
|
394
|
+
return False
|
|
395
|
+
|
|
396
|
+
async def _request(self, method: str, path: str, **kwargs) -> dict:
|
|
397
|
+
"""Make an HTTP request with automatic retry logic.
|
|
398
|
+
|
|
399
|
+
Retries on transient errors (502, 503, 504, connection/read errors).
|
|
400
|
+
Does not retry on 4xx errors. Uses exponential backoff with jitter.
|
|
401
|
+
|
|
402
|
+
Args:
|
|
403
|
+
method: HTTP method (GET, POST, DELETE, etc)
|
|
404
|
+
path: URL path relative to base_url
|
|
405
|
+
**kwargs: Additional arguments to pass to httpx
|
|
406
|
+
|
|
407
|
+
Returns:
|
|
408
|
+
Parsed JSON response as dict
|
|
409
|
+
|
|
410
|
+
Raises:
|
|
411
|
+
MuninnAuthError: 401 Unauthorized
|
|
412
|
+
MuninnNotFound: 404 Not Found
|
|
413
|
+
MuninnConflict: 409 Conflict
|
|
414
|
+
MuninnServerError: 5xx errors
|
|
415
|
+
MuninnTimeoutError: Request timeout
|
|
416
|
+
MuninnConnectionError: Connection error
|
|
417
|
+
MuninnError: Other HTTP errors
|
|
418
|
+
"""
|
|
419
|
+
if not self._http:
|
|
420
|
+
raise MuninnError("Client not initialized. Use 'async with' context manager.")
|
|
421
|
+
|
|
422
|
+
attempt = 0
|
|
423
|
+
while attempt <= self._max_retries:
|
|
424
|
+
try:
|
|
425
|
+
response = await self._http.request(method, path, **kwargs)
|
|
426
|
+
self._raise_for_status(response)
|
|
427
|
+
return response.json()
|
|
428
|
+
|
|
429
|
+
except (httpx.ConnectError, httpx.ReadError, httpx.RemoteProtocolError) as e:
|
|
430
|
+
if attempt >= self._max_retries:
|
|
431
|
+
raise MuninnConnectionError(f"Connection failed: {str(e)}")
|
|
432
|
+
await self._backoff(attempt)
|
|
433
|
+
attempt += 1
|
|
434
|
+
|
|
435
|
+
except httpx.ReadTimeout as e:
|
|
436
|
+
if attempt >= self._max_retries:
|
|
437
|
+
raise MuninnTimeoutError(f"Request timeout: {str(e)}")
|
|
438
|
+
await self._backoff(attempt)
|
|
439
|
+
attempt += 1
|
|
440
|
+
|
|
441
|
+
except httpx.HTTPStatusError as e:
|
|
442
|
+
# Don't retry on 4xx (except certain ones), do retry on 5xx
|
|
443
|
+
if 500 <= e.response.status_code < 600:
|
|
444
|
+
if attempt >= self._max_retries:
|
|
445
|
+
self._raise_for_status(e.response)
|
|
446
|
+
await self._backoff(attempt)
|
|
447
|
+
attempt += 1
|
|
448
|
+
else:
|
|
449
|
+
self._raise_for_status(e.response)
|
|
450
|
+
|
|
451
|
+
except MuninnError:
|
|
452
|
+
raise
|
|
453
|
+
|
|
454
|
+
raise MuninnError("Max retries exceeded")
|
|
455
|
+
|
|
456
|
+
async def _backoff(self, attempt: int):
|
|
457
|
+
"""Wait with exponential backoff + jitter.
|
|
458
|
+
|
|
459
|
+
Args:
|
|
460
|
+
attempt: Attempt number (0-indexed)
|
|
461
|
+
"""
|
|
462
|
+
delay = self._retry_backoff * (2 ** attempt) + random.uniform(0, 0.1)
|
|
463
|
+
await asyncio.sleep(delay)
|
|
464
|
+
|
|
465
|
+
def _default_headers(self) -> dict:
|
|
466
|
+
"""Build default request headers."""
|
|
467
|
+
headers = {"Content-Type": "application/json"}
|
|
468
|
+
if self._token:
|
|
469
|
+
headers["Authorization"] = f"Bearer {self._token}"
|
|
470
|
+
return headers
|
|
471
|
+
|
|
472
|
+
def _raise_for_status(self, response: httpx.Response):
|
|
473
|
+
"""Convert httpx response to appropriate MuninnError.
|
|
474
|
+
|
|
475
|
+
Args:
|
|
476
|
+
response: httpx Response object
|
|
477
|
+
|
|
478
|
+
Raises:
|
|
479
|
+
Appropriate MuninnError subclass
|
|
480
|
+
"""
|
|
481
|
+
if response.status_code == 401:
|
|
482
|
+
raise MuninnAuthError(
|
|
483
|
+
"Authentication required. Provide token= parameter to MuninnClient.",
|
|
484
|
+
401,
|
|
485
|
+
)
|
|
486
|
+
elif response.status_code == 404:
|
|
487
|
+
raise MuninnNotFound(f"Not found: {response.text}", 404)
|
|
488
|
+
elif response.status_code == 409:
|
|
489
|
+
raise MuninnConflict(f"Conflict: {response.text}", 409)
|
|
490
|
+
elif 500 <= response.status_code < 600:
|
|
491
|
+
raise MuninnServerError(
|
|
492
|
+
f"Server error {response.status_code}: {response.text}",
|
|
493
|
+
response.status_code,
|
|
494
|
+
)
|
|
495
|
+
elif response.status_code >= 400:
|
|
496
|
+
raise MuninnError(
|
|
497
|
+
f"Client error {response.status_code}: {response.text}",
|
|
498
|
+
response.status_code,
|
|
499
|
+
)
|
muninn/errors.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""MuninnDB error types."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class MuninnError(Exception):
|
|
7
|
+
"""Base exception for all MuninnDB errors."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, message: str, status_code: Optional[int] = None):
|
|
10
|
+
super().__init__(message)
|
|
11
|
+
self.status_code = status_code
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class MuninnConnectionError(MuninnError):
|
|
15
|
+
"""Connection-related errors (network, SSL, DNS)."""
|
|
16
|
+
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class MuninnAuthError(MuninnError):
|
|
21
|
+
"""Authentication failed (401 Unauthorized)."""
|
|
22
|
+
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class MuninnNotFound(MuninnError):
|
|
27
|
+
"""Resource not found (404 Not Found)."""
|
|
28
|
+
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class MuninnConflict(MuninnError):
|
|
33
|
+
"""Request conflict (409 Conflict)."""
|
|
34
|
+
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class MuninnServerError(MuninnError):
|
|
39
|
+
"""Server error (5xx)."""
|
|
40
|
+
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class MuninnTimeoutError(MuninnError):
|
|
45
|
+
"""Request timeout."""
|
|
46
|
+
|
|
47
|
+
pass
|