memwal 0.1.11.dev3__tar.gz → 0.1.11.dev4__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.dev3 → memwal-0.1.11.dev4}/CHANGELOG.md +6 -0
- {memwal-0.1.11.dev3 → memwal-0.1.11.dev4}/PKG-INFO +1 -1
- {memwal-0.1.11.dev3 → memwal-0.1.11.dev4}/memwal/__init__.py +3 -1
- {memwal-0.1.11.dev3 → memwal-0.1.11.dev4}/memwal/client.py +94 -25
- {memwal-0.1.11.dev3 → memwal-0.1.11.dev4}/pyproject.toml +1 -1
- {memwal-0.1.11.dev3 → memwal-0.1.11.dev4}/tests/test_client.py +96 -0
- {memwal-0.1.11.dev3 → memwal-0.1.11.dev4}/tests/test_polling_delay.py +2 -1
- {memwal-0.1.11.dev3 → memwal-0.1.11.dev4}/.gitignore +0 -0
- {memwal-0.1.11.dev3 → memwal-0.1.11.dev4}/README.md +0 -0
- {memwal-0.1.11.dev3 → memwal-0.1.11.dev4}/examples/.env.example +0 -0
- {memwal-0.1.11.dev3 → memwal-0.1.11.dev4}/examples/.gitignore +0 -0
- {memwal-0.1.11.dev3 → memwal-0.1.11.dev4}/examples/async_remember_demo.py +0 -0
- {memwal-0.1.11.dev3 → memwal-0.1.11.dev4}/examples/interactive_demo.py +0 -0
- {memwal-0.1.11.dev3 → memwal-0.1.11.dev4}/examples/verify_credentials.py +0 -0
- {memwal-0.1.11.dev3 → memwal-0.1.11.dev4}/memwal/compatibility.py +0 -0
- {memwal-0.1.11.dev3 → memwal-0.1.11.dev4}/memwal/middleware.py +0 -0
- {memwal-0.1.11.dev3 → memwal-0.1.11.dev4}/memwal/mock.py +0 -0
- {memwal-0.1.11.dev3 → memwal-0.1.11.dev4}/memwal/types.py +0 -0
- {memwal-0.1.11.dev3 → memwal-0.1.11.dev4}/memwal/utils.py +0 -0
- {memwal-0.1.11.dev3 → memwal-0.1.11.dev4}/notebooks/walrus_memory_python_sdk.ipynb +0 -0
- {memwal-0.1.11.dev3 → memwal-0.1.11.dev4}/run_tests.py +0 -0
- {memwal-0.1.11.dev3 → memwal-0.1.11.dev4}/tests/__init__.py +0 -0
- {memwal-0.1.11.dev3 → memwal-0.1.11.dev4}/tests/test_auth_rejected_message.py +0 -0
- {memwal-0.1.11.dev3 → memwal-0.1.11.dev4}/tests/test_env_presets.py +0 -0
- {memwal-0.1.11.dev3 → memwal-0.1.11.dev4}/tests/test_integration.py +0 -0
- {memwal-0.1.11.dev3 → memwal-0.1.11.dev4}/tests/test_list_namespaces.py +0 -0
- {memwal-0.1.11.dev3 → memwal-0.1.11.dev4}/tests/test_middleware.py +0 -0
- {memwal-0.1.11.dev3 → memwal-0.1.11.dev4}/tests/test_mock.py +0 -0
- {memwal-0.1.11.dev3 → memwal-0.1.11.dev4}/tests/test_normalize_server_url.py +0 -0
- {memwal-0.1.11.dev3 → memwal-0.1.11.dev4}/tests/test_signing.py +0 -0
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# memwal
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- `wait_for_remember_jobs` / `remember_bulk_and_wait` no longer return a batch they could not read as if it were still uploading. When every status poll is rate-limited, the wait makes one confirming read and raises `MemWalRateLimited` (`status` 429, `job_ids`, `retry_after`) if that is refused too. An item still unsettled at the deadline names its last known status, or says no read got through. (WALM-671, #967)
|
|
8
|
+
|
|
3
9
|
## 0.1.12
|
|
4
10
|
|
|
5
11
|
### Fixed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: memwal
|
|
3
|
-
Version: 0.1.11.
|
|
3
|
+
Version: 0.1.11.dev4
|
|
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,6 +27,7 @@ from .client import (
|
|
|
27
27
|
MemWalClockDriftError,
|
|
28
28
|
MemWalCompatibilityError,
|
|
29
29
|
MemWalError,
|
|
30
|
+
MemWalRateLimited,
|
|
30
31
|
MemWalRememberJobFailed,
|
|
31
32
|
MemWalRememberJobNotFound,
|
|
32
33
|
MemWalRememberJobTimeout,
|
|
@@ -82,6 +83,7 @@ __all__ = [
|
|
|
82
83
|
"MemWalError",
|
|
83
84
|
"MemWalCompatibilityError",
|
|
84
85
|
"MemWalClockDriftError",
|
|
86
|
+
"MemWalRateLimited",
|
|
85
87
|
"MemWalRememberJobFailed",
|
|
86
88
|
"MemWalRememberJobNotFound",
|
|
87
89
|
"MemWalRememberJobTimeout",
|
|
@@ -126,4 +128,4 @@ __all__ = [
|
|
|
126
128
|
"RecallManualResult",
|
|
127
129
|
]
|
|
128
130
|
|
|
129
|
-
__version__ = "0.1.11.
|
|
131
|
+
__version__ = "0.1.11.dev4"
|
|
@@ -662,6 +662,31 @@ class MemWal:
|
|
|
662
662
|
attempt = 0
|
|
663
663
|
saw_rate_limit = False
|
|
664
664
|
retry_after_ms = 0
|
|
665
|
+
last_seen: Dict[str, RememberBulkStatusItem] = {}
|
|
666
|
+
last_refusal: Optional[int] = None
|
|
667
|
+
# Whether any status read got through. An item missing from an answer
|
|
668
|
+
# was not refused, so it must not be reported as if it had been.
|
|
669
|
+
answered = False
|
|
670
|
+
|
|
671
|
+
def settle(item: RememberBulkStatusItem) -> bool:
|
|
672
|
+
last_seen[item.job_id] = item
|
|
673
|
+
if item.status == "done":
|
|
674
|
+
results[item.job_id] = RememberBulkItemResult(
|
|
675
|
+
id=item.job_id,
|
|
676
|
+
blob_id=item.blob_id or "",
|
|
677
|
+
status="done",
|
|
678
|
+
error=None,
|
|
679
|
+
)
|
|
680
|
+
return True
|
|
681
|
+
if item.status in ("failed", "not_found"):
|
|
682
|
+
results[item.job_id] = RememberBulkItemResult(
|
|
683
|
+
id=item.job_id,
|
|
684
|
+
blob_id=item.blob_id or "",
|
|
685
|
+
status="failed",
|
|
686
|
+
error=item.error,
|
|
687
|
+
)
|
|
688
|
+
return True
|
|
689
|
+
return False
|
|
665
690
|
|
|
666
691
|
while pending and _now_ms() < deadline_ms:
|
|
667
692
|
if retry_after_ms > 0:
|
|
@@ -675,40 +700,66 @@ class MemWal:
|
|
|
675
700
|
batch = await self.get_remember_bulk_status(pending)
|
|
676
701
|
except _HttpStatusError as err:
|
|
677
702
|
if _is_transient_polling_status(err.status):
|
|
703
|
+
last_refusal = err.status
|
|
678
704
|
if err.status == 429:
|
|
679
705
|
saw_rate_limit = True
|
|
680
706
|
retry_after_ms = _clamped_retry_after_ms(err, deadline_ms)
|
|
681
707
|
continue
|
|
682
708
|
raise
|
|
709
|
+
answered = True
|
|
683
710
|
|
|
684
|
-
terminal_ids: Set[str] =
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
blob_id=item.blob_id or "",
|
|
690
|
-
status="done",
|
|
691
|
-
error=None,
|
|
692
|
-
)
|
|
693
|
-
terminal_ids.add(item.job_id)
|
|
694
|
-
elif item.status in ("failed", "not_found"):
|
|
695
|
-
results[item.job_id] = RememberBulkItemResult(
|
|
696
|
-
id=item.job_id,
|
|
697
|
-
blob_id=item.blob_id or "",
|
|
698
|
-
status="failed",
|
|
699
|
-
error=item.error,
|
|
700
|
-
)
|
|
701
|
-
terminal_ids.add(item.job_id)
|
|
711
|
+
terminal_ids: Set[str] = {
|
|
712
|
+
item.job_id
|
|
713
|
+
for item in batch.results
|
|
714
|
+
if item.job_id in pending and settle(item)
|
|
715
|
+
}
|
|
702
716
|
pending = [jid for jid in pending if jid not in terminal_ids]
|
|
703
717
|
|
|
718
|
+
if opts.timeout_ms > 0 and len(pending) == len(job_ids) and not last_seen:
|
|
719
|
+
# Nothing settled and nothing was seen: the polls may all have been
|
|
720
|
+
# refused. Confirm with one direct read before reporting anything.
|
|
721
|
+
try:
|
|
722
|
+
probed = await self.get_remember_bulk_status(list(job_ids))
|
|
723
|
+
except _HttpStatusError as err:
|
|
724
|
+
if err.status == 429:
|
|
725
|
+
# Header first, else the body's retry_after_seconds, so a
|
|
726
|
+
# proxy that strips Retry-After does not lose the hint.
|
|
727
|
+
wait_ms = _retry_after_ms(err)
|
|
728
|
+
retry_after = f"{wait_ms / 1000:g}" if wait_ms else None
|
|
729
|
+
raise MemWalRateLimited(job_ids, retry_after) from err
|
|
730
|
+
# Any other probe failure is not evidence about the jobs.
|
|
731
|
+
else:
|
|
732
|
+
answered = True
|
|
733
|
+
settled = {
|
|
734
|
+
item.job_id
|
|
735
|
+
for item in probed.results
|
|
736
|
+
if item.job_id in pending and settle(item)
|
|
737
|
+
}
|
|
738
|
+
pending = [jid for jid in pending if jid not in settled]
|
|
739
|
+
|
|
740
|
+
for job_id in pending:
|
|
741
|
+
seen = last_seen.get(job_id)
|
|
742
|
+
if seen is not None:
|
|
743
|
+
suffix = f": {_redact_internal_urls(seen.error)}" if seen.error else ""
|
|
744
|
+
error = f"still {seen.status} after {opts.timeout_ms}ms{suffix}"
|
|
745
|
+
elif answered:
|
|
746
|
+
error = "not in the relayer's status answer; this item may not be stored"
|
|
747
|
+
elif last_refusal is not None:
|
|
748
|
+
error = (
|
|
749
|
+
f"no status read got through (last poll: HTTP {last_refusal}); "
|
|
750
|
+
"this item may not be stored"
|
|
751
|
+
)
|
|
752
|
+
else:
|
|
753
|
+
error = f"polling timed out after {opts.timeout_ms}ms"
|
|
754
|
+
# Only the "no status read got through" message already names the 429.
|
|
755
|
+
names_rate_limit = seen is None and not answered and last_refusal == 429
|
|
756
|
+
if saw_rate_limit and not names_rate_limit:
|
|
757
|
+
error += "; wait hit a rate limit (429)"
|
|
758
|
+
results[job_id] = RememberBulkItemResult(
|
|
759
|
+
id=job_id, blob_id="", status="timeout", error=error
|
|
760
|
+
)
|
|
761
|
+
|
|
704
762
|
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
|
-
)
|
|
712
763
|
succeeded = sum(1 for r in ordered if r.status == "done")
|
|
713
764
|
failed = sum(1 for r in ordered if r.status == "failed")
|
|
714
765
|
timed_out = sum(1 for r in ordered if r.status == "timeout")
|
|
@@ -1601,6 +1652,24 @@ class MemWalRememberJobFailed(MemWalError):
|
|
|
1601
1652
|
self.error = error
|
|
1602
1653
|
|
|
1603
1654
|
|
|
1655
|
+
class MemWalRateLimited(MemWalError):
|
|
1656
|
+
"""Every status read for a bulk wait was rate-limited, the confirming read
|
|
1657
|
+
included, so none of its writes could be confirmed (GH #967). Status reads
|
|
1658
|
+
count against the same delegate-key budget as writes."""
|
|
1659
|
+
|
|
1660
|
+
def __init__(self, job_ids: Sequence[str], retry_after: Optional[str] = None) -> None:
|
|
1661
|
+
wait = f" after ~{retry_after}s" if retry_after else ""
|
|
1662
|
+
super().__init__(
|
|
1663
|
+
f"Walrus Memory rate-limited every status read for this batch, so none of its "
|
|
1664
|
+
f"{len(job_ids)} writes could be confirmed. Do not treat them as stored: check them "
|
|
1665
|
+
f"with get_remember_bulk_status(job_ids){wait}. Status reads count against the same "
|
|
1666
|
+
"delegate-key budget as writes."
|
|
1667
|
+
)
|
|
1668
|
+
self.status = 429
|
|
1669
|
+
self.job_ids = list(job_ids)
|
|
1670
|
+
self.retry_after = retry_after
|
|
1671
|
+
|
|
1672
|
+
|
|
1604
1673
|
class MemWalRememberJobTimeout(MemWalError):
|
|
1605
1674
|
"""Polling loop exceeded the configured timeout."""
|
|
1606
1675
|
|
|
@@ -9,6 +9,7 @@ from __future__ import annotations
|
|
|
9
9
|
|
|
10
10
|
import base64
|
|
11
11
|
import json
|
|
12
|
+
import time
|
|
12
13
|
from datetime import datetime, timedelta, timezone
|
|
13
14
|
from typing import Any, Sequence
|
|
14
15
|
|
|
@@ -22,7 +23,9 @@ from memwal.client import (
|
|
|
22
23
|
MemWalClockDriftError,
|
|
23
24
|
MemWalCompatibilityError,
|
|
24
25
|
MemWalError,
|
|
26
|
+
MemWalRateLimited,
|
|
25
27
|
MemWalSync,
|
|
28
|
+
_HttpStatusError,
|
|
26
29
|
)
|
|
27
30
|
from memwal.types import (
|
|
28
31
|
RecallManualOptions,
|
|
@@ -462,6 +465,99 @@ class TestRememberBulkAsync:
|
|
|
462
465
|
assert [item.status for item in result.results] == ["done", "done"]
|
|
463
466
|
|
|
464
467
|
|
|
468
|
+
class TestBulkWaitUnderRateLimit:
|
|
469
|
+
async def test_every_read_refused_raises_rate_limited(
|
|
470
|
+
self, memwal_client: MemWal
|
|
471
|
+
) -> None:
|
|
472
|
+
ids = [f"job-{i}" for i in range(12)]
|
|
473
|
+
|
|
474
|
+
async def refused(job_ids: Sequence[str]) -> RememberBulkStatusResult:
|
|
475
|
+
raise _HttpStatusError(429, '{"error":"rate limited"}', retry_after="30")
|
|
476
|
+
|
|
477
|
+
memwal_client.get_remember_bulk_status = refused # type: ignore[method-assign]
|
|
478
|
+
with pytest.raises(MemWalRateLimited) as exc:
|
|
479
|
+
await memwal_client.wait_for_remember_jobs(
|
|
480
|
+
ids, RememberBulkOptions(poll_interval_ms=1, timeout_ms=300)
|
|
481
|
+
)
|
|
482
|
+
assert exc.value.status == 429
|
|
483
|
+
assert exc.value.job_ids == ids
|
|
484
|
+
assert exc.value.retry_after == "30"
|
|
485
|
+
assert "none of its 12 writes could be confirmed" in str(exc.value)
|
|
486
|
+
|
|
487
|
+
async def test_rate_limited_reads_the_body_when_the_header_is_stripped(
|
|
488
|
+
self, memwal_client: MemWal
|
|
489
|
+
) -> None:
|
|
490
|
+
async def refused(job_ids: Sequence[str]) -> RememberBulkStatusResult:
|
|
491
|
+
raise _HttpStatusError(429, '{"retry_after_seconds":45}')
|
|
492
|
+
|
|
493
|
+
memwal_client.get_remember_bulk_status = refused # type: ignore[method-assign]
|
|
494
|
+
with pytest.raises(MemWalRateLimited) as exc:
|
|
495
|
+
await memwal_client.wait_for_remember_jobs(
|
|
496
|
+
["job-1"], RememberBulkOptions(poll_interval_ms=1, timeout_ms=300)
|
|
497
|
+
)
|
|
498
|
+
assert exc.value.retry_after == "45"
|
|
499
|
+
assert "after ~45s" in str(exc.value)
|
|
500
|
+
|
|
501
|
+
async def test_confirming_read_settles_a_refused_wait(
|
|
502
|
+
self, memwal_client: MemWal
|
|
503
|
+
) -> None:
|
|
504
|
+
start = time.monotonic()
|
|
505
|
+
|
|
506
|
+
async def probe_answers(job_ids: Sequence[str]) -> RememberBulkStatusResult:
|
|
507
|
+
# Refused for the whole 250ms wait; the read after the deadline is
|
|
508
|
+
# the probe. Time-based because jitter varies the poll count.
|
|
509
|
+
if time.monotonic() - start < 0.25:
|
|
510
|
+
raise _HttpStatusError(429, "{}")
|
|
511
|
+
return RememberBulkStatusResult(
|
|
512
|
+
results=[
|
|
513
|
+
RememberBulkStatusItem(job_id="job-a", status="done", blob_id="blob-a"),
|
|
514
|
+
RememberBulkStatusItem(
|
|
515
|
+
job_id="job-b", status="failed", error="walrus upload failed"
|
|
516
|
+
),
|
|
517
|
+
]
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
memwal_client.get_remember_bulk_status = probe_answers # type: ignore[method-assign]
|
|
521
|
+
out = await memwal_client.wait_for_remember_jobs(
|
|
522
|
+
["job-a", "job-b", "job-c"],
|
|
523
|
+
RememberBulkOptions(poll_interval_ms=1, timeout_ms=250),
|
|
524
|
+
)
|
|
525
|
+
assert [r.status for r in out.results] == ["done", "failed", "timeout"]
|
|
526
|
+
# A read did get through; it just left job-c out, so saying no read
|
|
527
|
+
# got through would be false.
|
|
528
|
+
assert "not in the relayer's status answer" in (out.results[2].error or "")
|
|
529
|
+
|
|
530
|
+
async def test_partly_settled_batch_is_returned(self, memwal_client: MemWal) -> None:
|
|
531
|
+
reads = 0
|
|
532
|
+
|
|
533
|
+
async def then_refused(job_ids: Sequence[str]) -> RememberBulkStatusResult:
|
|
534
|
+
nonlocal reads
|
|
535
|
+
reads += 1
|
|
536
|
+
if reads == 1:
|
|
537
|
+
return RememberBulkStatusResult(
|
|
538
|
+
results=[
|
|
539
|
+
RememberBulkStatusItem(
|
|
540
|
+
job_id="job-a", status="done", blob_id="blob-a"
|
|
541
|
+
),
|
|
542
|
+
RememberBulkStatusItem(
|
|
543
|
+
job_id="job-b",
|
|
544
|
+
status="running",
|
|
545
|
+
error="still retrying this upload",
|
|
546
|
+
),
|
|
547
|
+
]
|
|
548
|
+
)
|
|
549
|
+
raise _HttpStatusError(429, "{}")
|
|
550
|
+
|
|
551
|
+
memwal_client.get_remember_bulk_status = then_refused # type: ignore[method-assign]
|
|
552
|
+
out = await memwal_client.wait_for_remember_jobs(
|
|
553
|
+
["job-a", "job-b"], RememberBulkOptions(poll_interval_ms=1, timeout_ms=300)
|
|
554
|
+
)
|
|
555
|
+
assert [r.status for r in out.results] == ["done", "timeout"]
|
|
556
|
+
assert "still running after 300ms: still retrying this upload" in (
|
|
557
|
+
out.results[1].error or ""
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
|
|
465
561
|
# ============================================================
|
|
466
562
|
# recall() tests
|
|
467
563
|
# ============================================================
|
|
@@ -139,5 +139,6 @@ async def test_bulk_timeout_names_429_and_keeps_unreturned_jobs_pending(
|
|
|
139
139
|
assert settled.results[0].status == "done"
|
|
140
140
|
assert settled.results[1].status == "timeout"
|
|
141
141
|
assert settled.results[1].error == (
|
|
142
|
-
"
|
|
142
|
+
"not in the relayer's status answer; this item may not be stored; "
|
|
143
|
+
"wait hit a rate limit (429)"
|
|
143
144
|
)
|
|
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
|