Skip to content

Commit 367ded9

Browse files
fix(export): surface bulk partial failures via X-Export-Warnings header
Accumulate structured ExportFailure entries in BulkExportResult, return 422 EXPORT_ALL_FAILED when every candidate fails, and attach X-Export-Warnings on partial 200 ZIP responses without changing the stable ZIP body contract.
1 parent 32e60f4 commit 367ded9

5 files changed

Lines changed: 141 additions & 5 deletions

File tree

api/error_codes.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ class ErrorCode(StrEnum):
1515
INVALID_SINCE_MODE = "INVALID_SINCE_MODE"
1616
PARSE_ERROR = "PARSE_ERROR"
1717
EXPORT_NOTHING_TO_EXPORT = "EXPORT_NOTHING_TO_EXPORT"
18+
EXPORT_ALL_FAILED = "EXPORT_ALL_FAILED"
1819
INTERNAL_ERROR = "INTERNAL_ERROR"
1920

2021

api/export_api.py

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Export endpoints -- bulk zip download and single-session md/json."""
22

33
import io
4+
import json
45
import os
56
import zipfile
67
from datetime import datetime
@@ -12,7 +13,12 @@
1213
from api.error_codes import ErrorCode, error_response
1314
from models.export import ExportStateDict
1415
from utils.exclusion_rules import is_session_excluded
15-
from utils.export_engine import EXPORT_ERRORS as _EXPORT_ERRORS, ZipSink, run_bulk_export
16+
from utils.export_engine import (
17+
EXPORT_ERRORS as _EXPORT_ERRORS,
18+
ExportFailure,
19+
ZipSink,
20+
run_bulk_export,
21+
)
1622
from utils.export_state_store import (
1723
EXPORT_STATE_FILE,
1824
atomic_write_export_state,
@@ -49,6 +55,17 @@ def _read_state() -> ExportStateDict:
4955
return _load_state_from_disk()
5056

5157

58+
def _serialize_export_failures(failures: list[ExportFailure]) -> list[dict[str, str]]:
59+
return [
60+
{
61+
"session_id": item.session_id,
62+
"code": str(item.code),
63+
"message": item.message,
64+
}
65+
for item in failures
66+
]
67+
68+
5269
def _write_state(sessions_map: dict[str, float], count: int) -> None:
5370
"""Persist merge of *sessions_map* and update last-export metadata (*count* = this run only)."""
5471
with _state_lock():
@@ -123,8 +140,17 @@ def _on_export_error(sid: str, exc: Exception) -> None:
123140
count = result.exported_session_count
124141
new_sessions_map = result.new_sessions_map
125142
latest_day = result.latest_day
143+
failure_payload = _serialize_export_failures(result.failures)
126144

127145
if count == 0:
146+
if result.failures:
147+
return error_response(
148+
ErrorCode.EXPORT_ALL_FAILED,
149+
"All export candidates failed",
150+
422,
151+
since=since,
152+
failures=failure_payload,
153+
)
128154
return error_response(
129155
ErrorCode.EXPORT_NOTHING_TO_EXPORT,
130156
"Nothing to export",
@@ -145,12 +171,15 @@ def _on_export_error(sid: str, exc: Exception) -> None:
145171
suffix = "-incremental"
146172
else:
147173
suffix = ""
148-
return send_file(
174+
resp = send_file(
149175
buf,
150176
mimetype="application/zip",
151177
as_attachment=True,
152178
download_name=f"claude-code-export{suffix}-{date_tag}.zip", # type: ignore[call-arg]
153179
)
180+
if result.failures:
181+
resp.headers["X-Export-Warnings"] = json.dumps(failure_payload)
182+
return resp
154183

155184

156185
@export_bp.route("/api/export/session/<path:project_name>/<session_id>")

docs/api-reference.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ Extra fields may appear for specific codes (for example `since` on invalid bulk-
6262
| `INVALID_SINCE_MODE` | 400 | `POST /api/export` | `since` is not `all`, `last`, or `incremental` |
6363
| `PARSE_ERROR` | 500 | Session, stats, export session | JSONL file could not be parsed |
6464
| `EXPORT_NOTHING_TO_EXPORT` | 422 | `POST /api/export` | No sessions matched the requested slice |
65+
| `EXPORT_ALL_FAILED` | 422 | `POST /api/export` | At least one session was attempted but every candidate failed |
6566
| `INTERNAL_ERROR` | 500 | `GET .../stats`, export session | Unexpected failure after parse (e.g. stats computation) |
6667

6768
---
@@ -372,13 +373,22 @@ Filename pattern:
372373

373374
Zip contains Markdown per session and optional `manifest.jsonl` metadata.
374375

376+
When some sessions fail but at least one succeeds, the response is still **`200`** with the ZIP body (successful sessions only). Skipped sessions are listed in the response header:
377+
378+
| Header | When | Value |
379+
|--------|------|--------|
380+
| `X-Export-Warnings` | Partial export (≥1 success, ≥1 failure) | JSON array of `{ "session_id", "code", "message" }` |
381+
382+
`code` uses the same strings as the error catalog (`PARSE_ERROR`, `INTERNAL_ERROR`, etc.).
383+
375384
#### Errors
376385

377386
| Status | `code` | When | Extra fields |
378387
|--------|--------|------|--------------|
379388
| 400 | `INVALID_REQUEST_BODY` | Body is not a JSON object ||
380389
| 400 | `INVALID_SINCE_MODE` | Invalid `since` value | `since` echoes rejected value |
381-
| 422 | `EXPORT_NOTHING_TO_EXPORT` | Zero sessions matched | `since` echoes request mode |
390+
| 422 | `EXPORT_NOTHING_TO_EXPORT` | Zero sessions matched (none attempted) | `since` echoes request mode |
391+
| 422 | `EXPORT_ALL_FAILED` | Candidates existed but every attempted session failed | `since`, `failures` (same shape as `X-Export-Warnings` entries) |
382392

383393
```bash
384394
curl -X POST -H "Content-Type: application/json" \

tests/test_export_api_bulk.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22

33
from __future__ import annotations
44

5+
import io
56
import json
67
import sys
8+
import zipfile
79
from pathlib import Path
810

911
import pytest
@@ -14,6 +16,7 @@
1416
from flask import Flask
1517

1618
from api.export_api import export_bp
19+
from utils.jsonl_parser import parse_session
1720

1821

1922
@pytest.fixture
@@ -69,6 +72,70 @@ def test_bulk_export_empty_returns_422_json(isolated_state, tmp_path):
6972
assert body["since"] == "all"
7073

7174

75+
def test_bulk_export_all_succeed_no_warnings_header(client):
76+
resp = client.post("/api/export", json={"since": "all"})
77+
assert resp.status_code == 200
78+
assert resp.content_type.startswith("application/zip")
79+
assert "X-Export-Warnings" not in resp.headers
80+
zf = zipfile.ZipFile(io.BytesIO(resp.data))
81+
md_files = [name for name in zf.namelist() if name.endswith(".md")]
82+
assert len(md_files) == 2
83+
84+
85+
def test_bulk_export_partial_fail_returns_warning_header(client, monkeypatch):
86+
real_parse = parse_session
87+
88+
def flaky_parse(path: str):
89+
if path.endswith("session_def456.jsonl"):
90+
raise json.JSONDecodeError("bad", "doc", 0)
91+
return real_parse(path)
92+
93+
monkeypatch.setattr("utils.export_engine.parse_session", flaky_parse)
94+
resp = client.post("/api/export", json={"since": "all"})
95+
assert resp.status_code == 200
96+
assert "X-Export-Warnings" in resp.headers
97+
warnings = json.loads(resp.headers["X-Export-Warnings"])
98+
assert len(warnings) == 1
99+
assert warnings[0]["session_id"] == "session_def456"
100+
assert warnings[0]["code"] == "PARSE_ERROR"
101+
zf = zipfile.ZipFile(io.BytesIO(resp.data))
102+
assert len([name for name in zf.namelist() if name.endswith(".md")]) == 1
103+
104+
105+
def test_bulk_export_all_fail_returns_422(client, monkeypatch):
106+
def always_fail(path: str):
107+
raise json.JSONDecodeError("bad", "doc", 0)
108+
109+
monkeypatch.setattr("utils.export_engine.parse_session", always_fail)
110+
resp = client.post("/api/export", json={"since": "all"})
111+
assert resp.status_code == 422
112+
body = resp.get_json()
113+
assert body["code"] == "EXPORT_ALL_FAILED"
114+
assert body["since"] == "all"
115+
assert len(body["failures"]) == 2
116+
assert {item["code"] for item in body["failures"]} == {"PARSE_ERROR"}
117+
118+
119+
def test_bulk_export_partial_fail_excludes_failed_from_state(
120+
client, monkeypatch, export_state_file
121+
):
122+
real_parse = parse_session
123+
124+
def flaky_parse(path: str):
125+
if path.endswith("session_def456.jsonl"):
126+
raise json.JSONDecodeError("bad", "doc", 0)
127+
return real_parse(path)
128+
129+
monkeypatch.setattr("utils.export_engine.parse_session", flaky_parse)
130+
resp = client.post("/api/export", json={"since": "all"})
131+
assert resp.status_code == 200
132+
133+
state = json.loads(export_state_file.read_text(encoding="utf-8"))
134+
sessions = state.get("sessions", {})
135+
assert "session_abc123" in sessions
136+
assert "session_def456" not in sessions
137+
138+
72139
def test_export_state_json_fields(isolated_state):
73140
isolated_state.write_text(
74141
json.dumps(

utils/export_engine.py

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from datetime import date, datetime, timezone
1010
from typing import Any, Callable, Literal, Protocol
1111

12+
from api.error_codes import ErrorCode
1213
from models.project import ProjectDict, SessionListItemDict
1314
from models.session import SessionDict, SessionMetadataDict
1415
from models.stats import SessionStatsDict
@@ -59,6 +60,26 @@ def serialize_manifest_jsonl(manifest: list[dict[str, Any]]) -> str:
5960
return "\n".join(json.dumps(e, default=str) for e in manifest) + "\n"
6061

6162

63+
@dataclass
64+
class ExportFailure:
65+
"""One per-session bulk export failure for API warning/error payloads."""
66+
67+
session_id: str
68+
message: str
69+
code: ErrorCode
70+
71+
72+
def failure_code_for_exception(exc: Exception, *, phase: str = "parse") -> ErrorCode:
73+
"""Map an export exception to a stable :class:`ErrorCode`."""
74+
if phase == "export":
75+
return ErrorCode.INTERNAL_ERROR
76+
if isinstance(exc, json.JSONDecodeError):
77+
return ErrorCode.PARSE_ERROR
78+
if isinstance(exc, EXPORT_ERRORS):
79+
return ErrorCode.PARSE_ERROR
80+
return ErrorCode.INTERNAL_ERROR
81+
82+
6283
@dataclass
6384
class BulkExportResult:
6485
"""Outcome of a bulk export run."""
@@ -69,6 +90,7 @@ class BulkExportResult:
6990
new_sessions_map: dict[str, float] = field(default_factory=dict)
7091
exported_session_count: int = 0
7192
failure_count: int = 0
93+
failures: list[ExportFailure] = field(default_factory=list)
7294
skipped_count: int = 0
7395
skipped_mtime_unchanged_count: int = 0
7496
total_candidates: int = 0
@@ -262,8 +284,15 @@ def run_bulk_export(
262284
result = BulkExportResult()
263285
manifest: list[dict[str, Any]] = []
264286

265-
def _record_failure(sid: str, exc: Exception) -> None:
287+
def _record_failure(sid: str, exc: Exception, *, phase: str = "parse") -> None:
266288
result.failure_count += 1
289+
result.failures.append(
290+
ExportFailure(
291+
session_id=sid,
292+
message=str(exc),
293+
code=failure_code_for_exception(exc, phase=phase),
294+
)
295+
)
267296
if on_export_error is not None:
268297
on_export_error(sid, exc)
269298

@@ -283,7 +312,7 @@ def _export_parsed(
283312
result.new_sessions_map[sid] = float(sess_info.get("modified", 0))
284313
result.exported_session_count += 1
285314
except Exception as exc:
286-
_record_failure(sid, exc)
315+
_record_failure(sid, exc, phase="export")
287316

288317
if since == "last":
289318
latest_day, rows, scan_total = collect_sessions_for_latest_activity_day(

0 commit comments

Comments
 (0)