Skip to content

Commit c09c42b

Browse files
gloryfromcaclaude
andcommitted
fix(plugins): land a slice despite a dropped oversize message, normalise keys, skip landed adds on retry
Three findings from the second review pass. A slice holding one message over the 250 KB ceiling answered False after the other messages had landed, and both retrying callers (StorePipeline, the importer) re-sent the whole slice on every attempt: the drop is deterministic, so the rest was stored again and an import source never completed. The slice now reports landed; the warning that names the session and the message index stays. resolve_api_key strips whitespace and refuses a key with a control character, because the HTTP layer quotes the whole Authorization value in its error and that text reached the health hint, the settings card and the gateway log. A retry of the same slice skips the adds the service already accepted (their digests are kept until the slice lands), so a transient failure mid-slice stores nothing twice; the digest ignores the fill-in timestamp. A lone surrogate in a message is replaced so the size check and the send never raise. A parity test holds convert_messages to the local plugin's. Co-authored-by: Claude (claude-fable-5-1) <noreply@anthropic.com>
1 parent a68ffaf commit c09c42b

2 files changed

Lines changed: 111 additions & 12 deletions

File tree

‎plugins-dist/everos-cloud-memory/raven_everos_cloud/backend.py‎

Lines changed: 44 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
from __future__ import annotations
2929

3030
import asyncio
31+
import hashlib
3132
import json
3233
import logging
3334
import math
@@ -96,11 +97,14 @@ def resolve_api_key(config: Mapping[str, Any]) -> tuple[str, str | None]:
9697
one the settings page edits; a key that came from the environment is never
9798
written back, so the file records only what a person typed.
9899
"""
99-
key = str(config.get("api_key") or "")
100-
if key:
101-
return key, "file"
102-
key = os.environ.get(ENV_KEY, "")
103-
return (key, "env") if key else ("", None)
100+
for raw, source in ((config.get("api_key"), "file"), (os.environ.get(ENV_KEY), "env")):
101+
key = str(raw or "").strip()
102+
if key:
103+
# A control character cannot travel in a header: the HTTP layer refuses
104+
# it by quoting the whole Authorization value in its error, and every
105+
# hint and log line that prints that error would show the key.
106+
return (key, source) if key.isprintable() else ("", None)
107+
return "", None
104108

105109

106110
# ---------------------------------------------------------------------------
@@ -161,6 +165,9 @@ def convert_messages(
161165
content = "" # a tool-call-only turn; "None" would be read by the extraction model as a reply
162166
elif not isinstance(content, str):
163167
content = str(content)
168+
# A lone surrogate (a Claude Code transcript can carry one) cannot be encoded;
169+
# it becomes U+FFFD here so the slice is sendable and ``store`` never raises.
170+
content = content.encode("utf-8", errors="replace").decode("utf-8")
164171
tool_calls = m.get("tool_calls") if role == "assistant" else None
165172
if not content and not tool_calls:
166173
continue
@@ -243,6 +250,21 @@ def _score(row: dict[str, Any], default: float = 0.0) -> float:
243250
return default
244251

245252

253+
def _digest(body: dict[str, Any]) -> str:
254+
"""Identity of one add for the retry memo: the content, not the clock.
255+
256+
A row without a timestamp is stamped with "now" on every conversion, so a
257+
retry of the same slice would never match if the stamp took part.
258+
"""
259+
stable = {
260+
"session_id": body.get("session_id"),
261+
"messages": [{k: v for k, v in m.items() if k != "timestamp"} for m in body.get("messages", [])],
262+
}
263+
return hashlib.sha256(
264+
json.dumps(stable, ensure_ascii=False, sort_keys=True).encode("utf-8", errors="replace")
265+
).hexdigest()
266+
267+
246268
def _joined(*parts: Any) -> str:
247269
return " - ".join(str(part).strip() for part in parts if part)
248270

@@ -370,6 +392,7 @@ def __init__(self, ctx: PluginContext, *, client: httpx.AsyncClient | None = Non
370392
self._client: httpx.AsyncClient | None = client
371393
self._owns_client = client is None
372394
self._unflushed: set[str] = set()
395+
self._landed: dict[str, set[str]] = {}
373396
self._stopping = False
374397
self._no_key_logged = False
375398

@@ -527,32 +550,44 @@ async def store(
527550
return True
528551
if not self._has_key("store"):
529552
return False
530-
batches, dropped = self._batches(session_id, payload)
553+
batches, _dropped = self._batches(session_id, payload)
531554
explicit_end = bool(metadata and (metadata.get("flush") or metadata.get("is_final")))
532555
self._unflushed.add(session_id)
533556
for batch in batches:
534557
body = {"session_id": session_id, "mode": "agent", "messages": batch}
558+
# Both callers re-send a slice that answered False (contracts/memory.py);
559+
# a batch the service accepted on the failed attempt is not sent twice.
560+
# The memo lives only until the slice lands, so a later store of the
561+
# same content is a new write, not a retry.
562+
digest = _digest(body)
563+
if digest in self._landed.get(session_id, ()):
564+
continue
535565
if not await self._send("add", "/api/v2/memory/add", body, ADD_TIMEOUT_S):
536566
return False
567+
self._landed.setdefault(session_id, set()).add(digest)
537568
if explicit_end:
538569
if not await self._send("flush", "/api/v2/memory/flush", {"session_id": session_id}, flush_timeout_s()):
539570
return False
540571
self._unflushed.discard(session_id)
541-
return not dropped
572+
self._landed.pop(session_id, None)
573+
# A dropped oversize message is logged by _batches and is not a reason to
574+
# answer False: no retry can make it fit, and a False here would only make
575+
# both callers store the rest of the slice again on every attempt.
576+
return True
542577

543578
def _batches(self, session_id: str, payload: list[dict[str, Any]]) -> tuple[list[list[dict[str, Any]]], bool]:
544579
"""Split a slice at the service's limits; a single oversize message is dropped and named.
545580
546581
A message over the byte ceiling cannot be made to fit without storing
547582
words the user never said, so it is left out and the rest goes on; the
548-
caller hears ``False`` for the slice.
583+
slice still reports landed (see ``store``).
549584
"""
550585
batches: list[list[dict[str, Any]]] = []
551586
current: list[dict[str, Any]] = []
552587
size = 0
553588
dropped = False
554589
for index, message in enumerate(payload):
555-
item_size = len(json.dumps(message, ensure_ascii=False).encode("utf-8"))
590+
item_size = len(json.dumps(message, ensure_ascii=False).encode("utf-8", errors="replace"))
556591
if item_size > ADD_MAX_BYTES:
557592
# ponytail: a single message over the ceiling is dropped whole; chunk one
558593
# message across adds if real turns ever hit this.

‎tests/test_everos_cloud_backend.py‎

Lines changed: 67 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from raven.contracts.memory import BackendHealth, Memory
1414
from raven.plugins import PluginContext, ServiceLocator
1515
from raven_everos_cloud import backend as mod
16-
from raven_everos_cloud.backend import EverosCloudBackend, resolve_api_key
16+
from raven_everos_cloud.backend import EverosCloudBackend, convert_messages, resolve_api_key
1717
from tests._everos_cloud_fake import FakeCloud
1818

1919
pytestmark = pytest.mark.everos_cloud
@@ -40,6 +40,14 @@ def _requests(fake: FakeCloud, path_end: str) -> list[dict]:
4040
# ── key resolution (C7) ──────────────────────────────────────────────
4141

4242

43+
def test_key_resolution_strips_whitespace_and_refuses_a_control_character(monkeypatch: pytest.MonkeyPatch) -> None:
44+
"""A trailing newline would make the HTTP layer quote the whole Authorization value in its error."""
45+
monkeypatch.setenv("EVEROS_CLOUD_API_KEY", "from-env\n")
46+
assert resolve_api_key({"api_key": " from-file \n"}) == ("from-file", "file")
47+
assert resolve_api_key({}) == ("from-env", "env")
48+
assert resolve_api_key({"api_key": "from\x00file"}) == ("", None)
49+
50+
4351
def test_key_resolution_prefers_the_file(monkeypatch: pytest.MonkeyPatch) -> None:
4452
monkeypatch.setenv("EVEROS_CLOUD_API_KEY", "from-env")
4553
assert resolve_api_key({"api_key": "from-file"}) == ("from-file", "file")
@@ -193,7 +201,9 @@ async def test_oversize_message_fails_its_own_add_and_logs_index(tmp_path: Path,
193201
+ [{"role": "user", "content": "small"}] * 2
194202
)
195203
with caplog.at_level(logging.WARNING, logger="t"):
196-
assert await b.store("over", msgs) is False
204+
# The slice lands: no retry can make the dropped message fit, and a False
205+
# would make both callers store the other four again on every attempt.
206+
assert await b.store("over", msgs) is True
197207
adds = _requests(fake, "/add")
198208
assert len(adds) == 1 and len(adds[0]["body"]["messages"]) == 4
199209
assert "message 2 of session over" in caplog.text
@@ -216,7 +226,7 @@ async def test_batches_are_sized_in_utf8_bytes_not_code_points(tmp_path: Path, c
216226
b2 = _backend(tmp_path, fake2)
217227
big = [{"role": "user", "content": "\u00e9" * 150_000}, {"role": "user", "content": "small"}]
218228
with caplog.at_level(logging.WARNING, logger="t"):
219-
assert await b2.store("over-utf8", big) is False
229+
assert await b2.store("over-utf8", big) is True
220230
adds2 = _requests(fake2, "/add")
221231
assert len(adds2) == 1 and len(adds2[0]["body"]["messages"]) == 1
222232
assert "message 0 of session over-utf8 is 300" in caplog.text
@@ -253,6 +263,60 @@ async def test_a_tool_call_only_turn_is_stored_with_empty_content(tmp_path: Path
253263
assert sent[1]["content"] == "" and sent[1]["role"] == "assistant"
254264

255265

266+
async def test_a_lone_surrogate_is_replaced_and_the_slice_still_lands(tmp_path: Path) -> None:
267+
fake = FakeCloud()
268+
b = _backend(tmp_path, fake)
269+
assert await b.store("surrogate", [{"role": "user", "content": "half \ud83d emoji"}]) is True
270+
sent = _requests(fake, "/add")[0]["body"]["messages"][0]["content"]
271+
assert "\ud83d" not in sent and "half" in sent and "emoji" in sent
272+
273+
274+
async def test_a_retry_after_a_transient_failure_skips_the_batches_that_landed(tmp_path: Path) -> None:
275+
"""StorePipeline re-sends a slice that answered False; the batches the service
276+
already accepted must not be stored twice for it."""
277+
fake = FakeCloud()
278+
inner = fake.transport()
279+
adds = {"n": 0}
280+
281+
async def flaky(request: httpx.Request) -> httpx.Response:
282+
if request.url.path.endswith("/add"):
283+
adds["n"] += 1
284+
if adds["n"] == 2:
285+
return httpx.Response(500, json={"error": {"message": "ECM-TRANSIENT"}})
286+
return await inner.handle_async_request(request)
287+
288+
b = EverosCloudBackend(_ctx(tmp_path), client=httpx.AsyncClient(transport=httpx.MockTransport(flaky)))
289+
msgs = [{"role": "user", "content": f"m{i}"} for i in range(600)]
290+
assert await b.store("retry", msgs) is False
291+
assert await b.store("retry", msgs) is True
292+
sent = [len(a["body"]["messages"]) for a in _requests(fake, "/add")]
293+
assert sent == [500, 100] # the 500-message batch once, the 100-message batch once (its first try never landed)
294+
295+
296+
async def test_message_translation_matches_the_local_plugin() -> None:
297+
"""The cloud copy of convert_messages must not drift from raven_everos (the boundary forbids importing it)."""
298+
from raven_everos.backend import convert_messages as local_convert
299+
300+
rows = [
301+
{"role": "user", "content": "hello", "timestamp": 1_760_000_000_000},
302+
{
303+
"role": "assistant",
304+
"content": None,
305+
"tool_calls": [{"id": "c1", "type": "function", "function": {"name": "x", "arguments": "{}"}}],
306+
"timestamp": 1_760_000_000_001,
307+
},
308+
{
309+
"role": "tool",
310+
"content": [{"type": "text", "text": "ok"}, {"type": "image", "url": "x"}],
311+
"tool_call_id": "c1",
312+
"timestamp": 1_760_000_000_002,
313+
},
314+
{"role": "system", "content": "dropped", "timestamp": 1_760_000_000_003},
315+
{"role": "assistant", "content": 42, "timestamp": "2026-10-09T00:00:00Z"},
316+
]
317+
assert convert_messages(rows, agent_id="a", user_id="u") == local_convert(rows, agent_id="a", user_id="u")
318+
319+
256320
# ── recall_session (A15) ────────────────────────────────────────────
257321

258322

0 commit comments

Comments
 (0)