memwal 0.1.11.dev7__tar.gz → 0.1.11.dev9__tar.gz

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.
Files changed (34) hide show
  1. {memwal-0.1.11.dev7 → memwal-0.1.11.dev9}/PKG-INFO +1 -1
  2. {memwal-0.1.11.dev7 → memwal-0.1.11.dev9}/memwal/__init__.py +1 -1
  3. {memwal-0.1.11.dev7 → memwal-0.1.11.dev9}/memwal/client.py +118 -18
  4. {memwal-0.1.11.dev7 → memwal-0.1.11.dev9}/pyproject.toml +1 -1
  5. memwal-0.1.11.dev9/tests/test_remember_derived_key.py +250 -0
  6. memwal-0.1.11.dev9/tests/test_retry_after_http_date.py +160 -0
  7. memwal-0.1.11.dev9/tests/test_transport_retry.py +195 -0
  8. {memwal-0.1.11.dev7 → memwal-0.1.11.dev9}/.gitignore +0 -0
  9. {memwal-0.1.11.dev7 → memwal-0.1.11.dev9}/CHANGELOG.md +0 -0
  10. {memwal-0.1.11.dev7 → memwal-0.1.11.dev9}/README.md +0 -0
  11. {memwal-0.1.11.dev7 → memwal-0.1.11.dev9}/examples/.env.example +0 -0
  12. {memwal-0.1.11.dev7 → memwal-0.1.11.dev9}/examples/.gitignore +0 -0
  13. {memwal-0.1.11.dev7 → memwal-0.1.11.dev9}/examples/async_remember_demo.py +0 -0
  14. {memwal-0.1.11.dev7 → memwal-0.1.11.dev9}/examples/interactive_demo.py +0 -0
  15. {memwal-0.1.11.dev7 → memwal-0.1.11.dev9}/examples/verify_credentials.py +0 -0
  16. {memwal-0.1.11.dev7 → memwal-0.1.11.dev9}/memwal/compatibility.py +0 -0
  17. {memwal-0.1.11.dev7 → memwal-0.1.11.dev9}/memwal/middleware.py +0 -0
  18. {memwal-0.1.11.dev7 → memwal-0.1.11.dev9}/memwal/mock.py +0 -0
  19. {memwal-0.1.11.dev7 → memwal-0.1.11.dev9}/memwal/types.py +0 -0
  20. {memwal-0.1.11.dev7 → memwal-0.1.11.dev9}/memwal/utils.py +0 -0
  21. {memwal-0.1.11.dev7 → memwal-0.1.11.dev9}/notebooks/walrus_memory_python_sdk.ipynb +0 -0
  22. {memwal-0.1.11.dev7 → memwal-0.1.11.dev9}/run_tests.py +0 -0
  23. {memwal-0.1.11.dev7 → memwal-0.1.11.dev9}/tests/__init__.py +0 -0
  24. {memwal-0.1.11.dev7 → memwal-0.1.11.dev9}/tests/test_auth_rejected_message.py +0 -0
  25. {memwal-0.1.11.dev7 → memwal-0.1.11.dev9}/tests/test_blank_text.py +0 -0
  26. {memwal-0.1.11.dev7 → memwal-0.1.11.dev9}/tests/test_client.py +0 -0
  27. {memwal-0.1.11.dev7 → memwal-0.1.11.dev9}/tests/test_env_presets.py +0 -0
  28. {memwal-0.1.11.dev7 → memwal-0.1.11.dev9}/tests/test_integration.py +0 -0
  29. {memwal-0.1.11.dev7 → memwal-0.1.11.dev9}/tests/test_list_namespaces.py +0 -0
  30. {memwal-0.1.11.dev7 → memwal-0.1.11.dev9}/tests/test_middleware.py +0 -0
  31. {memwal-0.1.11.dev7 → memwal-0.1.11.dev9}/tests/test_mock.py +0 -0
  32. {memwal-0.1.11.dev7 → memwal-0.1.11.dev9}/tests/test_normalize_server_url.py +0 -0
  33. {memwal-0.1.11.dev7 → memwal-0.1.11.dev9}/tests/test_polling_delay.py +0 -0
  34. {memwal-0.1.11.dev7 → memwal-0.1.11.dev9}/tests/test_signing.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: memwal
3
- Version: 0.1.11.dev7
3
+ Version: 0.1.11.dev9
4
4
  Summary: Python SDK for Walrus Memory — Privacy-first AI memory with Ed25519 signing
5
5
  Project-URL: Homepage, https://memory.walrus.xyz
6
6
  Project-URL: Documentation, https://memory.walrus.xyz
@@ -128,4 +128,4 @@ __all__ = [
128
128
  "RecallManualResult",
129
129
  ]
130
130
 
131
- __version__ = "0.1.11.dev7"
131
+ __version__ = "0.1.11.dev9"
@@ -27,13 +27,16 @@ from __future__ import annotations
27
27
 
28
28
  import asyncio
29
29
  import base64
30
+ import hashlib
30
31
  import json
31
32
  import logging
33
+ import math
32
34
  import random
33
35
  import re
34
36
  import time
35
37
  import uuid
36
38
  from datetime import datetime, timezone
39
+ from email.utils import parsedate_to_datetime
37
40
  from typing import Any, Dict, List, Optional, Sequence, Set, Tuple, TypeVar, Union
38
41
  from urllib.parse import ParseResult, urlencode, urlparse
39
42
 
@@ -134,6 +137,25 @@ def _reject_blank_text(text: object, message: str = "Text cannot be empty") -> N
134
137
  raise ValueError(message)
135
138
 
136
139
 
140
+ IDEMPOTENCY_BUCKET_MS = 30 * 60 * 1000
141
+
142
+
143
+ def _derived_idempotency_key(request_identity: str) -> str:
144
+ """Window over which the same (namespace, text) resolves to one key.
145
+
146
+ `_pending_remember_keys` only dedupes retries that reuse one client instance,
147
+ and callers such as sidecars or serverless functions build a fresh client
148
+ per session — so a random key lets a replay mint a second paid blob. The
149
+ bucket bounds the collapse: `remember_jobs` rows are never pruned, so an
150
+ unbucketed key would dedupe against a job from any point in history.
151
+
152
+ Matches TypeScript SDK derivedIdempotencyKey contract.
153
+ """
154
+ bucket = int(time.time() * 1000) // IDEMPOTENCY_BUCKET_MS
155
+ payload = f"{bucket}\0{request_identity}".encode("utf-8")
156
+ return f"r1-{hashlib.sha256(payload).hexdigest()}"
157
+
158
+
137
159
  def normalize_server_url(url: str) -> str:
138
160
  """Strip a trailing slash and warn on plaintext HTTP to a remote host.
139
161
 
@@ -199,18 +221,51 @@ def _positive_retry_ms(raw: object) -> Optional[int]:
199
221
  seconds = float(raw) # type: ignore[arg-type]
200
222
  except (TypeError, ValueError):
201
223
  return None
202
- if seconds <= 0 or seconds != seconds or seconds == float("inf"):
224
+ if seconds <= 0 or math.isnan(seconds) or math.isinf(seconds):
225
+ return None
226
+ try:
227
+ ms = seconds * 1000.0
228
+ if math.isinf(ms) or math.isnan(ms):
229
+ return None
230
+ return int(ms)
231
+ except (OverflowError, ValueError):
232
+ return None
233
+
234
+
235
+ def _http_date_retry_ms(raw: object) -> Optional[int]:
236
+ if not isinstance(raw, str):
237
+ return None
238
+ try:
239
+ dt = parsedate_to_datetime(raw)
240
+ if dt is None:
241
+ return None
242
+ if dt.tzinfo is None:
243
+ dt = dt.replace(tzinfo=timezone.utc)
244
+ target_ts = dt.timestamp()
245
+ # HTTP-date is absolute wall-clock time, so we deliberately compare against
246
+ # time.time() rather than monotonic _now_ms().
247
+ now_ts = time.time()
248
+ diff = target_ts - now_ts
249
+ if diff <= 0 or math.isnan(diff) or math.isinf(diff):
250
+ return None
251
+ ms = diff * 1000.0
252
+ if math.isinf(ms) or math.isnan(ms):
253
+ return None
254
+ return int(ms)
255
+ except Exception:
203
256
  return None
204
- return int(seconds * 1000)
205
257
 
206
258
 
207
259
  def _retry_after_ms(err: "_HttpStatusError") -> int:
208
- """Retry-After seconds, else JSON ``retry_after_seconds``, in ms."""
260
+ """Retry-After seconds or HTTP-date, else JSON ``retry_after_seconds``, in ms."""
209
261
 
210
262
  if err.retry_after:
211
263
  header_ms = _positive_retry_ms(err.retry_after)
212
264
  if header_ms is not None:
213
265
  return header_ms
266
+ date_ms = _http_date_retry_ms(err.retry_after)
267
+ if date_ms is not None:
268
+ return date_ms
214
269
  if not err.body:
215
270
  return 0
216
271
  try:
@@ -431,9 +486,11 @@ class MemWal:
431
486
  resolved_namespace = namespace or self._namespace
432
487
  request_identity = f"{resolved_namespace}\0{text}"
433
488
  generated_key = idempotency_key is None
434
- resolved_key = idempotency_key or self._pending_remember_keys.get(request_identity)
435
- if resolved_key is None:
436
- resolved_key = str(uuid.uuid4())
489
+ resolved_key = (
490
+ idempotency_key
491
+ or self._pending_remember_keys.get(request_identity)
492
+ or _derived_idempotency_key(request_identity)
493
+ )
437
494
  if generated_key:
438
495
  self._pending_remember_keys[request_identity] = resolved_key
439
496
 
@@ -463,6 +520,10 @@ class MemWal:
463
520
  ) -> RememberAcceptedResult:
464
521
  return await self.remember(text, namespace, idempotency_key)
465
522
 
523
+ def _poll_deadline_s(self, deadline_ms: int) -> float:
524
+ remaining_s = (deadline_ms - _now_ms()) / 1000.0
525
+ return max(0.001, min(60.0, remaining_s))
526
+
466
527
  async def wait_for_remember_job(
467
528
  self,
468
529
  job_id: str,
@@ -498,12 +559,25 @@ class MemWal:
498
559
  retry_after_ms = 0
499
560
 
500
561
  try:
501
- data = await self._signed_request(
502
- "GET",
503
- f"/api/remember/{job_id}",
504
- {},
505
- accepted_statuses=(200, 404),
506
- )
562
+ poll_timeout = self._poll_deadline_s(deadline_ms)
563
+ try:
564
+ data = await self._signed_request(
565
+ "GET",
566
+ f"/api/remember/{job_id}",
567
+ {},
568
+ accepted_statuses=(200, 404),
569
+ timeout=poll_timeout,
570
+ )
571
+ except TypeError as err:
572
+ if "unexpected keyword argument" in str(err) and "timeout" in str(err):
573
+ data = await self._signed_request(
574
+ "GET",
575
+ f"/api/remember/{job_id}",
576
+ {},
577
+ accepted_statuses=(200, 404),
578
+ )
579
+ else:
580
+ raise
507
581
  except _HttpStatusError as err:
508
582
  if _is_transient_polling_status(err.status):
509
583
  last_refusal = err.status
@@ -512,6 +586,11 @@ class MemWal:
512
586
  retry_after_ms = _clamped_retry_after_ms(err, deadline_ms)
513
587
  continue
514
588
  raise
589
+ except httpx.TransportError:
590
+ # Network drops retry as status 0; continuing next loop iteration
591
+ # increments `attempt` so exponential backoff delay continues to grow.
592
+ last_refusal = 0
593
+ continue
515
594
 
516
595
  status_str = data.get("status")
517
596
  if status_str is None or status_str == "not_found":
@@ -558,12 +637,15 @@ class MemWal:
558
637
  ``failed``). Mirrors TS ``rememberAndWait``.
559
638
  """
560
639
 
640
+ _reject_blank_text(text)
561
641
  resolved_namespace = namespace or self._namespace
562
642
  request_identity = f"{resolved_namespace}\0{text}"
563
643
  generated_key = idempotency_key is None
564
- resolved_key = idempotency_key or self._pending_remember_keys.get(request_identity)
565
- if resolved_key is None:
566
- resolved_key = str(uuid.uuid4())
644
+ resolved_key = (
645
+ idempotency_key
646
+ or self._pending_remember_keys.get(request_identity)
647
+ or _derived_idempotency_key(request_identity)
648
+ )
567
649
  if generated_key:
568
650
  self._pending_remember_keys[request_identity] = resolved_key
569
651
 
@@ -632,7 +714,9 @@ class MemWal:
632
714
  return await self.remember_bulk_async(items)
633
715
 
634
716
  async def get_remember_bulk_status(
635
- self, job_ids: Sequence[str]
717
+ self,
718
+ job_ids: Sequence[str],
719
+ timeout: Optional[float] = None,
636
720
  ) -> RememberBulkStatusResult:
637
721
  """Poll the bulk-status endpoint for a batch of job_ids.
638
722
 
@@ -644,6 +728,7 @@ class MemWal:
644
728
  "POST",
645
729
  "/api/remember/bulk/status",
646
730
  {"job_ids": list(job_ids)},
731
+ timeout=timeout,
647
732
  )
648
733
  return RememberBulkStatusResult(
649
734
  results=[
@@ -726,7 +811,14 @@ class MemWal:
726
811
  retry_after_ms = 0
727
812
 
728
813
  try:
729
- batch = await self.get_remember_bulk_status(pending)
814
+ poll_timeout = self._poll_deadline_s(deadline_ms)
815
+ try:
816
+ batch = await self.get_remember_bulk_status(pending, timeout=poll_timeout)
817
+ except TypeError as err:
818
+ if "unexpected keyword argument" in str(err) and "timeout" in str(err):
819
+ batch = await self.get_remember_bulk_status(pending)
820
+ else:
821
+ raise
730
822
  except _HttpStatusError as err:
731
823
  if _is_transient_polling_status(err.status):
732
824
  last_refusal = err.status
@@ -735,6 +827,11 @@ class MemWal:
735
827
  retry_after_ms = _clamped_retry_after_ms(err, deadline_ms)
736
828
  continue
737
829
  raise
830
+ except httpx.TransportError:
831
+ # Network drops retry as status 0; continuing next loop iteration
832
+ # increments `attempt` so exponential backoff delay continues to grow.
833
+ last_refusal = 0
834
+ continue
738
835
  answered = True
739
836
 
740
837
  terminal_ids: Set[str] = {
@@ -757,6 +854,8 @@ class MemWal:
757
854
  retry_after = f"{wait_ms / 1000:g}" if wait_ms else None
758
855
  raise MemWalRateLimited(job_ids, retry_after) from err
759
856
  # Any other probe failure is not evidence about the jobs.
857
+ except httpx.TransportError:
858
+ pass
760
859
  else:
761
860
  answered = True
762
861
  settled = {
@@ -1515,6 +1614,7 @@ class MemWal:
1515
1614
  body: Dict[str, Any],
1516
1615
  accepted_statuses: tuple = (200,),
1517
1616
  include_seal_session: bool = True,
1617
+ timeout: Optional[float] = None,
1518
1618
  ) -> Dict[str, Any]:
1519
1619
  """Make a signed request to the server.
1520
1620
 
@@ -1535,7 +1635,6 @@ class MemWal:
1535
1635
  - ``x-account-id``: Walrus Memory account object ID
1536
1636
  - ``Content-Type``: application/json
1537
1637
  """
1538
- import uuid
1539
1638
 
1540
1639
  await self._ensure_compatible_relayer()
1541
1640
 
@@ -1575,6 +1674,7 @@ class MemWal:
1575
1674
  url=url,
1576
1675
  headers=headers,
1577
1676
  content=None if method_upper == "GET" else body_str,
1677
+ timeout=timeout,
1578
1678
  )
1579
1679
 
1580
1680
  if response.status_code not in accepted_statuses:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "memwal"
7
- version = "0.1.11.dev7"
7
+ version = "0.1.11.dev9"
8
8
  description = "Python SDK for Walrus Memory — Privacy-first AI memory with Ed25519 signing"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -0,0 +1,250 @@
1
+ """
2
+ Tests for derived idempotency keys in Python SDK (GH #1094, WALM-800).
3
+
4
+ Validates parity with TypeScript SDK specification:
5
+ - An omitted idempotency_key is derived from bucket + namespace + text:
6
+ r1-{sha256(bucket \0 namespace \0 text)}
7
+ - Repeated identical writes reuse the same idempotency key across client instances
8
+ - Different texts or namespaces produce distinct keys
9
+ - Explicit idempotency keys are respected
10
+ - MemWalSync synchronous wrapper inherits the same behavior
11
+ """
12
+
13
+ import hashlib
14
+ import json
15
+ import time
16
+ from unittest.mock import patch
17
+
18
+ import httpx
19
+ import pytest
20
+ import respx
21
+
22
+ from memwal.client import (
23
+ IDEMPOTENCY_BUCKET_MS,
24
+ MemWal,
25
+ MemWalRememberJobTimeout,
26
+ MemWalSync,
27
+ _derived_idempotency_key,
28
+ )
29
+ from memwal.utils import bytes_to_hex
30
+
31
+ _TEST_KEY_HEX = bytes_to_hex(b"\x01" * 32)
32
+ _TEST_ACCOUNT_ID = "0xabc123"
33
+ _TEST_SERVER = "http://localhost:8000"
34
+
35
+
36
+ def _make_client(namespace: str = "default") -> MemWal:
37
+ client = MemWal.create(
38
+ key=_TEST_KEY_HEX,
39
+ account_id=_TEST_ACCOUNT_ID,
40
+ server_url=_TEST_SERVER,
41
+ namespace=namespace,
42
+ )
43
+ # Cache a valid seal session (expiry in milliseconds) to avoid needing live Sui RPC
44
+ client._session_cache = ("fake-seal-session", int(time.time() * 1000) + 3_600_000)
45
+ return client
46
+
47
+
48
+ def _mock_version_and_config():
49
+ respx.get(f"{_TEST_SERVER}/version").mock(
50
+ return_value=httpx.Response(
51
+ 200,
52
+ json={
53
+ "apiVersion": "1.0.0",
54
+ "relayerVersion": "1.0.0",
55
+ "minSupportedSdk": {"python": "0.1.0"},
56
+ },
57
+ )
58
+ )
59
+ respx.get(f"{_TEST_SERVER}/config").mock(
60
+ return_value=httpx.Response(
61
+ 200,
62
+ json={"packageId": "0x1", "network": "testnet"},
63
+ )
64
+ )
65
+
66
+
67
+ def test_derived_idempotency_key_format_and_cross_language_parity():
68
+ """Verify byte-for-byte SHA256 digest parity with TypeScript SDK."""
69
+ bucket = 123456
70
+ request_identity = "default\0I am allergic to peanuts"
71
+
72
+ # Pre-calculated reference matching TypeScript:
73
+ # r1-${sha256("123456\0default\0I am allergic to peanuts")}
74
+ expected_digest = hashlib.sha256(f"{bucket}\0{request_identity}".encode("utf-8")).hexdigest()
75
+ expected_key = f"r1-{expected_digest}"
76
+ assert expected_key == "r1-8690455a552eca1cc0d77d60b2a7c2a05a2dd836874f5106b1c9967dc5adc083"
77
+
78
+ with patch("time.time", return_value=(bucket * IDEMPOTENCY_BUCKET_MS) / 1000.0):
79
+ actual_key = _derived_idempotency_key(request_identity)
80
+ assert actual_key == expected_key
81
+ assert actual_key.startswith("r1-")
82
+ assert len(actual_key) == 3 + 64
83
+
84
+
85
+ @respx.mock
86
+ @pytest.mark.asyncio
87
+ async def test_same_fact_reuses_one_idempotency_key_across_client_instances():
88
+ """Two instances build what sidecars or serverless functions run on reconnect.
89
+
90
+ A per-instance random uuid4 made the replay look like a new write, so the
91
+ relayer minted a second paid Walrus blob. The content-based derived key
92
+ ensures both send the identical key.
93
+ """
94
+ _mock_version_and_config()
95
+ posted_bodies = []
96
+
97
+ def handle_remember(request: httpx.Request) -> httpx.Response:
98
+ posted_bodies.append(json.loads(request.content))
99
+ return httpx.Response(202, json={"job_id": "job-1", "status": "pending"})
100
+
101
+ respx.post(f"{_TEST_SERVER}/api/remember").mock(side_effect=handle_remember)
102
+ respx.get(f"{_TEST_SERVER}/api/remember/job-1").mock(
103
+ return_value=httpx.Response(
104
+ 200,
105
+ json={
106
+ "job_id": "job-1",
107
+ "status": "done",
108
+ "blob_id": "blob-1",
109
+ "owner": _TEST_ACCOUNT_ID,
110
+ "namespace": "default",
111
+ },
112
+ )
113
+ )
114
+
115
+ client1 = _make_client()
116
+ await client1.remember_and_wait("a durable fact")
117
+
118
+ client2 = _make_client()
119
+ await client2.remember_and_wait("a durable fact")
120
+
121
+ assert len(posted_bodies) == 2
122
+ assert posted_bodies[0]["idempotency_key"] == posted_bodies[1]["idempotency_key"]
123
+ assert posted_bodies[0]["idempotency_key"].startswith("r1-")
124
+
125
+
126
+ @respx.mock
127
+ @pytest.mark.asyncio
128
+ async def test_remember_async_reuses_derived_key():
129
+ """remember() (async accept) also derives the content key."""
130
+ _mock_version_and_config()
131
+ posted_bodies = []
132
+
133
+ def handle_remember(request: httpx.Request) -> httpx.Response:
134
+ posted_bodies.append(json.loads(request.content))
135
+ job_id = f"job-{len(posted_bodies)}"
136
+ return httpx.Response(202, json={"job_id": job_id, "status": "pending"})
137
+
138
+ respx.post(f"{_TEST_SERVER}/api/remember").mock(side_effect=handle_remember)
139
+
140
+ client = _make_client()
141
+ await client.remember("user likes pizza")
142
+ await client.remember("user likes pizza")
143
+
144
+ assert len(posted_bodies) == 2
145
+ assert posted_bodies[0]["idempotency_key"] == posted_bodies[1]["idempotency_key"]
146
+
147
+
148
+ @respx.mock
149
+ @pytest.mark.asyncio
150
+ async def test_different_text_and_namespaces_get_different_keys():
151
+ """Distinct writes must not collapse onto one job."""
152
+ _mock_version_and_config()
153
+ posted_bodies = []
154
+
155
+ def handle_remember(request: httpx.Request) -> httpx.Response:
156
+ posted_bodies.append(json.loads(request.content))
157
+ job_id = f"job-{len(posted_bodies)}"
158
+ return httpx.Response(202, json={"job_id": job_id, "status": "pending"})
159
+
160
+ respx.post(f"{_TEST_SERVER}/api/remember").mock(side_effect=handle_remember)
161
+
162
+ client = _make_client()
163
+ await client.remember("fact one")
164
+ await client.remember("fact two")
165
+ await client.remember("fact one", namespace="other-namespace")
166
+
167
+ keys = [b["idempotency_key"] for b in posted_bodies]
168
+ assert len(set(keys)) == 3, "distinct writes must produce distinct keys"
169
+
170
+
171
+ @respx.mock
172
+ @pytest.mark.asyncio
173
+ async def test_explicit_idempotency_key_still_wins():
174
+ """When the caller passes an explicit key, that key is sent directly."""
175
+ _mock_version_and_config()
176
+ posted_bodies = []
177
+
178
+ def handle_remember(request: httpx.Request) -> httpx.Response:
179
+ posted_bodies.append(json.loads(request.content))
180
+ return httpx.Response(202, json={"job_id": "job-explicit", "status": "pending"})
181
+
182
+ respx.post(f"{_TEST_SERVER}/api/remember").mock(side_effect=handle_remember)
183
+
184
+ client = _make_client()
185
+ await client.remember("a fact", idempotency_key="caller-owned-key")
186
+
187
+ assert len(posted_bodies) == 1
188
+ assert posted_bodies[0]["idempotency_key"] == "caller-owned-key"
189
+
190
+
191
+ @respx.mock
192
+ @pytest.mark.asyncio
193
+ async def test_remember_and_wait_preserves_key_on_polling_timeout():
194
+ """If polling times out, the pending key stays in the map for immediate retry."""
195
+ _mock_version_and_config()
196
+ posted_bodies = []
197
+
198
+ def handle_remember(request: httpx.Request) -> httpx.Response:
199
+ posted_bodies.append(json.loads(request.content))
200
+ return httpx.Response(202, json={"job_id": "slow-job", "status": "pending"})
201
+
202
+ respx.post(f"{_TEST_SERVER}/api/remember").mock(side_effect=handle_remember)
203
+ respx.get(f"{_TEST_SERVER}/api/remember/slow-job").mock(
204
+ return_value=httpx.Response(200, json={"job_id": "slow-job", "status": "running"})
205
+ )
206
+
207
+ client = _make_client()
208
+ with pytest.raises(MemWalRememberJobTimeout):
209
+ await client.remember_and_wait("timeout fact", poll_interval_ms=10, timeout_ms=50)
210
+
211
+ # Key must still be in pending map
212
+ request_identity = "default\0timeout fact"
213
+ assert request_identity in client._pending_remember_keys
214
+ pending_key = client._pending_remember_keys[request_identity]
215
+
216
+ # Retry uses the exact same pending key
217
+ with pytest.raises(MemWalRememberJobTimeout):
218
+ await client.remember_and_wait("timeout fact", poll_interval_ms=10, timeout_ms=50)
219
+
220
+ assert len(posted_bodies) == 2
221
+ assert posted_bodies[0]["idempotency_key"] == pending_key
222
+ assert posted_bodies[1]["idempotency_key"] == pending_key
223
+
224
+
225
+ @respx.mock
226
+ def test_sync_client_uses_derived_key():
227
+ """MemWalSync synchronous client derives and reuses content keys."""
228
+ _mock_version_and_config()
229
+ posted_bodies = []
230
+
231
+ def handle_remember(request: httpx.Request) -> httpx.Response:
232
+ posted_bodies.append(json.loads(request.content))
233
+ return httpx.Response(202, json={"job_id": "job-sync", "status": "pending"})
234
+
235
+ respx.post(f"{_TEST_SERVER}/api/remember").mock(side_effect=handle_remember)
236
+
237
+ sync_client = MemWalSync.create(
238
+ key=_TEST_KEY_HEX,
239
+ account_id=_TEST_ACCOUNT_ID,
240
+ server_url=_TEST_SERVER,
241
+ namespace="default",
242
+ )
243
+ sync_client._inner._session_cache = ("fake-seal-session", int(time.time() * 1000) + 3_600_000)
244
+
245
+ sync_client.remember("sync fact")
246
+ sync_client.remember("sync fact")
247
+
248
+ assert len(posted_bodies) == 2
249
+ assert posted_bodies[0]["idempotency_key"] == posted_bodies[1]["idempotency_key"]
250
+ assert posted_bodies[0]["idempotency_key"].startswith("r1-")
@@ -0,0 +1,160 @@
1
+ """Tests for HTTP-date Retry-After header parsing and arithmetic overflow protection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timedelta, timezone
6
+ from email.utils import format_datetime
7
+ from typing import Optional
8
+
9
+ import pytest
10
+
11
+ from memwal.client import (
12
+ MemWal,
13
+ _clamped_retry_after_ms,
14
+ _HttpStatusError,
15
+ _positive_retry_ms,
16
+ _retry_after_ms,
17
+ )
18
+
19
+
20
+ def _err(
21
+ retry_after: Optional[str] = None,
22
+ body: Optional[str] = None,
23
+ ) -> _HttpStatusError:
24
+ return _HttpStatusError(429, body or "{}", retry_after=retry_after)
25
+
26
+
27
+ def test_positive_retry_ms_delta_seconds() -> None:
28
+ assert _positive_retry_ms("45") == 45000
29
+ assert _positive_retry_ms(45) == 45000
30
+ assert _positive_retry_ms("0.5") == 500
31
+ assert _positive_retry_ms(0.25) == 250
32
+
33
+
34
+ def test_positive_retry_ms_invalid_or_non_positive() -> None:
35
+ assert _positive_retry_ms(0) is None
36
+ assert _positive_retry_ms("0") is None
37
+ assert _positive_retry_ms("-5") is None
38
+ assert _positive_retry_ms("invalid") is None
39
+ assert _positive_retry_ms(None) is None
40
+ assert _positive_retry_ms(float("nan")) is None
41
+ assert _positive_retry_ms(float("inf")) is None
42
+
43
+
44
+ def test_positive_retry_ms_overflow_protection() -> None:
45
+ # 1e308 is finite float, but 1e308 * 1000 overflows to infinity
46
+ assert _positive_retry_ms("1e308") is None
47
+ assert _positive_retry_ms(1e308) is None
48
+
49
+
50
+ def test_retry_after_http_date_in_future() -> None:
51
+ future_time = datetime.now(timezone.utc) + timedelta(seconds=45)
52
+ future_header = format_datetime(future_time, usegmt=True)
53
+
54
+ err = _err(retry_after=future_header)
55
+ delay = _retry_after_ms(err)
56
+ # Delay should be approximately 45,000 ms (allowing slight test execution variance)
57
+ assert 40000 <= delay <= 46000
58
+
59
+
60
+ def test_retry_after_http_date_takes_priority_over_json() -> None:
61
+ future_time = datetime.now(timezone.utc) + timedelta(seconds=60)
62
+ future_header = format_datetime(future_time, usegmt=True)
63
+ body = '{"retry_after_seconds": 10}'
64
+
65
+ err = _err(retry_after=future_header, body=body)
66
+ delay = _retry_after_ms(err)
67
+ assert 55000 <= delay <= 61000
68
+
69
+
70
+ def test_retry_after_http_date_in_past_falls_back_to_json() -> None:
71
+ past_time = datetime.now(timezone.utc) - timedelta(seconds=10)
72
+ past_header = format_datetime(past_time, usegmt=True)
73
+ body = '{"retry_after_seconds": 15}'
74
+
75
+ err = _err(retry_after=past_header, body=body)
76
+ assert _retry_after_ms(err) == 15000
77
+
78
+
79
+ def test_retry_after_http_date_in_past_returns_zero_when_no_json() -> None:
80
+ past_time = datetime.now(timezone.utc) - timedelta(seconds=10)
81
+ past_header = format_datetime(past_time, usegmt=True)
82
+
83
+ err = _err(retry_after=past_header)
84
+ assert _retry_after_ms(err) == 0
85
+
86
+
87
+ def test_retry_after_malformed_header_falls_back_to_json() -> None:
88
+ err = _err(retry_after="Not A Valid Date Or Number", body='{"retry_after_seconds": 25}')
89
+ assert _retry_after_ms(err) == 25000
90
+
91
+
92
+ def test_retry_after_malformed_header_without_json_returns_zero() -> None:
93
+ err = _err(retry_after="invalid-header")
94
+ assert _retry_after_ms(err) == 0
95
+
96
+
97
+ def test_retry_after_overflowing_number_falls_back_to_json() -> None:
98
+ err = _err(retry_after="1e308", body='{"retry_after_seconds": 30}')
99
+ assert _retry_after_ms(err) == 30000
100
+
101
+
102
+ def test_clamped_retry_after_ms_with_http_date(monkeypatch: pytest.MonkeyPatch) -> None:
103
+ fixed_now = 1_000_000
104
+ monkeypatch.setattr("memwal.client._now_ms", lambda: fixed_now)
105
+
106
+ # Future date roughly 50s ahead
107
+ future_time = datetime.now(timezone.utc) + timedelta(seconds=50)
108
+ future_header = format_datetime(future_time, usegmt=True)
109
+ err = _err(retry_after=future_header)
110
+
111
+ # If remaining time is only 20s (deadline 1,020,000), clamped to 20,000
112
+ clamped = _clamped_retry_after_ms(err, deadline_ms=fixed_now + 20_000)
113
+ assert clamped == 20_000
114
+
115
+ # If remaining time is 100s, delay (~50,000 ms) is used
116
+ clamped_large = _clamped_retry_after_ms(err, deadline_ms=fixed_now + 100_000)
117
+ assert 45_000 <= clamped_large <= 51_000
118
+
119
+
120
+ @pytest.mark.asyncio
121
+ async def test_wait_for_remember_job_honors_http_date_retry_after(
122
+ monkeypatch: pytest.MonkeyPatch,
123
+ ) -> None:
124
+ fixed_ts = 1700000000.0
125
+ monkeypatch.setattr("time.time", lambda: fixed_ts)
126
+ clock = {"now": int(fixed_ts * 1000)}
127
+ slept: list[int] = []
128
+
129
+ def fake_now_ms() -> int:
130
+ return clock["now"]
131
+
132
+ async def fake_sleep_ms(ms: int) -> None:
133
+ slept.append(ms)
134
+ clock["now"] += ms
135
+
136
+ monkeypatch.setattr("memwal.client._now_ms", fake_now_ms)
137
+ monkeypatch.setattr("memwal.client._sleep_ms", fake_sleep_ms)
138
+
139
+ client = MemWal.create(key="11" * 32, account_id="0x1", server_url="http://localhost:8000")
140
+ calls = {"n": 0}
141
+
142
+ # 429 Retry-After is 2 seconds into future
143
+ future_dt = datetime.fromtimestamp(fixed_ts, tz=timezone.utc) + timedelta(seconds=2)
144
+ http_date = format_datetime(future_dt, usegmt=True)
145
+
146
+ async def status(*_args: object, **_kwargs: object) -> dict:
147
+ calls["n"] += 1
148
+ if calls["n"] == 1:
149
+ raise _HttpStatusError(429, "{}", retry_after=http_date)
150
+ return {"job_id": "job-1", "status": "done", "blob_id": "b-1"}
151
+
152
+ monkeypatch.setattr(client, "_signed_request", status)
153
+
154
+ res = await client.wait_for_remember_job("job-1", timeout_ms=10_000)
155
+ assert res.blob_id == "b-1"
156
+ assert calls["n"] == 2
157
+ # First sleep is attempt 0 (0 ms), second sleep should be 2000 ms from Retry-After
158
+ assert len(slept) == 2
159
+ assert slept[0] == 0
160
+ assert slept[1] == 2000
@@ -0,0 +1,195 @@
1
+ """Tests for network transport error retry and deadline bounding.
2
+
3
+ Covers WALM-696 and GH #1000.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import httpx
9
+ import pytest
10
+
11
+ from memwal.client import (
12
+ MemWal,
13
+ MemWalRememberJobTimeout,
14
+ RememberBulkOptions,
15
+ )
16
+
17
+
18
+ def _client() -> MemWal:
19
+ return MemWal.create(key="11" * 32, account_id="0x1", server_url="http://localhost:8000")
20
+
21
+
22
+ def _patch_clock(monkeypatch: pytest.MonkeyPatch) -> tuple[dict[str, int], list[int]]:
23
+ clock = {"now": 0}
24
+ slept: list[int] = []
25
+
26
+ def now_ms() -> int:
27
+ return clock["now"]
28
+
29
+ async def sleep_ms(ms: int) -> None:
30
+ slept.append(ms)
31
+ clock["now"] += ms
32
+
33
+ monkeypatch.setattr("memwal.client._now_ms", now_ms)
34
+ monkeypatch.setattr("memwal.client._sleep_ms", sleep_ms)
35
+ return clock, slept
36
+
37
+
38
+ @pytest.mark.asyncio
39
+ async def test_wait_for_remember_job_retries_connect_error(monkeypatch: pytest.MonkeyPatch) -> None:
40
+ _patch_clock(monkeypatch)
41
+ client = _client()
42
+ calls = {"n": 0}
43
+
44
+ async def status(*_args: object, **_kwargs: object) -> dict:
45
+ calls["n"] += 1
46
+ if calls["n"] == 1:
47
+ raise httpx.ConnectError("simulated connection drop")
48
+ return {
49
+ "job_id": "job-1",
50
+ "status": "done",
51
+ "blob_id": "blob-1",
52
+ "owner": "0x1",
53
+ "namespace": "default",
54
+ }
55
+
56
+ monkeypatch.setattr(client, "_signed_request", status)
57
+
58
+ done = await client.wait_for_remember_job("job-1", poll_interval_ms=100, timeout_ms=5000)
59
+ assert done.blob_id == "blob-1"
60
+ assert calls["n"] == 2
61
+
62
+
63
+ @pytest.mark.asyncio
64
+ async def test_wait_for_remember_job_retries_read_timeout(monkeypatch: pytest.MonkeyPatch) -> None:
65
+ _patch_clock(monkeypatch)
66
+ client = _client()
67
+ calls = {"n": 0}
68
+
69
+ async def status(*_args: object, **_kwargs: object) -> dict:
70
+ calls["n"] += 1
71
+ if calls["n"] == 1:
72
+ raise httpx.ReadTimeout("simulated socket read timeout")
73
+ return {
74
+ "job_id": "job-2",
75
+ "status": "done",
76
+ "blob_id": "blob-2",
77
+ "owner": "0x1",
78
+ "namespace": "default",
79
+ }
80
+
81
+ monkeypatch.setattr(client, "_signed_request", status)
82
+
83
+ done = await client.wait_for_remember_job("job-2", poll_interval_ms=100, timeout_ms=5000)
84
+ assert done.blob_id == "blob-2"
85
+ assert calls["n"] == 2
86
+
87
+
88
+ @pytest.mark.asyncio
89
+ async def test_wait_for_remember_job_times_out_with_http_0_message(
90
+ monkeypatch: pytest.MonkeyPatch,
91
+ ) -> None:
92
+ clock, slept = _patch_clock(monkeypatch)
93
+ client = _client()
94
+ calls = {"n": 0}
95
+
96
+ async def failing(*_args: object, **_kwargs: object) -> dict:
97
+ calls["n"] += 1
98
+ clock["now"] += 100
99
+ raise httpx.ConnectError("network unreachable")
100
+
101
+ monkeypatch.setattr(client, "_signed_request", failing)
102
+
103
+ with pytest.raises(MemWalRememberJobTimeout) as exc_info:
104
+ await client.wait_for_remember_job("job-fail", poll_interval_ms=100, timeout_ms=500)
105
+
106
+ err = exc_info.value
107
+ assert err.last_refusal == 0
108
+ assert err.status == 504
109
+ assert "last poll: HTTP 0" in str(err)
110
+ assert calls["n"] > 1
111
+
112
+
113
+ @pytest.mark.asyncio
114
+ async def test_wait_for_remember_job_bounds_poll_timeout(monkeypatch: pytest.MonkeyPatch) -> None:
115
+ clock, _ = _patch_clock(monkeypatch)
116
+ client = _client()
117
+ captured_timeouts: list[float] = []
118
+
119
+ async def capture(*_args: object, **kwargs: object) -> dict:
120
+ timeout = kwargs.get("timeout")
121
+ if timeout is not None:
122
+ captured_timeouts.append(timeout)
123
+ return {
124
+ "job_id": "job-bound",
125
+ "status": "done",
126
+ "blob_id": "blob-bound",
127
+ "owner": "0x1",
128
+ "namespace": "default",
129
+ }
130
+
131
+ monkeypatch.setattr(client, "_signed_request", capture)
132
+
133
+ clock["now"] = 1000
134
+ # timeout_ms=2000 -> deadline=3000 -> remaining=2.0s
135
+ await client.wait_for_remember_job("job-bound", timeout_ms=2000)
136
+ assert len(captured_timeouts) == 1
137
+ assert captured_timeouts[0] == pytest.approx(2.0, rel=1e-2)
138
+
139
+
140
+ @pytest.mark.asyncio
141
+ async def test_wait_for_remember_jobs_bulk_retries_transport_error(
142
+ monkeypatch: pytest.MonkeyPatch,
143
+ ) -> None:
144
+ _patch_clock(monkeypatch)
145
+ client = _client()
146
+ calls = {"n": 0}
147
+
148
+ async def bulk_status(job_ids: list[str], timeout: float | None = None):
149
+ from memwal.types import RememberBulkStatusItem, RememberBulkStatusResult
150
+
151
+ calls["n"] += 1
152
+ if calls["n"] == 1:
153
+ raise httpx.ConnectError("simulated bulk network drop")
154
+ return RememberBulkStatusResult(
155
+ results=[
156
+ RememberBulkStatusItem(
157
+ job_id="bulk-1",
158
+ status="done",
159
+ blob_id="blob-bulk-1",
160
+ )
161
+ ]
162
+ )
163
+
164
+ monkeypatch.setattr(client, "get_remember_bulk_status", bulk_status)
165
+
166
+ res = await client.wait_for_remember_jobs(
167
+ ["bulk-1"],
168
+ opts=RememberBulkOptions(poll_interval_ms=100, timeout_ms=5000),
169
+ )
170
+ assert len(res.results) == 1
171
+ assert res.results[0].status == "done"
172
+ assert res.results[0].blob_id == "blob-bulk-1"
173
+ assert calls["n"] == 2
174
+
175
+
176
+ @pytest.mark.asyncio
177
+ async def test_wait_for_remember_jobs_bulk_timeout_on_persistent_transport_error(
178
+ monkeypatch: pytest.MonkeyPatch,
179
+ ) -> None:
180
+ clock, _ = _patch_clock(monkeypatch)
181
+ client = _client()
182
+
183
+ async def bulk_failing(job_ids: list[str], timeout: float | None = None):
184
+ clock["now"] += 100
185
+ raise httpx.ConnectError("persistent bulk network drop")
186
+
187
+ monkeypatch.setattr(client, "get_remember_bulk_status", bulk_failing)
188
+
189
+ res = await client.wait_for_remember_jobs(
190
+ ["bulk-dead"],
191
+ opts=RememberBulkOptions(poll_interval_ms=100, timeout_ms=500),
192
+ )
193
+ assert len(res.results) == 1
194
+ assert res.results[0].status == "timeout"
195
+ assert "last poll: HTTP 0" in (res.results[0].error or "")
File without changes
File without changes
File without changes
File without changes