upload_spool: spool 403 (node not approved yet) instead of deleting (node-26#44)

A node waiting on approval would otherwise lose every call it records. The
512 MiB / 72h spool caps bound what a never-approved node keeps. 400 and 413
are still deleted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Logan Cusano
2026-10-27 20:17:53 -04:00
co-authored by Claude Opus 5.5
parent a4a575ec9f
commit b62912b6fe
2 changed files with 7 additions and 5 deletions
+5 -3
View File
@@ -15,8 +15,8 @@ Failure classes (see `classify`):
retry network error, timeout, 5xx, 2xx with no URL (C2 accepted the retry network error, timeout, 5xx, 2xx with no URL (C2 accepted the
request but storage failed), 401 (a reissued key is on its way request but storage failed), 401 (a reissued key is on its way
over MQTT/key pickup), 408, 429 over MQTT/key pickup), 403 (not approved yet), 408, 429
reject any other 4xx — 400 empty file, 403 not approved, 413 too large. reject any other 4xx — 400 empty file, 413 too large.
Retrying the same bytes cannot change the answer, so the file is Retrying the same bytes cannot change the answer, so the file is
deleted with an ERROR log naming the status. deleted with an ERROR log naming the status.
@@ -54,7 +54,9 @@ from app.internal.logger import logger
SIDECAR_SUFFIX = ".json" SIDECAR_SUFFIX = ".json"
UPLOAD_TIMEOUT_SECONDS = 120.0 UPLOAD_TIMEOUT_SECONDS = 120.0
# Statuses in the 4xx range that are worth retrying unchanged. # Statuses in the 4xx range that are worth retrying unchanged.
RETRYABLE_4XX = {401, 408, 429} # 403 = node not approved yet: approval can arrive later, and the spool caps
# bound what a never-approved node keeps.
RETRYABLE_4XX = {401, 403, 408, 429}
OK, RETRY, REJECT = "ok", "retry", "reject" OK, RETRY, REJECT = "ok", "retry", "reject"
+2 -2
View File
@@ -61,7 +61,7 @@ def _spool(tmp_path: Path, uploader, clock=None, **kw) -> UploadSpool:
(500, None, RETRY), (500, None, RETRY),
(503, None, RETRY), (503, None, RETRY),
(400, None, REJECT), (400, None, REJECT),
(403, None, REJECT), (403, None, RETRY), # not approved yet: approval may still come
(413, None, REJECT), (413, None, REJECT),
]) ])
def test_classify(status, url, kind): def test_classify(status, url, kind):
@@ -111,7 +111,7 @@ async def test_successful_upload_deletes_the_file(tmp_path, recorder, monkeypatc
async def test_rejected_upload_is_not_spooled(tmp_path, recorder, monkeypatch): async def test_rejected_upload_is_not_spooled(tmp_path, recorder, monkeypatch):
_patch_post(monkeypatch, UploadResult(REJECT, status=403, detail="HTTP 403: Node is not approved")) _patch_post(monkeypatch, UploadResult(REJECT, status=413, detail="HTTP 413: File too large"))
path = _recording(tmp_path) path = _recording(tmp_path)
assert await recorder.upload_recording(path, "call-1") is None assert await recorder.upload_recording(path, "call-1") is None
assert not path.exists() assert not path.exists()