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.
@@ -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()