memwal 0.1.11.dev4__tar.gz → 0.1.11.dev6__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.dev4 → memwal-0.1.11.dev6}/CHANGELOG.md +3 -0
- {memwal-0.1.11.dev4 → memwal-0.1.11.dev6}/PKG-INFO +1 -1
- {memwal-0.1.11.dev4 → memwal-0.1.11.dev6}/memwal/__init__.py +1 -1
- {memwal-0.1.11.dev4 → memwal-0.1.11.dev6}/memwal/client.py +73 -14
- {memwal-0.1.11.dev4 → memwal-0.1.11.dev6}/pyproject.toml +1 -1
- {memwal-0.1.11.dev4 → memwal-0.1.11.dev6}/tests/test_auth_rejected_message.py +1 -0
- memwal-0.1.11.dev6/tests/test_blank_text.py +22 -0
- {memwal-0.1.11.dev4 → memwal-0.1.11.dev6}/tests/test_client.py +84 -0
- {memwal-0.1.11.dev4 → memwal-0.1.11.dev6}/.gitignore +0 -0
- {memwal-0.1.11.dev4 → memwal-0.1.11.dev6}/README.md +0 -0
- {memwal-0.1.11.dev4 → memwal-0.1.11.dev6}/examples/.env.example +0 -0
- {memwal-0.1.11.dev4 → memwal-0.1.11.dev6}/examples/.gitignore +0 -0
- {memwal-0.1.11.dev4 → memwal-0.1.11.dev6}/examples/async_remember_demo.py +0 -0
- {memwal-0.1.11.dev4 → memwal-0.1.11.dev6}/examples/interactive_demo.py +0 -0
- {memwal-0.1.11.dev4 → memwal-0.1.11.dev6}/examples/verify_credentials.py +0 -0
- {memwal-0.1.11.dev4 → memwal-0.1.11.dev6}/memwal/compatibility.py +0 -0
- {memwal-0.1.11.dev4 → memwal-0.1.11.dev6}/memwal/middleware.py +0 -0
- {memwal-0.1.11.dev4 → memwal-0.1.11.dev6}/memwal/mock.py +0 -0
- {memwal-0.1.11.dev4 → memwal-0.1.11.dev6}/memwal/types.py +0 -0
- {memwal-0.1.11.dev4 → memwal-0.1.11.dev6}/memwal/utils.py +0 -0
- {memwal-0.1.11.dev4 → memwal-0.1.11.dev6}/notebooks/walrus_memory_python_sdk.ipynb +0 -0
- {memwal-0.1.11.dev4 → memwal-0.1.11.dev6}/run_tests.py +0 -0
- {memwal-0.1.11.dev4 → memwal-0.1.11.dev6}/tests/__init__.py +0 -0
- {memwal-0.1.11.dev4 → memwal-0.1.11.dev6}/tests/test_env_presets.py +0 -0
- {memwal-0.1.11.dev4 → memwal-0.1.11.dev6}/tests/test_integration.py +0 -0
- {memwal-0.1.11.dev4 → memwal-0.1.11.dev6}/tests/test_list_namespaces.py +0 -0
- {memwal-0.1.11.dev4 → memwal-0.1.11.dev6}/tests/test_middleware.py +0 -0
- {memwal-0.1.11.dev4 → memwal-0.1.11.dev6}/tests/test_mock.py +0 -0
- {memwal-0.1.11.dev4 → memwal-0.1.11.dev6}/tests/test_normalize_server_url.py +0 -0
- {memwal-0.1.11.dev4 → memwal-0.1.11.dev6}/tests/test_polling_delay.py +0 -0
- {memwal-0.1.11.dev4 → memwal-0.1.11.dev6}/tests/test_signing.py +0 -0
|
@@ -4,7 +4,10 @@
|
|
|
4
4
|
|
|
5
5
|
### Fixed
|
|
6
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)
|
|
7
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
|
+
- `MemWalRememberJobTimeout` carries `last_status`, `server_error` and `last_refusal`, and its message names the relayer's last reported state. Under upload congestion a job stays `running` for up to ~25 minutes while the relayer retries, and the timeout used to hide that, so a job that was still retrying looked dead. (WALM-670, #966)
|
|
8
11
|
|
|
9
12
|
## 0.1.12
|
|
10
13
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: memwal
|
|
3
|
-
Version: 0.1.11.
|
|
3
|
+
Version: 0.1.11.dev6
|
|
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
|
|
@@ -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
|
|
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
|
|
@@ -471,6 +485,9 @@ class MemWal:
|
|
|
471
485
|
attempt = 0
|
|
472
486
|
saw_rate_limit = False
|
|
473
487
|
retry_after_ms = 0
|
|
488
|
+
last_status: Optional[str] = None
|
|
489
|
+
server_error: Optional[str] = None
|
|
490
|
+
last_refusal: Optional[int] = None
|
|
474
491
|
|
|
475
492
|
while _now_ms() < deadline_ms:
|
|
476
493
|
if retry_after_ms > 0:
|
|
@@ -489,6 +506,7 @@ class MemWal:
|
|
|
489
506
|
)
|
|
490
507
|
except _HttpStatusError as err:
|
|
491
508
|
if _is_transient_polling_status(err.status):
|
|
509
|
+
last_refusal = err.status
|
|
492
510
|
if err.status == 429:
|
|
493
511
|
saw_rate_limit = True
|
|
494
512
|
retry_after_ms = _clamped_retry_after_ms(err, deadline_ms)
|
|
@@ -498,6 +516,8 @@ class MemWal:
|
|
|
498
516
|
status_str = data.get("status")
|
|
499
517
|
if status_str is None or status_str == "not_found":
|
|
500
518
|
raise MemWalRememberJobNotFound(job_id)
|
|
519
|
+
last_status = status_str
|
|
520
|
+
server_error = data.get("error")
|
|
501
521
|
|
|
502
522
|
if status_str == "done":
|
|
503
523
|
return RememberResult(
|
|
@@ -514,7 +534,14 @@ class MemWal:
|
|
|
514
534
|
|
|
515
535
|
# pending / running / uploaded — keep polling.
|
|
516
536
|
|
|
517
|
-
raise
|
|
537
|
+
raise MemWalRememberJobTimeout(
|
|
538
|
+
job_id=job_id,
|
|
539
|
+
timeout_ms=timeout_ms,
|
|
540
|
+
last_status=last_status,
|
|
541
|
+
server_error=server_error,
|
|
542
|
+
last_refusal=last_refusal,
|
|
543
|
+
saw_rate_limit=saw_rate_limit,
|
|
544
|
+
)
|
|
518
545
|
|
|
519
546
|
async def remember_and_wait(
|
|
520
547
|
self,
|
|
@@ -569,6 +596,8 @@ class MemWal:
|
|
|
569
596
|
|
|
570
597
|
if not items:
|
|
571
598
|
raise ValueError("remember_bulk_async: items must be a non-empty array")
|
|
599
|
+
for index, item in enumerate(items):
|
|
600
|
+
_reject_blank_text(item.text, f"items[{index}].text cannot be empty")
|
|
572
601
|
|
|
573
602
|
payload_items: List[Dict[str, Any]] = [
|
|
574
603
|
{
|
|
@@ -898,6 +927,7 @@ class MemWal:
|
|
|
898
927
|
:class:`AnalyzeResult` with extracted ``facts`` + per-fact
|
|
899
928
|
``job_ids`` for downstream polling.
|
|
900
929
|
"""
|
|
930
|
+
_reject_blank_text(text)
|
|
901
931
|
body: Dict[str, Any] = {
|
|
902
932
|
"text": text,
|
|
903
933
|
"namespace": namespace or self._namespace,
|
|
@@ -970,6 +1000,7 @@ class MemWal:
|
|
|
970
1000
|
:meth:`remember` (server handles embed + encrypt + upload).
|
|
971
1001
|
"""
|
|
972
1002
|
|
|
1003
|
+
_reject_blank_text(text, "text cannot be empty")
|
|
973
1004
|
data = await self._signed_request(
|
|
974
1005
|
"POST",
|
|
975
1006
|
"/api/embed",
|
|
@@ -1671,24 +1702,52 @@ class MemWalRateLimited(MemWalError):
|
|
|
1671
1702
|
|
|
1672
1703
|
|
|
1673
1704
|
class MemWalRememberJobTimeout(MemWalError):
|
|
1674
|
-
"""Polling loop exceeded the configured timeout.
|
|
1705
|
+
"""Polling loop exceeded the configured timeout.
|
|
1706
|
+
|
|
1707
|
+
Carries the last status the relayer reported (GH #966): under upload
|
|
1708
|
+
congestion a job stays ``running`` for tens of minutes, and without it a
|
|
1709
|
+
job that is still retrying looks dead.
|
|
1710
|
+
"""
|
|
1675
1711
|
|
|
1676
|
-
def __init__(
|
|
1712
|
+
def __init__(
|
|
1713
|
+
self,
|
|
1714
|
+
job_id: str,
|
|
1715
|
+
timeout_ms: int,
|
|
1716
|
+
last_status: Optional[str] = None,
|
|
1717
|
+
server_error: Optional[str] = None,
|
|
1718
|
+
last_refusal: Optional[int] = None,
|
|
1719
|
+
saw_rate_limit: bool = False,
|
|
1720
|
+
) -> None:
|
|
1721
|
+
# "no status read got through (last poll: HTTP 429)" already names the rate limit.
|
|
1722
|
+
named = last_status is None and last_refusal == 429
|
|
1723
|
+
rate_limited = "; wait hit a rate limit (429)" if saw_rate_limit and not named else ""
|
|
1724
|
+
# Redacted once, for both the message and the attribute.
|
|
1725
|
+
server_error = _redact_internal_urls(server_error) if server_error else None
|
|
1726
|
+
if last_status is not None:
|
|
1727
|
+
# The wait only times out on a job still pending/running/uploaded
|
|
1728
|
+
# (a failed job raises), so the advice is to keep waiting on it.
|
|
1729
|
+
suffix = f" ({server_error})" if server_error else ""
|
|
1730
|
+
detail = (
|
|
1731
|
+
f"last status: {last_status}{suffix}. The job is still in progress: "
|
|
1732
|
+
f"call wait_for_remember_job({job_id!r}) to keep waiting on it. Sending "
|
|
1733
|
+
"the text again under a new idempotency key stores it twice; a key you "
|
|
1734
|
+
"passed as idempotency_key is not remembered by the client, so pass it "
|
|
1735
|
+
"again on any retry."
|
|
1736
|
+
)
|
|
1737
|
+
else:
|
|
1738
|
+
detail = (
|
|
1739
|
+
f"no status read got through (last poll: HTTP {last_refusal or 0}), "
|
|
1740
|
+
"so the job's state is unknown."
|
|
1741
|
+
)
|
|
1677
1742
|
super().__init__(
|
|
1678
|
-
f"remember job timed out after {timeout_ms}ms (job_id={job_id})"
|
|
1743
|
+
f"remember job timed out after {timeout_ms}ms (job_id={job_id}){rate_limited}; {detail}"
|
|
1679
1744
|
)
|
|
1680
1745
|
self.status = 504
|
|
1681
1746
|
self.job_id = job_id
|
|
1682
1747
|
self.timeout_ms = timeout_ms
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
job_id: str, timeout_ms: int, saw_rate_limit: bool
|
|
1687
|
-
) -> MemWalRememberJobTimeout:
|
|
1688
|
-
exc = MemWalRememberJobTimeout(job_id, timeout_ms)
|
|
1689
|
-
if saw_rate_limit:
|
|
1690
|
-
exc.args = (f"{exc.args[0]}; wait hit a rate limit (429)",)
|
|
1691
|
-
return exc
|
|
1748
|
+
self.last_status = last_status
|
|
1749
|
+
self.server_error = server_error
|
|
1750
|
+
self.last_refusal = last_refusal
|
|
1692
1751
|
|
|
1693
1752
|
|
|
1694
1753
|
async def _discard_http_client(memwal: MemWal) -> None:
|
|
@@ -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")
|
|
@@ -24,6 +24,7 @@ from memwal.client import (
|
|
|
24
24
|
MemWalCompatibilityError,
|
|
25
25
|
MemWalError,
|
|
26
26
|
MemWalRateLimited,
|
|
27
|
+
MemWalRememberJobTimeout,
|
|
27
28
|
MemWalSync,
|
|
28
29
|
_HttpStatusError,
|
|
29
30
|
)
|
|
@@ -558,6 +559,89 @@ class TestBulkWaitUnderRateLimit:
|
|
|
558
559
|
)
|
|
559
560
|
|
|
560
561
|
|
|
562
|
+
class TestRememberJobTimeoutDetail:
|
|
563
|
+
async def test_reports_last_status_and_server_message(
|
|
564
|
+
self, memwal_client: MemWal
|
|
565
|
+
) -> None:
|
|
566
|
+
async def running(*_args: Any, **_kwargs: Any) -> dict:
|
|
567
|
+
return {
|
|
568
|
+
"job_id": "slow-job",
|
|
569
|
+
"status": "running",
|
|
570
|
+
"error": "the hosted relayer is still retrying this upload",
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
memwal_client._signed_request = running # type: ignore[method-assign]
|
|
574
|
+
with pytest.raises(MemWalRememberJobTimeout) as exc:
|
|
575
|
+
await memwal_client.wait_for_remember_job(
|
|
576
|
+
"slow-job", poll_interval_ms=0, timeout_ms=300
|
|
577
|
+
)
|
|
578
|
+
assert exc.value.status == 504
|
|
579
|
+
assert exc.value.last_status == "running"
|
|
580
|
+
assert "still retrying" in (exc.value.server_error or "")
|
|
581
|
+
assert str(exc.value).startswith("remember job timed out after 300ms")
|
|
582
|
+
assert "last status: running" in str(exc.value)
|
|
583
|
+
# The job is still live, so the advice is to wait on its id, not to
|
|
584
|
+
# POST again: `remember` drops its key once accepted, so a re-send
|
|
585
|
+
# would mint a new key and a second paid write.
|
|
586
|
+
assert "call wait_for_remember_job('slow-job')" in str(exc.value)
|
|
587
|
+
assert "idempotency_key is not remembered" in str(exc.value)
|
|
588
|
+
|
|
589
|
+
async def test_timeout_attribute_carries_the_redacted_server_error(
|
|
590
|
+
self, memwal_client: MemWal
|
|
591
|
+
) -> None:
|
|
592
|
+
async def running(*_args: Any, **_kwargs: Any) -> dict:
|
|
593
|
+
return {
|
|
594
|
+
"job_id": "slow-job",
|
|
595
|
+
"status": "running",
|
|
596
|
+
"error": "sidecar http://localhost:9000/upload unreachable",
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
memwal_client._signed_request = running # type: ignore[method-assign]
|
|
600
|
+
with pytest.raises(MemWalRememberJobTimeout) as exc:
|
|
601
|
+
await memwal_client.wait_for_remember_job(
|
|
602
|
+
"slow-job", poll_interval_ms=0, timeout_ms=300
|
|
603
|
+
)
|
|
604
|
+
assert exc.value.server_error == "sidecar [internal] unreachable"
|
|
605
|
+
assert "localhost" not in str(exc.value)
|
|
606
|
+
|
|
607
|
+
async def test_every_poll_refused_reports_unknown_state(
|
|
608
|
+
self, memwal_client: MemWal
|
|
609
|
+
) -> None:
|
|
610
|
+
async def refused(*_args: Any, **_kwargs: Any) -> dict:
|
|
611
|
+
raise _HttpStatusError(429, '{"error":"rate limited"}')
|
|
612
|
+
|
|
613
|
+
memwal_client._signed_request = refused # type: ignore[method-assign]
|
|
614
|
+
with pytest.raises(MemWalRememberJobTimeout) as exc:
|
|
615
|
+
await memwal_client.wait_for_remember_job(
|
|
616
|
+
"hidden-job", poll_interval_ms=0, timeout_ms=300
|
|
617
|
+
)
|
|
618
|
+
assert exc.value.last_status is None
|
|
619
|
+
assert exc.value.last_refusal == 429
|
|
620
|
+
assert "no status read got through (last poll: HTTP 429)" in str(exc.value)
|
|
621
|
+
|
|
622
|
+
async def test_seen_then_rate_limited_keeps_both(
|
|
623
|
+
self, memwal_client: MemWal
|
|
624
|
+
) -> None:
|
|
625
|
+
calls = 0
|
|
626
|
+
|
|
627
|
+
async def running_then_refused(*_args: Any, **_kwargs: Any) -> dict:
|
|
628
|
+
nonlocal calls
|
|
629
|
+
calls += 1
|
|
630
|
+
if calls == 1:
|
|
631
|
+
return {"job_id": "slow-job", "status": "running"}
|
|
632
|
+
raise _HttpStatusError(429, '{"error":"rate limited"}')
|
|
633
|
+
|
|
634
|
+
memwal_client._signed_request = running_then_refused # type: ignore[method-assign]
|
|
635
|
+
with pytest.raises(MemWalRememberJobTimeout) as exc:
|
|
636
|
+
await memwal_client.wait_for_remember_job(
|
|
637
|
+
"slow-job", poll_interval_ms=0, timeout_ms=300
|
|
638
|
+
)
|
|
639
|
+
assert exc.value.last_status == "running"
|
|
640
|
+
assert "(job_id=slow-job); wait hit a rate limit (429); last status: running." in str(
|
|
641
|
+
exc.value
|
|
642
|
+
)
|
|
643
|
+
|
|
644
|
+
|
|
561
645
|
# ============================================================
|
|
562
646
|
# recall() tests
|
|
563
647
|
# ============================================================
|
|
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
|