fix: retry asset downloads on 5xx and recover them across runs
An asset download is two hops — mint a signed URL, then fetch the bytes — and only the first went through _make_request. The second was a bare session.request with no 5xx handling, which lost two images in the 2026-10-20 09:00 run: one to a 500 the retried first hop had already recovered from, one to a 503. Both re-downloaded cleanly an hour later (106 KB and 1.3 MB), so the bytes were there all along. A third asset timed out and aborted its whole conversation. curl_cffi's RequestException subclasses OSError, so the un-wrapped timeout slipped past resolve_media's except ProviderError into the export loop's except OSError and was reported as "File write failed for conversation 6a945155" — a conversation that had already downloaded ~20 images. Nothing was wrong with any file write. The retry loop is factored out of _make_request into _send_with_retries, which returns the raw response so a bytes caller gets the same handling as a JSON one. Hop 2 goes through it: 5xx retried, Retry-After honoured, timeouts surfacing as ProviderError for resolve_media to placeholder. The hop is now logged, which it never was. Assets get their own ladder — an immediate retry, then ~2/4/8/16s with jitter kept, since a storage incident fails many assets at once — bounded by a per-asset wall-clock budget, because the timeout case costs 40s a go. Beyond the retry: - Transient failures (5xx, timeout, network) are recorded as pending_media so a later run re-fetches just that asset; capped at 3 runs. 404/403 stay permanent — settled 2026-08-17 for 19 assets. - A circuit breaker stops downloads after 5 consecutive transient failures, recording the rest as retryable. Permanent failures never trip it; any success resets the streak. - Media failures now appear in the set ids. The run that lost two assets pushed a clean "OK". Keeps FUTURE.md's per-conversation cache reset correctly closed. The README split (FUTURE.md §2) stays otial enough to justify breaking every anchor link. 37 new tests (414 passing). Verified live: both lost assets recovered, doctor green, a read-only chatgpt dns. The cross-run retry and breaker are unit-tested only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/cwa7qnGp Claude-Session: https://claude.ai/code/session_012gCWSknsWvhLnMFwa7qnGp
This commit is contained in:
co-authored by
Claude Opus 5
parent
805689c649
commit
8c3e00a520
@@ -254,3 +254,97 @@ class TestReexportPreservesJoplinLink:
|
||||
pending = tmp_cache.get_joplin_pending("chatgpt")
|
||||
assert len(pending) == 1
|
||||
assert pending[0][1]["joplin_note_id"] == "note-123"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cross-run media retry.
|
||||
#
|
||||
# Before this, a transient asset failure was permanent: resolve_media kept a
|
||||
# placeholder, mark_exported ran anyway, and the conversation was never looked
|
||||
# at again. Two assets were lost that way on 2026-10-20 to a 500 and a 503 —
|
||||
# both recoverable. The manifest now remembers which refs are still missing.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPendingMediaRetry:
|
||||
UPDATED = "2026-10-20T09:00:00Z"
|
||||
|
||||
def test_pending_media_forces_a_re_export(self, tmp_cache):
|
||||
tmp_cache.mark_exported(
|
||||
"chatgpt", "conv-1",
|
||||
{"updated_at": self.UPDATED, "pending_media": ["sediment://file_x"]},
|
||||
)
|
||||
assert tmp_cache.is_cached("chatgpt", "conv-1", self.UPDATED) is False
|
||||
|
||||
def test_no_pending_media_is_an_ordinary_hit(self, tmp_cache):
|
||||
tmp_cache.mark_exported("chatgpt", "conv-1", {"updated_at": self.UPDATED})
|
||||
assert tmp_cache.is_cached("chatgpt", "conv-1", self.UPDATED) is True
|
||||
|
||||
def test_a_resolved_asset_clears_the_flag(self, tmp_cache):
|
||||
"""The run that recovers the asset must stop the daily re-export."""
|
||||
tmp_cache.mark_exported(
|
||||
"chatgpt", "conv-1",
|
||||
{"updated_at": self.UPDATED, "pending_media": ["sediment://file_x"]},
|
||||
)
|
||||
assert tmp_cache.is_cached("chatgpt", "conv-1", self.UPDATED) is False
|
||||
|
||||
tmp_cache.mark_exported("chatgpt", "conv-1", {"updated_at": self.UPDATED})
|
||||
assert tmp_cache.is_cached("chatgpt", "conv-1", self.UPDATED) is True
|
||||
entry = tmp_cache.get_all_entries("chatgpt")["conv-1"]
|
||||
assert "pending_media" not in entry
|
||||
|
||||
def test_retries_are_capped_so_it_does_not_re_export_forever(self, tmp_cache):
|
||||
"""An asset that 5xxs every morning must stop costing a re-export."""
|
||||
from src.cache import MAX_MEDIA_RETRY_RUNS
|
||||
|
||||
for run in range(MAX_MEDIA_RETRY_RUNS):
|
||||
tmp_cache.mark_exported(
|
||||
"chatgpt", "conv-1",
|
||||
{"updated_at": self.UPDATED, "pending_media": ["sediment://file_x"]},
|
||||
)
|
||||
assert tmp_cache.is_cached("chatgpt", "conv-1", self.UPDATED) is False, (
|
||||
f"run {run + 1} should still retry"
|
||||
)
|
||||
|
||||
tmp_cache.mark_exported(
|
||||
"chatgpt", "conv-1",
|
||||
{"updated_at": self.UPDATED, "pending_media": ["sediment://file_x"]},
|
||||
)
|
||||
assert tmp_cache.is_cached("chatgpt", "conv-1", self.UPDATED) is True
|
||||
|
||||
def test_the_lost_refs_are_kept_as_a_record(self, tmp_cache):
|
||||
from src.cache import MAX_MEDIA_RETRY_RUNS
|
||||
|
||||
for _ in range(MAX_MEDIA_RETRY_RUNS + 1):
|
||||
tmp_cache.mark_exported(
|
||||
"chatgpt", "conv-1",
|
||||
{"updated_at": self.UPDATED, "pending_media": ["sediment://file_x"]},
|
||||
)
|
||||
entry = tmp_cache.get_all_entries("chatgpt")["conv-1"]
|
||||
assert entry["pending_media"] == ["sediment://file_x"]
|
||||
|
||||
def test_joplin_link_survives_a_media_re_export(self, tmp_cache):
|
||||
"""Re-exporting to recover an asset must not orphan the Joplin note."""
|
||||
tmp_cache.mark_exported("chatgpt", "conv-1", {"updated_at": self.UPDATED})
|
||||
tmp_cache.mark_joplin_synced("chatgpt", "conv-1", "note-123")
|
||||
tmp_cache.set_joplin_resources("chatgpt", "conv-1", {"media/a.png": "res-1"})
|
||||
|
||||
tmp_cache.mark_exported(
|
||||
"chatgpt", "conv-1",
|
||||
{"updated_at": self.UPDATED, "pending_media": ["sediment://file_x"]},
|
||||
)
|
||||
entry = tmp_cache.get_all_entries("chatgpt")["conv-1"]
|
||||
assert entry["joplin_note_id"] == "note-123"
|
||||
# Resource ids are the idempotency record: without them the re-sync
|
||||
# would re-upload every image in the note, not just the new one.
|
||||
assert entry["joplin_resources"] == {"media/a.png": "res-1"}
|
||||
|
||||
def test_pending_media_survives_a_manifest_round_trip(self, tmp_path):
|
||||
from src.cache import Cache
|
||||
|
||||
c = Cache(tmp_path)
|
||||
c.mark_exported(
|
||||
"chatgpt", "conv-1",
|
||||
{"updated_at": self.UPDATED, "pending_media": ["sediment://file_x"]},
|
||||
)
|
||||
assert Cache(tmp_path).is_cached("chatgpt", "conv-1", self.UPDATED) is False
|
||||
|
||||
Reference in New Issue
Block a user