memwal 0.1.11.dev3__tar.gz → 0.1.11.dev5__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 (31) hide show
  1. {memwal-0.1.11.dev3 → memwal-0.1.11.dev5}/CHANGELOG.md +8 -0
  2. {memwal-0.1.11.dev3 → memwal-0.1.11.dev5}/PKG-INFO +1 -1
  3. {memwal-0.1.11.dev3 → memwal-0.1.11.dev5}/memwal/__init__.py +3 -1
  4. {memwal-0.1.11.dev3 → memwal-0.1.11.dev5}/memwal/client.py +113 -26
  5. {memwal-0.1.11.dev3 → memwal-0.1.11.dev5}/pyproject.toml +1 -1
  6. {memwal-0.1.11.dev3 → memwal-0.1.11.dev5}/tests/test_auth_rejected_message.py +1 -0
  7. memwal-0.1.11.dev5/tests/test_blank_text.py +22 -0
  8. {memwal-0.1.11.dev3 → memwal-0.1.11.dev5}/tests/test_client.py +96 -0
  9. {memwal-0.1.11.dev3 → memwal-0.1.11.dev5}/tests/test_polling_delay.py +2 -1
  10. {memwal-0.1.11.dev3 → memwal-0.1.11.dev5}/.gitignore +0 -0
  11. {memwal-0.1.11.dev3 → memwal-0.1.11.dev5}/README.md +0 -0
  12. {memwal-0.1.11.dev3 → memwal-0.1.11.dev5}/examples/.env.example +0 -0
  13. {memwal-0.1.11.dev3 → memwal-0.1.11.dev5}/examples/.gitignore +0 -0
  14. {memwal-0.1.11.dev3 → memwal-0.1.11.dev5}/examples/async_remember_demo.py +0 -0
  15. {memwal-0.1.11.dev3 → memwal-0.1.11.dev5}/examples/interactive_demo.py +0 -0
  16. {memwal-0.1.11.dev3 → memwal-0.1.11.dev5}/examples/verify_credentials.py +0 -0
  17. {memwal-0.1.11.dev3 → memwal-0.1.11.dev5}/memwal/compatibility.py +0 -0
  18. {memwal-0.1.11.dev3 → memwal-0.1.11.dev5}/memwal/middleware.py +0 -0
  19. {memwal-0.1.11.dev3 → memwal-0.1.11.dev5}/memwal/mock.py +0 -0
  20. {memwal-0.1.11.dev3 → memwal-0.1.11.dev5}/memwal/types.py +0 -0
  21. {memwal-0.1.11.dev3 → memwal-0.1.11.dev5}/memwal/utils.py +0 -0
  22. {memwal-0.1.11.dev3 → memwal-0.1.11.dev5}/notebooks/walrus_memory_python_sdk.ipynb +0 -0
  23. {memwal-0.1.11.dev3 → memwal-0.1.11.dev5}/run_tests.py +0 -0
  24. {memwal-0.1.11.dev3 → memwal-0.1.11.dev5}/tests/__init__.py +0 -0
  25. {memwal-0.1.11.dev3 → memwal-0.1.11.dev5}/tests/test_env_presets.py +0 -0
  26. {memwal-0.1.11.dev3 → memwal-0.1.11.dev5}/tests/test_integration.py +0 -0
  27. {memwal-0.1.11.dev3 → memwal-0.1.11.dev5}/tests/test_list_namespaces.py +0 -0
  28. {memwal-0.1.11.dev3 → memwal-0.1.11.dev5}/tests/test_middleware.py +0 -0
  29. {memwal-0.1.11.dev3 → memwal-0.1.11.dev5}/tests/test_mock.py +0 -0
  30. {memwal-0.1.11.dev3 → memwal-0.1.11.dev5}/tests/test_normalize_server_url.py +0 -0
  31. {memwal-0.1.11.dev3 → memwal-0.1.11.dev5}/tests/test_signing.py +0 -0
@@ -1,5 +1,13 @@
1
1
  # memwal
2
2
 
3
+ ## Unreleased
4
+
5
+ ### Fixed
6
+
7
+ - `remember`, `remember_bulk`, `analyze`, and `embed` reject whitespace-only text before the request. A blank body used to be accepted and then sit until the caller timed out. (#1129)
8
+ - A 401 `AUTH_REJECTED` now says a wallet address is not a MemWal account ID. (#1132)
9
+ - `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)
10
+
3
11
  ## 0.1.12
4
12
 
5
13
  ### 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.dev5
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.dev5"
@@ -96,7 +96,8 @@ PACKAGE_VERSION_CHECK_TIMEOUT_S = 10.0
96
96
  SEAL_SESSION_SAFETY_MARGIN_MS = 30_000
97
97
  AUTH_REJECTED_MESSAGE = (
98
98
  "401 from relayer: typically wrong private key, key not registered on this "
99
- "account, account ID mismatch, or staging/mainnet mismatch. Check .env.local "
99
+ "account, account ID mismatch (a wallet address is not a MemWal account ID), "
100
+ "or staging/mainnet mismatch. Check .env.local "
100
101
  "and dashboard credentials. Full troubleshooting: "
101
102
  "https://docs.wal.app/walrus-memory/troubleshooting/overview#401-auth_rejected-errors"
102
103
  )
@@ -121,6 +122,18 @@ def _server_url_for_log(parsed: ParseResult) -> str:
121
122
  return f"{parsed.scheme}://{host}"
122
123
 
123
124
 
125
+ def _reject_blank_text(text: object, message: str = "Text cannot be empty") -> None:
126
+ """Reject empty and whitespace-only text before a request is sent.
127
+
128
+ The relayer does the same (#1129). Failing here means an older relayer
129
+ cannot accept the job and leave the caller polling until timeout.
130
+ Non-blank text is not trimmed.
131
+ """
132
+
133
+ if not isinstance(text, str) or not text.strip():
134
+ raise ValueError(message)
135
+
136
+
124
137
  def normalize_server_url(url: str) -> str:
125
138
  """Strip a trailing slash and warn on plaintext HTTP to a remote host.
126
139
 
@@ -414,6 +427,7 @@ class MemWal:
414
427
  :class:`RememberAcceptedResult` with ``job_id`` and initial
415
428
  status (``"pending"``).
416
429
  """
430
+ _reject_blank_text(text)
417
431
  resolved_namespace = namespace or self._namespace
418
432
  request_identity = f"{resolved_namespace}\0{text}"
419
433
  generated_key = idempotency_key is None
@@ -569,6 +583,8 @@ class MemWal:
569
583
 
570
584
  if not items:
571
585
  raise ValueError("remember_bulk_async: items must be a non-empty array")
586
+ for index, item in enumerate(items):
587
+ _reject_blank_text(item.text, f"items[{index}].text cannot be empty")
572
588
 
573
589
  payload_items: List[Dict[str, Any]] = [
574
590
  {
@@ -662,6 +678,31 @@ class MemWal:
662
678
  attempt = 0
663
679
  saw_rate_limit = False
664
680
  retry_after_ms = 0
681
+ last_seen: Dict[str, RememberBulkStatusItem] = {}
682
+ last_refusal: Optional[int] = None
683
+ # Whether any status read got through. An item missing from an answer
684
+ # was not refused, so it must not be reported as if it had been.
685
+ answered = False
686
+
687
+ def settle(item: RememberBulkStatusItem) -> bool:
688
+ last_seen[item.job_id] = item
689
+ if item.status == "done":
690
+ results[item.job_id] = RememberBulkItemResult(
691
+ id=item.job_id,
692
+ blob_id=item.blob_id or "",
693
+ status="done",
694
+ error=None,
695
+ )
696
+ return True
697
+ if item.status in ("failed", "not_found"):
698
+ results[item.job_id] = RememberBulkItemResult(
699
+ id=item.job_id,
700
+ blob_id=item.blob_id or "",
701
+ status="failed",
702
+ error=item.error,
703
+ )
704
+ return True
705
+ return False
665
706
 
666
707
  while pending and _now_ms() < deadline_ms:
667
708
  if retry_after_ms > 0:
@@ -675,40 +716,66 @@ class MemWal:
675
716
  batch = await self.get_remember_bulk_status(pending)
676
717
  except _HttpStatusError as err:
677
718
  if _is_transient_polling_status(err.status):
719
+ last_refusal = err.status
678
720
  if err.status == 429:
679
721
  saw_rate_limit = True
680
722
  retry_after_ms = _clamped_retry_after_ms(err, deadline_ms)
681
723
  continue
682
724
  raise
725
+ answered = True
683
726
 
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)
727
+ terminal_ids: Set[str] = {
728
+ item.job_id
729
+ for item in batch.results
730
+ if item.job_id in pending and settle(item)
731
+ }
702
732
  pending = [jid for jid in pending if jid not in terminal_ids]
703
733
 
734
+ if opts.timeout_ms > 0 and len(pending) == len(job_ids) and not last_seen:
735
+ # Nothing settled and nothing was seen: the polls may all have been
736
+ # refused. Confirm with one direct read before reporting anything.
737
+ try:
738
+ probed = await self.get_remember_bulk_status(list(job_ids))
739
+ except _HttpStatusError as err:
740
+ if err.status == 429:
741
+ # Header first, else the body's retry_after_seconds, so a
742
+ # proxy that strips Retry-After does not lose the hint.
743
+ wait_ms = _retry_after_ms(err)
744
+ retry_after = f"{wait_ms / 1000:g}" if wait_ms else None
745
+ raise MemWalRateLimited(job_ids, retry_after) from err
746
+ # Any other probe failure is not evidence about the jobs.
747
+ else:
748
+ answered = True
749
+ settled = {
750
+ item.job_id
751
+ for item in probed.results
752
+ if item.job_id in pending and settle(item)
753
+ }
754
+ pending = [jid for jid in pending if jid not in settled]
755
+
756
+ for job_id in pending:
757
+ seen = last_seen.get(job_id)
758
+ if seen is not None:
759
+ suffix = f": {_redact_internal_urls(seen.error)}" if seen.error else ""
760
+ error = f"still {seen.status} after {opts.timeout_ms}ms{suffix}"
761
+ elif answered:
762
+ error = "not in the relayer's status answer; this item may not be stored"
763
+ elif last_refusal is not None:
764
+ error = (
765
+ f"no status read got through (last poll: HTTP {last_refusal}); "
766
+ "this item may not be stored"
767
+ )
768
+ else:
769
+ error = f"polling timed out after {opts.timeout_ms}ms"
770
+ # Only the "no status read got through" message already names the 429.
771
+ names_rate_limit = seen is None and not answered and last_refusal == 429
772
+ if saw_rate_limit and not names_rate_limit:
773
+ error += "; wait hit a rate limit (429)"
774
+ results[job_id] = RememberBulkItemResult(
775
+ id=job_id, blob_id="", status="timeout", error=error
776
+ )
777
+
704
778
  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
779
  succeeded = sum(1 for r in ordered if r.status == "done")
713
780
  failed = sum(1 for r in ordered if r.status == "failed")
714
781
  timed_out = sum(1 for r in ordered if r.status == "timeout")
@@ -847,6 +914,7 @@ class MemWal:
847
914
  :class:`AnalyzeResult` with extracted ``facts`` + per-fact
848
915
  ``job_ids`` for downstream polling.
849
916
  """
917
+ _reject_blank_text(text)
850
918
  body: Dict[str, Any] = {
851
919
  "text": text,
852
920
  "namespace": namespace or self._namespace,
@@ -919,6 +987,7 @@ class MemWal:
919
987
  :meth:`remember` (server handles embed + encrypt + upload).
920
988
  """
921
989
 
990
+ _reject_blank_text(text, "text cannot be empty")
922
991
  data = await self._signed_request(
923
992
  "POST",
924
993
  "/api/embed",
@@ -1601,6 +1670,24 @@ class MemWalRememberJobFailed(MemWalError):
1601
1670
  self.error = error
1602
1671
 
1603
1672
 
1673
+ class MemWalRateLimited(MemWalError):
1674
+ """Every status read for a bulk wait was rate-limited, the confirming read
1675
+ included, so none of its writes could be confirmed (GH #967). Status reads
1676
+ count against the same delegate-key budget as writes."""
1677
+
1678
+ def __init__(self, job_ids: Sequence[str], retry_after: Optional[str] = None) -> None:
1679
+ wait = f" after ~{retry_after}s" if retry_after else ""
1680
+ super().__init__(
1681
+ f"Walrus Memory rate-limited every status read for this batch, so none of its "
1682
+ f"{len(job_ids)} writes could be confirmed. Do not treat them as stored: check them "
1683
+ f"with get_remember_bulk_status(job_ids){wait}. Status reads count against the same "
1684
+ "delegate-key budget as writes."
1685
+ )
1686
+ self.status = 429
1687
+ self.job_ids = list(job_ids)
1688
+ self.retry_after = retry_after
1689
+
1690
+
1604
1691
  class MemWalRememberJobTimeout(MemWalError):
1605
1692
  """Polling loop exceeded the configured timeout."""
1606
1693
 
@@ -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.dev5"
8
8
  description = "Python SDK for Walrus Memory — Privacy-first AI memory with Ed25519 signing"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -12,6 +12,7 @@ from memwal.client import (
12
12
 
13
13
  def test_auth_rejected_message_points_to_troubleshooting_guide() -> None:
14
14
  assert "docs.wal.app/walrus-memory/troubleshooting/overview" in AUTH_REJECTED_MESSAGE
15
+ assert "wallet address" in AUTH_REJECTED_MESSAGE
15
16
 
16
17
 
17
18
  def test_auth_503_is_retryable_not_a_credential_failure() -> None:
@@ -0,0 +1,22 @@
1
+ """Whitespace-only text is rejected before a request is sent (#1129)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+
7
+ from memwal.client import _reject_blank_text
8
+
9
+
10
+ @pytest.mark.parametrize("text", ["", " ", "\t", "\n", " \t\n "])
11
+ def test_blank_text_rejected(text: str) -> None:
12
+ with pytest.raises(ValueError, match="Text cannot be empty"):
13
+ _reject_blank_text(text)
14
+
15
+
16
+ def test_padded_text_is_not_trimmed() -> None:
17
+ _reject_blank_text(" hello ")
18
+
19
+
20
+ def test_bulk_item_message_names_the_index() -> None:
21
+ with pytest.raises(ValueError, match=r"items\[0\].text cannot be empty"):
22
+ _reject_blank_text(" \t", "items[0].text cannot be empty")
@@ -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