From 20770729c42c0b1c90a963b46365ec4ca89719c9 Mon Sep 17 00:00:00 2001 From: Amantux Date: Mon, 20 Jul 2026 19:35:51 +0000 Subject: [PATCH] NAS: downloading an offline-on-tape file returns actionable 409, not 404 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For a coherent NAS experience: a file that exists in the catalog but is not staged/cached is offline on tape, not missing. download_file returned a bare 404 (reads as "gone"); it now returns 409 with guidance to hydrate first (POST /virtual/hydrate) — while a truly-absent record still 404s. This matches the browse -> offline -> hydrate -> download journey a NAS client expects. Test: upload a file, drop all local copies (keep the record), assert download returns 409 with hydrate guidance; existing 404 (deleted/nonexistent) tests unaffected. 12 upload/download integration tests green. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_012K5Zjc2bggcyJtFbz2D4Pw --- openblade/api/routes_upload.py | 12 ++++++ .../test_upload_download_integration.py | 38 +++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/openblade/api/routes_upload.py b/openblade/api/routes_upload.py index 46fc199..4a44784 100644 --- a/openblade/api/routes_upload.py +++ b/openblade/api/routes_upload.py @@ -389,6 +389,18 @@ async def download_file( record = repo.get_nas_file_record(file_id) path = _resolve_file_path(file_id, record) if path is None: + if record is not None: + # The file exists in the catalog but is not staged/cached: it is offline + # on tape. Return an actionable 409 rather than a misleading 404 so a + # NAS client knows to hydrate it first, not that the file is gone. + state = str(record.get("status") or record.get("file_state") or "offline_on_tape") + raise HTTPException( + status_code=409, + detail=( + f"File {file_id} is {state} (offline on tape). " + "Hydrate it first (POST /virtual/hydrate), then download." + ), + ) raise HTTPException(status_code=404, detail=f"File {file_id} not found") filename = _sanitize_filename(record.get("relative_path") if record else file_id) return FileResponse(path=path, filename=filename) diff --git a/tests/integration/test_upload_download_integration.py b/tests/integration/test_upload_download_integration.py index 4c10ac8..1258417 100644 --- a/tests/integration/test_upload_download_integration.py +++ b/tests/integration/test_upload_download_integration.py @@ -235,3 +235,41 @@ def test_all_upload_download_require_auth(self, client: TestClient) -> None: assert client.get("/api/files/00000000-0000-4000-8000-000000000001/download").status_code in (401, 403) assert client.get("/api/pools/1/files").status_code in (401, 403) assert client.delete("/api/files/00000000-0000-4000-8000-000000000001").status_code in (401, 403) + + +def test_download_offline_file_returns_409_with_hydrate_guidance( + client: TestClient, admin_auth_headers: dict[str, str] +) -> None: + # NAS-experience coherence: an offline-on-tape file (record exists, nothing + # staged/cached) must return an actionable 409 telling the client to hydrate, + # not a misleading 404 that reads as "gone". + from pathlib import Path + + from openblade.api import routes_upload as ru + from openblade.bootstrap import get_context + + upload = client.post( + "/api/pools/integration-pool/upload", + files={"file": ("offline.txt", b"tape-resident bytes", "text/plain")}, + headers=admin_auth_headers, + ) + assert upload.status_code == 200 + file_id = upload.json()["file_id"] + assert client.get(f"/api/files/{file_id}/download", headers=admin_auth_headers).status_code == 200 + + # Simulate the file going offline: drop every local copy, keep the catalog record. + record = get_context().catalog.get_nas_file_record(file_id) + candidates = [ + ru._safe_resolve(ru._staging_dir(), file_id), + ru._safe_resolve(ru._restore_dir(), file_id), + ] + candidates += [ + Path(str(record[key])) for key in ("cache_path", "source_path") if record and record.get(key) + ] + for candidate in candidates: + if candidate.exists(): + candidate.unlink() + + dl = client.get(f"/api/files/{file_id}/download", headers=admin_auth_headers) + assert dl.status_code == 409, dl.status_code + assert "hydrate" in dl.json()["detail"].lower()