memwal 0.1.11.dev1__tar.gz → 0.1.11.dev2__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 (30) hide show
  1. {memwal-0.1.11.dev1 → memwal-0.1.11.dev2}/CHANGELOG.md +7 -0
  2. {memwal-0.1.11.dev1 → memwal-0.1.11.dev2}/PKG-INFO +1 -1
  3. {memwal-0.1.11.dev1 → memwal-0.1.11.dev2}/memwal/__init__.py +1 -1
  4. {memwal-0.1.11.dev1 → memwal-0.1.11.dev2}/memwal/client.py +87 -14
  5. {memwal-0.1.11.dev1 → memwal-0.1.11.dev2}/pyproject.toml +1 -1
  6. memwal-0.1.11.dev2/tests/test_polling_delay.py +143 -0
  7. {memwal-0.1.11.dev1 → memwal-0.1.11.dev2}/.gitignore +0 -0
  8. {memwal-0.1.11.dev1 → memwal-0.1.11.dev2}/README.md +0 -0
  9. {memwal-0.1.11.dev1 → memwal-0.1.11.dev2}/examples/.env.example +0 -0
  10. {memwal-0.1.11.dev1 → memwal-0.1.11.dev2}/examples/.gitignore +0 -0
  11. {memwal-0.1.11.dev1 → memwal-0.1.11.dev2}/examples/async_remember_demo.py +0 -0
  12. {memwal-0.1.11.dev1 → memwal-0.1.11.dev2}/examples/interactive_demo.py +0 -0
  13. {memwal-0.1.11.dev1 → memwal-0.1.11.dev2}/examples/verify_credentials.py +0 -0
  14. {memwal-0.1.11.dev1 → memwal-0.1.11.dev2}/memwal/compatibility.py +0 -0
  15. {memwal-0.1.11.dev1 → memwal-0.1.11.dev2}/memwal/middleware.py +0 -0
  16. {memwal-0.1.11.dev1 → memwal-0.1.11.dev2}/memwal/mock.py +0 -0
  17. {memwal-0.1.11.dev1 → memwal-0.1.11.dev2}/memwal/types.py +0 -0
  18. {memwal-0.1.11.dev1 → memwal-0.1.11.dev2}/memwal/utils.py +0 -0
  19. {memwal-0.1.11.dev1 → memwal-0.1.11.dev2}/notebooks/walrus_memory_python_sdk.ipynb +0 -0
  20. {memwal-0.1.11.dev1 → memwal-0.1.11.dev2}/run_tests.py +0 -0
  21. {memwal-0.1.11.dev1 → memwal-0.1.11.dev2}/tests/__init__.py +0 -0
  22. {memwal-0.1.11.dev1 → memwal-0.1.11.dev2}/tests/test_auth_rejected_message.py +0 -0
  23. {memwal-0.1.11.dev1 → memwal-0.1.11.dev2}/tests/test_client.py +0 -0
  24. {memwal-0.1.11.dev1 → memwal-0.1.11.dev2}/tests/test_env_presets.py +0 -0
  25. {memwal-0.1.11.dev1 → memwal-0.1.11.dev2}/tests/test_integration.py +0 -0
  26. {memwal-0.1.11.dev1 → memwal-0.1.11.dev2}/tests/test_list_namespaces.py +0 -0
  27. {memwal-0.1.11.dev1 → memwal-0.1.11.dev2}/tests/test_middleware.py +0 -0
  28. {memwal-0.1.11.dev1 → memwal-0.1.11.dev2}/tests/test_mock.py +0 -0
  29. {memwal-0.1.11.dev1 → memwal-0.1.11.dev2}/tests/test_normalize_server_url.py +0 -0
  30. {memwal-0.1.11.dev1 → memwal-0.1.11.dev2}/tests/test_signing.py +0 -0
@@ -1,5 +1,12 @@
1
1
  # memwal
2
2
 
3
+ ## 0.1.12
4
+
5
+ ### Fixed
6
+
7
+ - Grow `wait_for_remember_job` / `wait_for_remember_jobs` poll delay 1.5× from the caller interval (floor 100ms) toward a 5s cap, with an immediate first poll. Backoff is disabled above the cap. (WALM-623)
8
+ - A 429 during a remember-job wait is reported on the timeout. Retry-After is still clamped to the caller's remaining budget, and that remainder still buys one status read. (WALM-623)
9
+
3
10
  ## 0.1.11
4
11
 
5
12
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: memwal
3
- Version: 0.1.11.dev1
3
+ Version: 0.1.11.dev2
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
@@ -126,4 +126,4 @@ __all__ = [
126
126
  "RecallManualResult",
127
127
  ]
128
128
 
129
- __version__ = "0.1.11.dev1"
129
+ __version__ = "0.1.11.dev2"
@@ -170,18 +170,57 @@ async def _sleep_ms(ms: int) -> None:
170
170
 
171
171
 
172
172
  def _polling_delay_ms(base_ms: int, attempt: int) -> int:
173
- """Jittered exponential backoff matching TS ``pollingDelayMs``.
174
-
175
- base * 1.5^min(attempt, 6), capped at 10s, with ±25% jitter so
176
- concurrent clients don't synchronise.
177
- """
173
+ """Same formula as TS ``pollingDelayMs``."""
178
174
 
175
+ if attempt == 0:
176
+ return 0
179
177
  base = max(100, base_ms)
180
- capped = min(10_000, base * (1.5 ** min(attempt, 6)))
178
+ ceiling = max(5000, base)
179
+ capped = min(ceiling, base * (1.5 ** min(attempt - 1, 6)))
181
180
  jitter = 0.75 + random.random() * 0.5
182
181
  return int(capped * jitter)
183
182
 
184
183
 
184
+ def _positive_retry_ms(raw: object) -> Optional[int]:
185
+ try:
186
+ seconds = float(raw) # type: ignore[arg-type]
187
+ except (TypeError, ValueError):
188
+ return None
189
+ if seconds <= 0 or seconds != seconds or seconds == float("inf"):
190
+ return None
191
+ return int(seconds * 1000)
192
+
193
+
194
+ def _retry_after_ms(err: "_HttpStatusError") -> int:
195
+ """Retry-After seconds, else JSON ``retry_after_seconds``, in ms."""
196
+
197
+ if err.retry_after:
198
+ header_ms = _positive_retry_ms(err.retry_after)
199
+ if header_ms is not None:
200
+ return header_ms
201
+ if not err.body:
202
+ return 0
203
+ try:
204
+ payload = json.loads(err.body)
205
+ except json.JSONDecodeError:
206
+ return 0
207
+ if not isinstance(payload, dict):
208
+ return 0
209
+ return _positive_retry_ms(payload.get("retry_after_seconds")) or 0
210
+
211
+
212
+ def _clamped_retry_after_ms(err: "_HttpStatusError", deadline_ms: int) -> int:
213
+ """Clamp Retry-After to the time left. 0 means use the normal curve."""
214
+
215
+ delay = _retry_after_ms(err)
216
+ if delay <= 0:
217
+ return 0
218
+ remaining = deadline_ms - _now_ms()
219
+ if remaining <= 0:
220
+ return 0
221
+ return min(delay, remaining)
222
+
223
+
185
224
  def _is_transient_polling_status(status: int) -> bool:
186
225
  """Classify HTTP status codes for the polling retry loop.
187
226
 
@@ -423,17 +462,23 @@ class MemWal:
423
462
  - Accepts 200 + 404 from the status endpoint and dispatches on
424
463
  ``status`` field (404 / ``status == "not_found"`` raises).
425
464
  - Transient HTTP errors (429, 5xx, network drop) are retried until
426
- the timeout, not surfaced as polling failures.
427
- - Backoff is jittered exponential (1.5x cap 10s, ±25%) to avoid
428
- thundering-herd at scale.
465
+ the timeout, not surfaced as polling failures. A 429 waits out
466
+ Retry-After within the timeout; the timeout error says so.
467
+ - First poll is immediate; later polls grow 1.5x toward 5s.
429
468
  """
430
469
 
431
470
  deadline_ms = _now_ms() + timeout_ms
432
471
  attempt = 0
472
+ saw_rate_limit = False
473
+ retry_after_ms = 0
433
474
 
434
475
  while _now_ms() < deadline_ms:
435
- await _sleep_ms(_polling_delay_ms(poll_interval_ms, attempt))
436
- attempt += 1
476
+ if retry_after_ms > 0:
477
+ await _sleep_ms(retry_after_ms)
478
+ else:
479
+ await _sleep_ms(_polling_delay_ms(poll_interval_ms, attempt))
480
+ attempt += 1
481
+ retry_after_ms = 0
437
482
 
438
483
  try:
439
484
  data = await self._signed_request(
@@ -444,6 +489,9 @@ class MemWal:
444
489
  )
445
490
  except _HttpStatusError as err:
446
491
  if _is_transient_polling_status(err.status):
492
+ if err.status == 429:
493
+ saw_rate_limit = True
494
+ retry_after_ms = _clamped_retry_after_ms(err, deadline_ms)
447
495
  continue
448
496
  raise
449
497
 
@@ -466,7 +514,7 @@ class MemWal:
466
514
 
467
515
  # pending / running / uploaded — keep polling.
468
516
 
469
- raise MemWalRememberJobTimeout(job_id=job_id, timeout_ms=timeout_ms)
517
+ raise _remember_job_timeout(job_id, timeout_ms, saw_rate_limit)
470
518
 
471
519
  async def remember_and_wait(
472
520
  self,
@@ -612,15 +660,24 @@ class MemWal:
612
660
  }
613
661
  pending: List[str] = list(job_ids)
614
662
  attempt = 0
663
+ saw_rate_limit = False
664
+ retry_after_ms = 0
615
665
 
616
666
  while pending and _now_ms() < deadline_ms:
617
- await _sleep_ms(_polling_delay_ms(opts.poll_interval_ms, attempt))
618
- attempt += 1
667
+ if retry_after_ms > 0:
668
+ await _sleep_ms(retry_after_ms)
669
+ else:
670
+ await _sleep_ms(_polling_delay_ms(opts.poll_interval_ms, attempt))
671
+ attempt += 1
672
+ retry_after_ms = 0
619
673
 
620
674
  try:
621
675
  batch = await self.get_remember_bulk_status(pending)
622
676
  except _HttpStatusError as err:
623
677
  if _is_transient_polling_status(err.status):
678
+ if err.status == 429:
679
+ saw_rate_limit = True
680
+ retry_after_ms = _clamped_retry_after_ms(err, deadline_ms)
624
681
  continue
625
682
  raise
626
683
 
@@ -645,6 +702,13 @@ class MemWal:
645
702
  pending = [jid for jid in pending if jid not in terminal_ids]
646
703
 
647
704
  ordered = [results[job_id] for job_id in job_ids]
705
+ if saw_rate_limit:
706
+ for item in ordered:
707
+ if item.status == "timeout":
708
+ item.error = (
709
+ f"polling timed out after {opts.timeout_ms}ms; "
710
+ "wait hit a rate limit (429)"
711
+ )
648
712
  succeeded = sum(1 for r in ordered if r.status == "done")
649
713
  failed = sum(1 for r in ordered if r.status == "failed")
650
714
  timed_out = sum(1 for r in ordered if r.status == "timeout")
@@ -1549,6 +1613,15 @@ class MemWalRememberJobTimeout(MemWalError):
1549
1613
  self.timeout_ms = timeout_ms
1550
1614
 
1551
1615
 
1616
+ def _remember_job_timeout(
1617
+ job_id: str, timeout_ms: int, saw_rate_limit: bool
1618
+ ) -> MemWalRememberJobTimeout:
1619
+ exc = MemWalRememberJobTimeout(job_id, timeout_ms)
1620
+ if saw_rate_limit:
1621
+ exc.args = (f"{exc.args[0]}; wait hit a rate limit (429)",)
1622
+ return exc
1623
+
1624
+
1552
1625
  async def _discard_http_client(memwal: MemWal) -> None:
1553
1626
  """Close the cached httpx client, and drop it either way.
1554
1627
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "memwal"
7
- version = "0.1.11.dev1"
7
+ version = "0.1.11.dev2"
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,143 @@
1
+ """Parity with TS pollingDelayMs, plus 429 wait behavior."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import random
6
+ from types import SimpleNamespace
7
+
8
+ import pytest
9
+
10
+ from memwal.client import (
11
+ MemWal,
12
+ MemWalRememberJobTimeout,
13
+ _HttpStatusError,
14
+ _polling_delay_ms,
15
+ )
16
+
17
+
18
+ def test_attempt_0_is_immediate() -> None:
19
+ assert _polling_delay_ms(0, 0) == 0
20
+ assert _polling_delay_ms(5000, 0) == 0
21
+
22
+
23
+ def test_grows_1_5x_from_100ms_floor_toward_5s(monkeypatch: pytest.MonkeyPatch) -> None:
24
+ # client.py does `import random`, so patching memwal.client.random.random hits stdlib.
25
+ real_random = random.random
26
+ monkeypatch.setattr("memwal.client.random", SimpleNamespace(random=lambda: 0.5))
27
+ assert random.random is real_random
28
+ assert _polling_delay_ms(0, 1) == 100
29
+ assert _polling_delay_ms(50, 1) == 100
30
+ assert _polling_delay_ms(400, 1) == 400
31
+ assert _polling_delay_ms(400, 2) == 600
32
+ assert _polling_delay_ms(1500, 4) == 5000
33
+ assert _polling_delay_ms(1500, 20) == 5000
34
+ assert _polling_delay_ms(8000, 1) == 8000
35
+ assert _polling_delay_ms(8000, 15) == 8000
36
+
37
+
38
+ def _client() -> MemWal:
39
+ return MemWal.create(key="11" * 32, account_id="0x1", server_url="http://localhost:8000")
40
+
41
+
42
+ def _patch_clock(monkeypatch: pytest.MonkeyPatch) -> tuple[dict[str, int], list[int]]:
43
+ clock = {"now": 0}
44
+ slept: list[int] = []
45
+
46
+ def now_ms() -> int:
47
+ return clock["now"]
48
+
49
+ async def sleep_ms(ms: int) -> None:
50
+ slept.append(ms)
51
+ clock["now"] += ms
52
+
53
+ monkeypatch.setattr("memwal.client._now_ms", now_ms)
54
+ monkeypatch.setattr("memwal.client._sleep_ms", sleep_ms)
55
+ return clock, slept
56
+
57
+
58
+ def _rate_limit(seconds: str) -> _HttpStatusError:
59
+ return _HttpStatusError(
60
+ 429,
61
+ f'{{"retry_after_seconds": {seconds}}}',
62
+ retry_after=seconds,
63
+ )
64
+
65
+
66
+ @pytest.mark.asyncio
67
+ async def test_429_longer_than_budget_is_clamped_and_named(
68
+ monkeypatch: pytest.MonkeyPatch,
69
+ ) -> None:
70
+ clock, slept = _patch_clock(monkeypatch)
71
+ client = _client()
72
+ calls = {"n": 0}
73
+
74
+ async def status(*_args: object, **_kwargs: object) -> dict:
75
+ calls["n"] += 1
76
+ raise _rate_limit("600")
77
+
78
+ monkeypatch.setattr(client, "_signed_request", status)
79
+
80
+ with pytest.raises(MemWalRememberJobTimeout, match="429"):
81
+ await client.wait_for_remember_job("job-1", poll_interval_ms=1500, timeout_ms=800)
82
+
83
+ assert clock["now"] <= 800
84
+ assert slept == [0, 800]
85
+ assert calls["n"] == 2
86
+
87
+
88
+ @pytest.mark.asyncio
89
+ async def test_429_that_fits_delays_the_next_read(monkeypatch: pytest.MonkeyPatch) -> None:
90
+ _patch_clock(monkeypatch)
91
+ client = _client()
92
+ calls = {"n": 0}
93
+
94
+ async def status(*_args: object, **_kwargs: object) -> dict:
95
+ calls["n"] += 1
96
+ if calls["n"] == 1:
97
+ raise _rate_limit("0.05")
98
+ return {
99
+ "job_id": "job-1",
100
+ "status": "done",
101
+ "blob_id": "blob-1",
102
+ "owner": "0x1",
103
+ "namespace": "default",
104
+ }
105
+
106
+ monkeypatch.setattr(client, "_signed_request", status)
107
+ done = await client.wait_for_remember_job("job-1", poll_interval_ms=1500, timeout_ms=5_000)
108
+
109
+ assert done.blob_id == "blob-1"
110
+ assert calls["n"] == 2
111
+
112
+
113
+ @pytest.mark.asyncio
114
+ async def test_bulk_timeout_names_429_and_keeps_unreturned_jobs_pending(
115
+ monkeypatch: pytest.MonkeyPatch,
116
+ ) -> None:
117
+ _patch_clock(monkeypatch)
118
+ client = _client()
119
+ seen: list[list[str]] = []
120
+
121
+ async def status(job_ids: list[str]) -> SimpleNamespace:
122
+ seen.append(list(job_ids))
123
+ if len(seen) == 1:
124
+ return SimpleNamespace(
125
+ results=[
126
+ SimpleNamespace(job_id="job-1", status="done", blob_id="b1", error=None)
127
+ ]
128
+ )
129
+ raise _rate_limit("600")
130
+
131
+ monkeypatch.setattr(client, "get_remember_bulk_status", status)
132
+ settled = await client.wait_for_remember_jobs(
133
+ ["job-1", "job-2"],
134
+ SimpleNamespace(poll_interval_ms=1500, timeout_ms=800),
135
+ )
136
+
137
+ assert seen[0] == ["job-1", "job-2"]
138
+ assert seen[1] == ["job-2"]
139
+ assert settled.results[0].status == "done"
140
+ assert settled.results[1].status == "timeout"
141
+ assert settled.results[1].error == (
142
+ "polling timed out after 800ms; wait hit a rate limit (429)"
143
+ )
File without changes
File without changes
File without changes