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.
- {memwal-0.1.11.dev1 → memwal-0.1.11.dev2}/CHANGELOG.md +7 -0
- {memwal-0.1.11.dev1 → memwal-0.1.11.dev2}/PKG-INFO +1 -1
- {memwal-0.1.11.dev1 → memwal-0.1.11.dev2}/memwal/__init__.py +1 -1
- {memwal-0.1.11.dev1 → memwal-0.1.11.dev2}/memwal/client.py +87 -14
- {memwal-0.1.11.dev1 → memwal-0.1.11.dev2}/pyproject.toml +1 -1
- memwal-0.1.11.dev2/tests/test_polling_delay.py +143 -0
- {memwal-0.1.11.dev1 → memwal-0.1.11.dev2}/.gitignore +0 -0
- {memwal-0.1.11.dev1 → memwal-0.1.11.dev2}/README.md +0 -0
- {memwal-0.1.11.dev1 → memwal-0.1.11.dev2}/examples/.env.example +0 -0
- {memwal-0.1.11.dev1 → memwal-0.1.11.dev2}/examples/.gitignore +0 -0
- {memwal-0.1.11.dev1 → memwal-0.1.11.dev2}/examples/async_remember_demo.py +0 -0
- {memwal-0.1.11.dev1 → memwal-0.1.11.dev2}/examples/interactive_demo.py +0 -0
- {memwal-0.1.11.dev1 → memwal-0.1.11.dev2}/examples/verify_credentials.py +0 -0
- {memwal-0.1.11.dev1 → memwal-0.1.11.dev2}/memwal/compatibility.py +0 -0
- {memwal-0.1.11.dev1 → memwal-0.1.11.dev2}/memwal/middleware.py +0 -0
- {memwal-0.1.11.dev1 → memwal-0.1.11.dev2}/memwal/mock.py +0 -0
- {memwal-0.1.11.dev1 → memwal-0.1.11.dev2}/memwal/types.py +0 -0
- {memwal-0.1.11.dev1 → memwal-0.1.11.dev2}/memwal/utils.py +0 -0
- {memwal-0.1.11.dev1 → memwal-0.1.11.dev2}/notebooks/walrus_memory_python_sdk.ipynb +0 -0
- {memwal-0.1.11.dev1 → memwal-0.1.11.dev2}/run_tests.py +0 -0
- {memwal-0.1.11.dev1 → memwal-0.1.11.dev2}/tests/__init__.py +0 -0
- {memwal-0.1.11.dev1 → memwal-0.1.11.dev2}/tests/test_auth_rejected_message.py +0 -0
- {memwal-0.1.11.dev1 → memwal-0.1.11.dev2}/tests/test_client.py +0 -0
- {memwal-0.1.11.dev1 → memwal-0.1.11.dev2}/tests/test_env_presets.py +0 -0
- {memwal-0.1.11.dev1 → memwal-0.1.11.dev2}/tests/test_integration.py +0 -0
- {memwal-0.1.11.dev1 → memwal-0.1.11.dev2}/tests/test_list_namespaces.py +0 -0
- {memwal-0.1.11.dev1 → memwal-0.1.11.dev2}/tests/test_middleware.py +0 -0
- {memwal-0.1.11.dev1 → memwal-0.1.11.dev2}/tests/test_mock.py +0 -0
- {memwal-0.1.11.dev1 → memwal-0.1.11.dev2}/tests/test_normalize_server_url.py +0 -0
- {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.
|
|
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
|
|
@@ -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
|
-
"""
|
|
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
|
-
|
|
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
|
-
|
|
428
|
-
|
|
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
|
-
|
|
436
|
-
|
|
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
|
|
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
|
-
|
|
618
|
-
|
|
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
|
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|