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.
Files changed (30) hide show
  1. {memwal-0.1.11.dev3 → memwal-0.1.11.dev4}/CHANGELOG.md +6 -0
  2. {memwal-0.1.11.dev3 → memwal-0.1.11.dev4}/PKG-INFO +1 -1
  3. {memwal-0.1.11.dev3 → memwal-0.1.11.dev4}/memwal/__init__.py +3 -1
  4. {memwal-0.1.11.dev3 → memwal-0.1.11.dev4}/memwal/client.py +94 -25
  5. {memwal-0.1.11.dev3 → memwal-0.1.11.dev4}/pyproject.toml +1 -1
  6. {memwal-0.1.11.dev3 → memwal-0.1.11.dev4}/tests/test_client.py +96 -0
  7. {memwal-0.1.11.dev3 → memwal-0.1.11.dev4}/tests/test_polling_delay.py +2 -1
  8. {memwal-0.1.11.dev3 → memwal-0.1.11.dev4}/.gitignore +0 -0
  9. {memwal-0.1.11.dev3 → memwal-0.1.11.dev4}/README.md +0 -0
  10. {memwal-0.1.11.dev3 → memwal-0.1.11.dev4}/examples/.env.example +0 -0
  11. {memwal-0.1.11.dev3 → memwal-0.1.11.dev4}/examples/.gitignore +0 -0
  12. {memwal-0.1.11.dev3 → memwal-0.1.11.dev4}/examples/async_remember_demo.py +0 -0
  13. {memwal-0.1.11.dev3 → memwal-0.1.11.dev4}/examples/interactive_demo.py +0 -0
  14. {memwal-0.1.11.dev3 → memwal-0.1.11.dev4}/examples/verify_credentials.py +0 -0
  15. {memwal-0.1.11.dev3 → memwal-0.1.11.dev4}/memwal/compatibility.py +0 -0
  16. {memwal-0.1.11.dev3 → memwal-0.1.11.dev4}/memwal/middleware.py +0 -0
  17. {memwal-0.1.11.dev3 → memwal-0.1.11.dev4}/memwal/mock.py +0 -0
  18. {memwal-0.1.11.dev3 → memwal-0.1.11.dev4}/memwal/types.py +0 -0
  19. {memwal-0.1.11.dev3 → memwal-0.1.11.dev4}/memwal/utils.py +0 -0
  20. {memwal-0.1.11.dev3 → memwal-0.1.11.dev4}/notebooks/walrus_memory_python_sdk.ipynb +0 -0
  21. {memwal-0.1.11.dev3 → memwal-0.1.11.dev4}/run_tests.py +0 -0
  22. {memwal-0.1.11.dev3 → memwal-0.1.11.dev4}/tests/__init__.py +0 -0
  23. {memwal-0.1.11.dev3 → memwal-0.1.11.dev4}/tests/test_auth_rejected_message.py +0 -0
  24. {memwal-0.1.11.dev3 → memwal-0.1.11.dev4}/tests/test_env_presets.py +0 -0
  25. {memwal-0.1.11.dev3 → memwal-0.1.11.dev4}/tests/test_integration.py +0 -0
  26. {memwal-0.1.11.dev3 → memwal-0.1.11.dev4}/tests/test_list_namespaces.py +0 -0
  27. {memwal-0.1.11.dev3 → memwal-0.1.11.dev4}/tests/test_middleware.py +0 -0
  28. {memwal-0.1.11.dev3 → memwal-0.1.11.dev4}/tests/test_mock.py +0 -0
  29. {memwal-0.1.11.dev3 → memwal-0.1.11.dev4}/tests/test_normalize_server_url.py +0 -0
  30. {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.dev3
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.dev3"
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] = set()
685
- for item in batch.results:
686
- if item.status == "done":
687
- results[item.job_id] = RememberBulkItemResult(
688
- id=item.job_id,
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
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "memwal"
7
- version = "0.1.11.dev3"
7
+ version = "0.1.11.dev4"
8
8
  description = "Python SDK for Walrus Memory — Privacy-first AI memory with Ed25519 signing"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -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
- "polling timed out after 800ms; wait hit a rate limit (429)"
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