From 2a0ed0cb8f8bf7458be7ce5dc494b62ba0c39510 Mon Sep 17 00:00:00 2001 From: Igor Lins e Silva <4753812+igorls@users.noreply.github.com> Date: Thu, 7 May 2026 12:38:39 -0300 Subject: [PATCH 1/2] fix(closet_llm): retry _call_llm on JSONDecodeError instead of bailing The retry loop already backs off on HTTP 429/503 and rate-limit-shaped exceptions, but JSONDecodeError exited on the first failure. Local LLM runtimes occasionally produce malformed JSON (truncated streams, partial chunks under load), and the retry was effectively dead for that path. Mirror the 429/503 branch: sleep with exponential backoff and continue through all 3 attempts, only returning None after the final failure. Closes #1155 --- mempalace/closet_llm.py | 3 +++ tests/test_closet_llm.py | 26 +++++++++++++++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/mempalace/closet_llm.py b/mempalace/closet_llm.py index 50000c8..a85d517 100644 --- a/mempalace/closet_llm.py +++ b/mempalace/closet_llm.py @@ -169,6 +169,9 @@ def _call_llm(cfg: LLMConfig, source_file: str, wing: str, room: str, content: s parsed = json.loads(text) return parsed, payload.get("usage") except json.JSONDecodeError: + if attempt < 2: + time.sleep(2**attempt) + continue return None, None except urllib.error.HTTPError as e: # 429 / 503 = retry with backoff diff --git a/tests/test_closet_llm.py b/tests/test_closet_llm.py index 3a0e84e..0255ee8 100644 --- a/tests/test_closet_llm.py +++ b/tests/test_closet_llm.py @@ -196,10 +196,34 @@ class TestCallLLM: } ) - with patch("urllib.request.urlopen", side_effect=fake_urlopen): + with ( + patch("urllib.request.urlopen", side_effect=fake_urlopen), + patch("mempalace.closet_llm.time.sleep"), + ): parsed, usage = _call_llm(cfg, "/tmp/x", "w", "r", "c") assert parsed is None + def test_retries_on_json_decode_error(self): + cfg = self._make_cfg() + call_count = {"n": 0} + + def fake_urlopen(req, timeout=None): + call_count["n"] += 1 + return _FakeResp( + { + "choices": [{"message": {"content": "not json at all"}}], + "usage": {"prompt_tokens": 1, "completion_tokens": 1}, + } + ) + + with ( + patch("urllib.request.urlopen", side_effect=fake_urlopen), + patch("mempalace.closet_llm.time.sleep"), + ): + parsed, usage = _call_llm(cfg, "/tmp/x", "w", "r", "c") + assert parsed is None + assert call_count["n"] == 3 + # ── regenerate_closets error paths ─────────────────────────────────────── From 8e21b5abd48a97830bce4fb3c28584d6e9b2caa5 Mon Sep 17 00:00:00 2001 From: Igor Lins e Silva <4753812+igorls@users.noreply.github.com> Date: Thu, 7 May 2026 12:49:27 -0300 Subject: [PATCH 2/2] test(closet_llm): use _ for unused return values per copilot review --- tests/test_closet_llm.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_closet_llm.py b/tests/test_closet_llm.py index 0255ee8..905d28b 100644 --- a/tests/test_closet_llm.py +++ b/tests/test_closet_llm.py @@ -200,7 +200,7 @@ class TestCallLLM: patch("urllib.request.urlopen", side_effect=fake_urlopen), patch("mempalace.closet_llm.time.sleep"), ): - parsed, usage = _call_llm(cfg, "/tmp/x", "w", "r", "c") + parsed, _ = _call_llm(cfg, "/tmp/x", "w", "r", "c") assert parsed is None def test_retries_on_json_decode_error(self): @@ -220,7 +220,7 @@ class TestCallLLM: patch("urllib.request.urlopen", side_effect=fake_urlopen), patch("mempalace.closet_llm.time.sleep"), ): - parsed, usage = _call_llm(cfg, "/tmp/x", "w", "r", "c") + parsed, _ = _call_llm(cfg, "/tmp/x", "w", "r", "c") assert parsed is None assert call_count["n"] == 3