memoryintelligence 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.
- memoryintelligence/__init__.py +71 -0
- memoryintelligence/_auth.py +316 -0
- memoryintelligence/_errors.py +232 -0
- memoryintelligence/_http.py +419 -0
- memoryintelligence/_simple.py +301 -0
- memoryintelligence/_version.py +3 -0
- memoryintelligence/py.typed +2 -0
- memoryintelligence-0.1.0.dist-info/METADATA +220 -0
- memoryintelligence-0.1.0.dist-info/RECORD +12 -0
- memoryintelligence-0.1.0.dist-info/WHEEL +5 -0
- memoryintelligence-0.1.0.dist-info/licenses/LICENSE +31 -0
- memoryintelligence-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
"""Memory Intelligence SDK - HTTP transport.
|
|
2
|
+
|
|
3
|
+
Synchronous and asynchronous HTTP transport with retry logic.
|
|
4
|
+
This is the only module that touches httpx.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import random
|
|
11
|
+
import time
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
from ulid import ULID
|
|
16
|
+
|
|
17
|
+
from ._errors import (
|
|
18
|
+
ConnectionError,
|
|
19
|
+
HTTP_STATUS_EXCEPTIONS,
|
|
20
|
+
RateLimitError,
|
|
21
|
+
ServerError,
|
|
22
|
+
TimeoutError,
|
|
23
|
+
get_exception_for_status,
|
|
24
|
+
)
|
|
25
|
+
from ._version import __version__
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger("memoryintelligence")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class _BaseTransport:
|
|
31
|
+
"""Base class with common transport logic."""
|
|
32
|
+
|
|
33
|
+
# Status codes that trigger retry
|
|
34
|
+
RETRY_STATUSES = {408, 429, 500, 502, 503, 504}
|
|
35
|
+
# Status codes that do NOT retry
|
|
36
|
+
NO_RETRY_STATUSES = {400, 401, 402, 403, 404, 409, 422}
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
api_key: str,
|
|
41
|
+
base_url: str,
|
|
42
|
+
timeout: float = 30.0,
|
|
43
|
+
max_retries: int = 3,
|
|
44
|
+
device_id: str | None = None,
|
|
45
|
+
actor_type: str | None = None,
|
|
46
|
+
):
|
|
47
|
+
self.api_key = api_key
|
|
48
|
+
self.base_url = base_url.rstrip("/")
|
|
49
|
+
self.timeout = timeout
|
|
50
|
+
self.max_retries = max_retries
|
|
51
|
+
self.device_id = device_id
|
|
52
|
+
self.actor_type = actor_type
|
|
53
|
+
|
|
54
|
+
def _get_headers(self) -> dict[str, str]:
|
|
55
|
+
"""Get headers required for every request.
|
|
56
|
+
|
|
57
|
+
Includes X-MI-Device-ID and X-MI-Actor-Type when set on the
|
|
58
|
+
transport. The server uses these for zero-friction provenance
|
|
59
|
+
inference — if present they override the server's ambient signal
|
|
60
|
+
chain (User-Agent, IP fingerprint, etc.).
|
|
61
|
+
"""
|
|
62
|
+
headers = {
|
|
63
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
64
|
+
"X-MI-SDK-Version": __version__,
|
|
65
|
+
"Content-Type": "application/json",
|
|
66
|
+
"X-MI-Encrypted": "true",
|
|
67
|
+
"Accept": "application/json",
|
|
68
|
+
}
|
|
69
|
+
if self.device_id:
|
|
70
|
+
headers["X-MI-Device-ID"] = self.device_id
|
|
71
|
+
if self.actor_type:
|
|
72
|
+
headers["X-MI-Actor-Type"] = self.actor_type
|
|
73
|
+
return headers
|
|
74
|
+
|
|
75
|
+
def _generate_request_id(self) -> str:
|
|
76
|
+
"""Generate a fresh ULID for request tracking."""
|
|
77
|
+
return str(ULID())
|
|
78
|
+
|
|
79
|
+
def _calculate_backoff(self, attempt: int, retry_after: int | None = None) -> float:
|
|
80
|
+
"""
|
|
81
|
+
Calculate backoff delay with exponential backoff and jitter.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
attempt: Current retry attempt (0-indexed)
|
|
85
|
+
retry_after: Optional Retry-After header value (seconds)
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
Delay in seconds
|
|
89
|
+
"""
|
|
90
|
+
if retry_after is not None and retry_after > 0:
|
|
91
|
+
return retry_after
|
|
92
|
+
|
|
93
|
+
# Exponential backoff: min(0.5 * 2^attempt, 30)
|
|
94
|
+
delay = min(0.5 * (2 ** attempt), 30.0)
|
|
95
|
+
# Add ±25% jitter
|
|
96
|
+
jitter = delay * 0.25 * (2 * random.random() - 1)
|
|
97
|
+
return delay + jitter
|
|
98
|
+
|
|
99
|
+
def _should_retry(self, status_code: int) -> bool:
|
|
100
|
+
"""Determine if a request should be retried based on status code."""
|
|
101
|
+
if status_code in self.NO_RETRY_STATUSES:
|
|
102
|
+
return False
|
|
103
|
+
if status_code in self.RETRY_STATUSES:
|
|
104
|
+
return True
|
|
105
|
+
# Default: don't retry unknown status codes
|
|
106
|
+
return False
|
|
107
|
+
|
|
108
|
+
def _raise_for_status(
|
|
109
|
+
self,
|
|
110
|
+
status_code: int,
|
|
111
|
+
response_body: dict[str, Any] | None,
|
|
112
|
+
request_id: str,
|
|
113
|
+
retry_after: int | None = None,
|
|
114
|
+
) -> None:
|
|
115
|
+
"""
|
|
116
|
+
Raise appropriate exception for HTTP status code.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
status_code: HTTP status code
|
|
120
|
+
response_body: Parsed JSON response body
|
|
121
|
+
request_id: Request ID from X-MI-Request-ID header
|
|
122
|
+
retry_after: Retry-After header value (for rate limiting)
|
|
123
|
+
"""
|
|
124
|
+
if 200 <= status_code < 300:
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
exc_class = get_exception_for_status(status_code)
|
|
128
|
+
message = f"HTTP {status_code}"
|
|
129
|
+
|
|
130
|
+
# Extract error message from response body if available
|
|
131
|
+
if response_body:
|
|
132
|
+
if "error" in response_body:
|
|
133
|
+
message = response_body["error"]
|
|
134
|
+
elif "message" in response_body:
|
|
135
|
+
message = response_body["message"]
|
|
136
|
+
elif "detail" in response_body:
|
|
137
|
+
message = response_body["detail"]
|
|
138
|
+
|
|
139
|
+
# Build exception with appropriate attributes
|
|
140
|
+
if exc_class == RateLimitError:
|
|
141
|
+
raise RateLimitError(message, retry_after=retry_after or 60)
|
|
142
|
+
elif exc_class == ServerError:
|
|
143
|
+
raise ServerError(message, request_id=request_id)
|
|
144
|
+
elif exc_class.__name__ == "ValidationError":
|
|
145
|
+
field = response_body.get("field", "") if response_body else ""
|
|
146
|
+
from ._errors import ValidationError
|
|
147
|
+
raise ValidationError(message, field=field)
|
|
148
|
+
elif exc_class.__name__ == "PIIViolationError":
|
|
149
|
+
detected_types = response_body.get("detected_types", []) if response_body else []
|
|
150
|
+
from ._errors import PIIViolationError
|
|
151
|
+
raise PIIViolationError(message, detected_types=detected_types)
|
|
152
|
+
else:
|
|
153
|
+
raise exc_class(message)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class SyncTransport(_BaseTransport):
|
|
157
|
+
"""Synchronous HTTP transport."""
|
|
158
|
+
|
|
159
|
+
def __init__(
|
|
160
|
+
self,
|
|
161
|
+
api_key: str,
|
|
162
|
+
base_url: str,
|
|
163
|
+
timeout: float = 30.0,
|
|
164
|
+
max_retries: int = 3,
|
|
165
|
+
device_id: str | None = None,
|
|
166
|
+
actor_type: str | None = None,
|
|
167
|
+
):
|
|
168
|
+
super().__init__(api_key, base_url, timeout, max_retries, device_id, actor_type)
|
|
169
|
+
self._client = httpx.Client(
|
|
170
|
+
base_url=self.base_url,
|
|
171
|
+
timeout=self.timeout,
|
|
172
|
+
headers=self._get_headers(),
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
def request(
|
|
176
|
+
self,
|
|
177
|
+
method: str,
|
|
178
|
+
path: str,
|
|
179
|
+
**kwargs,
|
|
180
|
+
) -> dict[str, Any]:
|
|
181
|
+
"""
|
|
182
|
+
Make HTTP request with retry logic.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
method: HTTP method (GET, POST, etc.)
|
|
186
|
+
path: Request path (without base URL)
|
|
187
|
+
**kwargs: Additional arguments for httpx
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
Parsed JSON response body
|
|
191
|
+
|
|
192
|
+
Raises:
|
|
193
|
+
Various MIError subclasses based on HTTP status
|
|
194
|
+
"""
|
|
195
|
+
request_id = self._generate_request_id()
|
|
196
|
+
headers = kwargs.pop("headers", {})
|
|
197
|
+
headers["X-MI-Request-ID"] = request_id
|
|
198
|
+
|
|
199
|
+
last_exception = None
|
|
200
|
+
|
|
201
|
+
for attempt in range(self.max_retries + 1):
|
|
202
|
+
try:
|
|
203
|
+
response = self._client.request(
|
|
204
|
+
method,
|
|
205
|
+
path,
|
|
206
|
+
headers=headers,
|
|
207
|
+
**kwargs,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
# Get retry-after header if present
|
|
211
|
+
retry_after = None
|
|
212
|
+
if "retry-after" in response.headers:
|
|
213
|
+
try:
|
|
214
|
+
retry_after = int(response.headers["retry-after"])
|
|
215
|
+
except ValueError:
|
|
216
|
+
pass
|
|
217
|
+
|
|
218
|
+
# Get request ID from response if server returned it
|
|
219
|
+
response_request_id = response.headers.get("x-mi-request-id", request_id)
|
|
220
|
+
|
|
221
|
+
# Check if we should retry
|
|
222
|
+
if response.status_code in self.RETRY_STATUSES and attempt < self.max_retries:
|
|
223
|
+
if self._should_retry(response.status_code):
|
|
224
|
+
delay = self._calculate_backoff(attempt, retry_after)
|
|
225
|
+
logger.debug(
|
|
226
|
+
f"Retrying request (attempt {attempt + 1}/{self.max_retries}) "
|
|
227
|
+
f"after {delay:.1f}s - status {response.status_code}"
|
|
228
|
+
)
|
|
229
|
+
time.sleep(delay)
|
|
230
|
+
continue
|
|
231
|
+
|
|
232
|
+
# Parse response body
|
|
233
|
+
response_body = None
|
|
234
|
+
try:
|
|
235
|
+
if response.content:
|
|
236
|
+
response_body = response.json()
|
|
237
|
+
except Exception:
|
|
238
|
+
response_body = {"message": response.text} if response.text else None
|
|
239
|
+
|
|
240
|
+
# Raise appropriate exception for error status codes
|
|
241
|
+
self._raise_for_status(
|
|
242
|
+
response.status_code,
|
|
243
|
+
response_body,
|
|
244
|
+
response_request_id,
|
|
245
|
+
retry_after,
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
# Success - return response body
|
|
249
|
+
return response_body or {}
|
|
250
|
+
|
|
251
|
+
except httpx.TimeoutException as e:
|
|
252
|
+
last_exception = TimeoutError(f"Request timed out: {e}")
|
|
253
|
+
if attempt < self.max_retries:
|
|
254
|
+
delay = self._calculate_backoff(attempt)
|
|
255
|
+
logger.debug(
|
|
256
|
+
f"Retrying request (attempt {attempt + 1}/{self.max_retries}) "
|
|
257
|
+
f"after {delay:.1f}s - timeout"
|
|
258
|
+
)
|
|
259
|
+
time.sleep(delay)
|
|
260
|
+
else:
|
|
261
|
+
raise last_exception
|
|
262
|
+
|
|
263
|
+
except httpx.ConnectError as e:
|
|
264
|
+
raise ConnectionError(f"Could not connect to API: {e}") from e
|
|
265
|
+
|
|
266
|
+
except httpx.RequestError as e:
|
|
267
|
+
last_exception = ConnectionError(f"Request failed: {e}")
|
|
268
|
+
if attempt < self.max_retries:
|
|
269
|
+
delay = self._calculate_backoff(attempt)
|
|
270
|
+
logger.debug(
|
|
271
|
+
f"Retrying request (attempt {attempt + 1}/{self.max_retries}) "
|
|
272
|
+
f"after {delay:.1f}s - connection error"
|
|
273
|
+
)
|
|
274
|
+
time.sleep(delay)
|
|
275
|
+
else:
|
|
276
|
+
raise last_exception
|
|
277
|
+
|
|
278
|
+
# Should not reach here, but just in case
|
|
279
|
+
if last_exception:
|
|
280
|
+
raise last_exception
|
|
281
|
+
return {}
|
|
282
|
+
|
|
283
|
+
def close(self) -> None:
|
|
284
|
+
"""Close transport connections."""
|
|
285
|
+
self._client.close()
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
class AsyncTransport(_BaseTransport):
|
|
289
|
+
"""Asynchronous HTTP transport."""
|
|
290
|
+
|
|
291
|
+
def __init__(
|
|
292
|
+
self,
|
|
293
|
+
api_key: str,
|
|
294
|
+
base_url: str,
|
|
295
|
+
timeout: float = 30.0,
|
|
296
|
+
max_retries: int = 3,
|
|
297
|
+
device_id: str | None = None,
|
|
298
|
+
actor_type: str | None = None,
|
|
299
|
+
):
|
|
300
|
+
super().__init__(api_key, base_url, timeout, max_retries, device_id, actor_type)
|
|
301
|
+
self._client = httpx.AsyncClient(
|
|
302
|
+
base_url=self.base_url,
|
|
303
|
+
timeout=self.timeout,
|
|
304
|
+
headers=self._get_headers(),
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
async def request(
|
|
308
|
+
self,
|
|
309
|
+
method: str,
|
|
310
|
+
path: str,
|
|
311
|
+
**kwargs,
|
|
312
|
+
) -> dict[str, Any]:
|
|
313
|
+
"""
|
|
314
|
+
Make async HTTP request with retry logic.
|
|
315
|
+
|
|
316
|
+
Args:
|
|
317
|
+
method: HTTP method (GET, POST, etc.)
|
|
318
|
+
path: Request path (without base URL)
|
|
319
|
+
**kwargs: Additional arguments for httpx
|
|
320
|
+
|
|
321
|
+
Returns:
|
|
322
|
+
Parsed JSON response body
|
|
323
|
+
|
|
324
|
+
Raises:
|
|
325
|
+
Various MIError subclasses based on HTTP status
|
|
326
|
+
"""
|
|
327
|
+
import asyncio
|
|
328
|
+
|
|
329
|
+
request_id = self._generate_request_id()
|
|
330
|
+
headers = kwargs.pop("headers", {})
|
|
331
|
+
headers["X-MI-Request-ID"] = request_id
|
|
332
|
+
|
|
333
|
+
last_exception = None
|
|
334
|
+
|
|
335
|
+
for attempt in range(self.max_retries + 1):
|
|
336
|
+
try:
|
|
337
|
+
response = await self._client.request(
|
|
338
|
+
method,
|
|
339
|
+
path,
|
|
340
|
+
headers=headers,
|
|
341
|
+
**kwargs,
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
# Get retry-after header if present
|
|
345
|
+
retry_after = None
|
|
346
|
+
if "retry-after" in response.headers:
|
|
347
|
+
try:
|
|
348
|
+
retry_after = int(response.headers["retry-after"])
|
|
349
|
+
except ValueError:
|
|
350
|
+
pass
|
|
351
|
+
|
|
352
|
+
# Get request ID from response if server returned it
|
|
353
|
+
response_request_id = response.headers.get("x-mi-request-id", request_id)
|
|
354
|
+
|
|
355
|
+
# Check if we should retry
|
|
356
|
+
if response.status_code in self.RETRY_STATUSES and attempt < self.max_retries:
|
|
357
|
+
if self._should_retry(response.status_code):
|
|
358
|
+
delay = self._calculate_backoff(attempt, retry_after)
|
|
359
|
+
logger.debug(
|
|
360
|
+
f"Retrying request (attempt {attempt + 1}/{self.max_retries}) "
|
|
361
|
+
f"after {delay:.1f}s - status {response.status_code}"
|
|
362
|
+
)
|
|
363
|
+
await asyncio.sleep(delay)
|
|
364
|
+
continue
|
|
365
|
+
|
|
366
|
+
# Parse response body
|
|
367
|
+
response_body = None
|
|
368
|
+
try:
|
|
369
|
+
if response.content:
|
|
370
|
+
response_body = response.json()
|
|
371
|
+
except Exception:
|
|
372
|
+
response_body = {"message": response.text} if response.text else None
|
|
373
|
+
|
|
374
|
+
# Raise appropriate exception for error status codes
|
|
375
|
+
self._raise_for_status(
|
|
376
|
+
response.status_code,
|
|
377
|
+
response_body,
|
|
378
|
+
response_request_id,
|
|
379
|
+
retry_after,
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
# Success - return response body
|
|
383
|
+
return response_body or {}
|
|
384
|
+
|
|
385
|
+
except httpx.TimeoutException as e:
|
|
386
|
+
last_exception = TimeoutError(f"Request timed out: {e}")
|
|
387
|
+
if attempt < self.max_retries:
|
|
388
|
+
delay = self._calculate_backoff(attempt)
|
|
389
|
+
logger.debug(
|
|
390
|
+
f"Retrying request (attempt {attempt + 1}/{self.max_retries}) "
|
|
391
|
+
f"after {delay:.1f}s - timeout"
|
|
392
|
+
)
|
|
393
|
+
await asyncio.sleep(delay)
|
|
394
|
+
else:
|
|
395
|
+
raise last_exception
|
|
396
|
+
|
|
397
|
+
except httpx.ConnectError as e:
|
|
398
|
+
raise ConnectionError(f"Could not connect to API: {e}") from e
|
|
399
|
+
|
|
400
|
+
except httpx.RequestError as e:
|
|
401
|
+
last_exception = ConnectionError(f"Request failed: {e}")
|
|
402
|
+
if attempt < self.max_retries:
|
|
403
|
+
delay = self._calculate_backoff(attempt)
|
|
404
|
+
logger.debug(
|
|
405
|
+
f"Retrying request (attempt {attempt + 1}/{self.max_retries}) "
|
|
406
|
+
f"after {delay:.1f}s - connection error"
|
|
407
|
+
)
|
|
408
|
+
await asyncio.sleep(delay)
|
|
409
|
+
else:
|
|
410
|
+
raise last_exception
|
|
411
|
+
|
|
412
|
+
# Should not reach here, but just in case
|
|
413
|
+
if last_exception:
|
|
414
|
+
raise last_exception
|
|
415
|
+
return {}
|
|
416
|
+
|
|
417
|
+
async def close(self) -> None:
|
|
418
|
+
"""Close transport connections."""
|
|
419
|
+
await self._client.aclose()
|