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.
- {memwal-0.1.11.dev7 → memwal-0.1.11.dev9}/PKG-INFO +1 -1
- {memwal-0.1.11.dev7 → memwal-0.1.11.dev9}/memwal/__init__.py +1 -1
- {memwal-0.1.11.dev7 → memwal-0.1.11.dev9}/memwal/client.py +118 -18
- {memwal-0.1.11.dev7 → memwal-0.1.11.dev9}/pyproject.toml +1 -1
- memwal-0.1.11.dev9/tests/test_remember_derived_key.py +250 -0
- memwal-0.1.11.dev9/tests/test_retry_after_http_date.py +160 -0
- memwal-0.1.11.dev9/tests/test_transport_retry.py +195 -0
- {memwal-0.1.11.dev7 → memwal-0.1.11.dev9}/.gitignore +0 -0
- {memwal-0.1.11.dev7 → memwal-0.1.11.dev9}/CHANGELOG.md +0 -0
- {memwal-0.1.11.dev7 → memwal-0.1.11.dev9}/README.md +0 -0
- {memwal-0.1.11.dev7 → memwal-0.1.11.dev9}/examples/.env.example +0 -0
- {memwal-0.1.11.dev7 → memwal-0.1.11.dev9}/examples/.gitignore +0 -0
- {memwal-0.1.11.dev7 → memwal-0.1.11.dev9}/examples/async_remember_demo.py +0 -0
- {memwal-0.1.11.dev7 → memwal-0.1.11.dev9}/examples/interactive_demo.py +0 -0
- {memwal-0.1.11.dev7 → memwal-0.1.11.dev9}/examples/verify_credentials.py +0 -0
- {memwal-0.1.11.dev7 → memwal-0.1.11.dev9}/memwal/compatibility.py +0 -0
- {memwal-0.1.11.dev7 → memwal-0.1.11.dev9}/memwal/middleware.py +0 -0
- {memwal-0.1.11.dev7 → memwal-0.1.11.dev9}/memwal/mock.py +0 -0
- {memwal-0.1.11.dev7 → memwal-0.1.11.dev9}/memwal/types.py +0 -0
- {memwal-0.1.11.dev7 → memwal-0.1.11.dev9}/memwal/utils.py +0 -0
- {memwal-0.1.11.dev7 → memwal-0.1.11.dev9}/notebooks/walrus_memory_python_sdk.ipynb +0 -0
- {memwal-0.1.11.dev7 → memwal-0.1.11.dev9}/run_tests.py +0 -0
- {memwal-0.1.11.dev7 → memwal-0.1.11.dev9}/tests/__init__.py +0 -0
- {memwal-0.1.11.dev7 → memwal-0.1.11.dev9}/tests/test_auth_rejected_message.py +0 -0
- {memwal-0.1.11.dev7 → memwal-0.1.11.dev9}/tests/test_blank_text.py +0 -0
- {memwal-0.1.11.dev7 → memwal-0.1.11.dev9}/tests/test_client.py +0 -0
- {memwal-0.1.11.dev7 → memwal-0.1.11.dev9}/tests/test_env_presets.py +0 -0
- {memwal-0.1.11.dev7 → memwal-0.1.11.dev9}/tests/test_integration.py +0 -0
- {memwal-0.1.11.dev7 → memwal-0.1.11.dev9}/tests/test_list_namespaces.py +0 -0
- {memwal-0.1.11.dev7 → memwal-0.1.11.dev9}/tests/test_middleware.py +0 -0
- {memwal-0.1.11.dev7 → memwal-0.1.11.dev9}/tests/test_mock.py +0 -0
- {memwal-0.1.11.dev7 → memwal-0.1.11.dev9}/tests/test_normalize_server_url.py +0 -0
- {memwal-0.1.11.dev7 → memwal-0.1.11.dev9}/tests/test_polling_delay.py +0 -0
- {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.
|
|
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
|
|
@@ -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
|
|
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 =
|
|
435
|
-
|
|
436
|
-
|
|
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
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
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 =
|
|
565
|
-
|
|
566
|
-
|
|
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,
|
|
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
|
-
|
|
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:
|
|
@@ -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
|
|
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
|