Skip to content

Commit 8083f3e

Browse files
fix(export): surface bulk partial failures via X-Export-Warnings header (#73)
* 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. * fix(export): cap X-Export-Warnings header and sanitize failure messages * fix(export): address PR #73 review (Literal phase, header guard, tests) Remove redundant JSONDecodeError branch, tighten phase typing, add last-resort header byte cap, and add truncation/incremental bookmark tests. * fix(export): move ErrorCode to models to avoid Flask in utils (PR #73) Extract ErrorCode to models/error_codes.py so export_engine no longer transitively imports Flask via api. Document export-phase exc handling. * fix(export): derive failure_count from failures list (PR #73 review)
1 parent 57462fd commit 8083f3e

7 files changed

Lines changed: 303 additions & 23 deletions

File tree

api/error_codes.py

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,12 @@
1-
"""Stable machine-readable error codes for API JSON error responses."""
1+
"""HTTP error envelope helpers; :class:`ErrorCode` lives in :mod:`models.error_codes`."""
22

33
from __future__ import annotations
44

5-
from enum import StrEnum
6-
75
from flask import Response, jsonify
86

7+
from models.error_codes import ErrorCode
98

10-
class ErrorCode(StrEnum):
11-
SEARCH_INVALID_LIMIT = "SEARCH_INVALID_LIMIT"
12-
INVALID_PATH = "INVALID_PATH"
13-
SESSION_NOT_FOUND = "SESSION_NOT_FOUND"
14-
INVALID_REQUEST_BODY = "INVALID_REQUEST_BODY"
15-
INVALID_SINCE_MODE = "INVALID_SINCE_MODE"
16-
PARSE_ERROR = "PARSE_ERROR"
17-
EXPORT_NOTHING_TO_EXPORT = "EXPORT_NOTHING_TO_EXPORT"
18-
INTERNAL_ERROR = "INTERNAL_ERROR"
9+
__all__ = ["ErrorCode", "error_response"]
1910

2011

2112
def error_response(

api/export_api.py

Lines changed: 69 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,
@@ -31,6 +37,10 @@
3137
# Tests monkeypatch this path; keep in sync with utils.export_state_store.
3238
_STATE_FILE = EXPORT_STATE_FILE
3339

40+
_EXPORT_WARNINGS_ZIP_NAME = "export-warnings.json"
41+
_EXPORT_WARNINGS_HEADER_MAX_ENTRIES = 20
42+
_EXPORT_WARNINGS_HEADER_MAX_BYTES = 8192
43+
3444

3545
def _state_lock() -> Any:
3646
return export_state_lock(_STATE_FILE)
@@ -49,6 +59,42 @@ def _read_state() -> ExportStateDict:
4959
return _load_state_from_disk()
5060

5161

62+
def _serialize_export_failures(failures: list[ExportFailure]) -> list[dict[str, object]]:
63+
return [
64+
{
65+
"session_id": item.session_id,
66+
"code": str(item.code),
67+
"message": item.message,
68+
}
69+
for item in failures
70+
]
71+
72+
73+
def _export_warnings_header_payload(
74+
failures: list[ExportFailure],
75+
) -> dict[str, object]:
76+
"""Bounded summary for X-Export-Warnings; full list lives in export-warnings.json."""
77+
entries = _serialize_export_failures(failures)
78+
total = len(entries)
79+
sample = entries[:_EXPORT_WARNINGS_HEADER_MAX_ENTRIES]
80+
truncated = total > len(sample)
81+
payload: dict[str, object] = {
82+
"total_failures": total,
83+
"truncated": truncated,
84+
"failures": sample,
85+
}
86+
while (
87+
len(json.dumps(payload, separators=(",", ":"))) > _EXPORT_WARNINGS_HEADER_MAX_BYTES
88+
and len(sample) > 1
89+
):
90+
sample = sample[:-1]
91+
truncated = True
92+
payload = {"total_failures": total, "truncated": truncated, "failures": sample}
93+
if len(json.dumps(payload, separators=(",", ":"))) > _EXPORT_WARNINGS_HEADER_MAX_BYTES:
94+
payload = {"total_failures": total, "truncated": True, "failures": []}
95+
return payload
96+
97+
5298
def _write_state(sessions_map: dict[str, float], count: int) -> None:
5399
"""Persist merge of *sessions_map* and update last-export metadata (*count* = this run only)."""
54100
with _state_lock():
@@ -119,12 +165,27 @@ def _on_export_error(sid: str, exc: Exception) -> None:
119165
manifest_style="api",
120166
on_export_error=_on_export_error,
121167
)
168+
if result.failures and result.exported_session_count > 0:
169+
full_warnings = _serialize_export_failures(result.failures)
170+
zf.writestr(
171+
_EXPORT_WARNINGS_ZIP_NAME,
172+
json.dumps(full_warnings, separators=(",", ":")) + "\n",
173+
)
122174

123175
count = result.exported_session_count
124176
new_sessions_map = result.new_sessions_map
125177
latest_day = result.latest_day
178+
failure_payload = _serialize_export_failures(result.failures)
126179

127180
if count == 0:
181+
if result.failures:
182+
return error_response(
183+
ErrorCode.EXPORT_ALL_FAILED,
184+
"All export candidates failed",
185+
422,
186+
since=since,
187+
failures=failure_payload,
188+
)
128189
return error_response(
129190
ErrorCode.EXPORT_NOTHING_TO_EXPORT,
130191
"Nothing to export",
@@ -145,12 +206,18 @@ def _on_export_error(sid: str, exc: Exception) -> None:
145206
suffix = "-incremental"
146207
else:
147208
suffix = ""
148-
return send_file(
209+
resp = send_file(
149210
buf,
150211
mimetype="application/zip",
151212
as_attachment=True,
152213
download_name=f"claude-code-export{suffix}-{date_tag}.zip", # type: ignore[call-arg]
153214
)
215+
if result.failures:
216+
resp.headers["X-Export-Warnings"] = json.dumps(
217+
_export_warnings_header_payload(result.failures),
218+
separators=(",", ":"),
219+
)
220+
return resp
154221

155222

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

docs/api-reference.md

Lines changed: 12 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,23 @@ 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 surfaced two ways:
377+
378+
| Channel | When | Value |
379+
|---------|------|--------|
380+
| `X-Export-Warnings` header | Partial export (≥1 success, ≥1 failure) | JSON object: `{ "total_failures", "truncated", "failures" }` where `failures` is a capped sample |
381+
| `export-warnings.json` in the ZIP | Same | Full array of `{ "session_id", "code", "message" }` |
382+
383+
`message` is a stable generic string per `code` (no exception text or paths). `code` uses the same strings as the error catalog (`PARSE_ERROR`, `INTERNAL_ERROR`, etc.).
384+
375385
#### Errors
376386

377387
| Status | `code` | When | Extra fields |
378388
|--------|--------|------|--------------|
379389
| 400 | `INVALID_REQUEST_BODY` | Body is not a JSON object ||
380390
| 400 | `INVALID_SINCE_MODE` | Invalid `since` value | `since` echoes rejected value |
381-
| 422 | `EXPORT_NOTHING_TO_EXPORT` | Zero sessions matched | `since` echoes request mode |
391+
| 422 | `EXPORT_NOTHING_TO_EXPORT` | Zero sessions matched (none attempted) | `since` echoes request mode |
392+
| 422 | `EXPORT_ALL_FAILED` | Candidates existed but every attempted session failed | `since`, `failures` — flat array of `{"session_id", "code", "message"}` objects (same item shape as the `failures` array inside the `X-Export-Warnings` header) |
382393

383394
```bash
384395
curl -X POST -H "Content-Type: application/json" \

models/error_codes.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
"""Stable machine-readable error codes (shared by API and utils; no Flask dependency)."""
2+
3+
from __future__ import annotations
4+
5+
from enum import StrEnum
6+
7+
8+
class ErrorCode(StrEnum):
9+
SEARCH_INVALID_LIMIT = "SEARCH_INVALID_LIMIT"
10+
INVALID_PATH = "INVALID_PATH"
11+
SESSION_NOT_FOUND = "SESSION_NOT_FOUND"
12+
INVALID_REQUEST_BODY = "INVALID_REQUEST_BODY"
13+
INVALID_SINCE_MODE = "INVALID_SINCE_MODE"
14+
PARSE_ERROR = "PARSE_ERROR"
15+
EXPORT_NOTHING_TO_EXPORT = "EXPORT_NOTHING_TO_EXPORT"
16+
EXPORT_ALL_FAILED = "EXPORT_ALL_FAILED"
17+
INTERNAL_ERROR = "INTERNAL_ERROR"

tests/test_cli_export_exit_codes.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@
1313
sys.path.insert(0, str(REPO_ROOT))
1414

1515
import scripts.export as export
16+
from models.error_codes import ErrorCode
1617
from tests.test_cli_e2e import _run_cli, _seed_base_dir
17-
from utils.export_engine import BulkExportResult
18+
from utils.export_engine import BulkExportResult, ExportFailure
1819
from utils.jsonl_parser import parse_session
1920

2021
_SUMMARY_RE = re.compile(
@@ -133,8 +134,17 @@ def _track_exit(result: BulkExportResult) -> None:
133134

134135

135136
def test_since_last_early_return_exits_one_on_failure(tmp_path, monkeypatch, capsys):
136-
"""Since-last early-return with failure_count>0 must produce real exit code 1."""
137-
fake_result = BulkExportResult(latest_day=None, failure_count=1)
137+
"""Since-last early-return with failures must produce real exit code 1."""
138+
fake_result = BulkExportResult(
139+
latest_day=None,
140+
failures=[
141+
ExportFailure(
142+
session_id="session_fail",
143+
message="Failed to parse session",
144+
code=ErrorCode.PARSE_ERROR,
145+
)
146+
],
147+
)
138148

139149
monkeypatch.setattr(export, "run_bulk_export", lambda **kwargs: fake_result)
140150
monkeypatch.setattr(export, "list_projects", lambda base: [{"name": "p", "path": "/p"}])

tests/test_export_api_bulk.py

Lines changed: 132 additions & 1 deletion
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
@@ -13,7 +15,10 @@
1315

1416
from flask import Flask
1517

16-
from api.export_api import export_bp
18+
from api.error_codes import ErrorCode
19+
from api.export_api import _export_warnings_header_payload, export_bp
20+
from utils.export_engine import ExportFailure
21+
from utils.jsonl_parser import parse_session
1722

1823

1924
@pytest.fixture
@@ -69,6 +74,132 @@ def test_bulk_export_empty_returns_422_json(isolated_state, tmp_path):
6974
assert body["since"] == "all"
7075

7176

77+
def test_bulk_export_all_succeed_no_warnings_header(client):
78+
resp = client.post("/api/export", json={"since": "all"})
79+
assert resp.status_code == 200
80+
assert resp.content_type.startswith("application/zip")
81+
assert "X-Export-Warnings" not in resp.headers
82+
zf = zipfile.ZipFile(io.BytesIO(resp.data))
83+
md_files = [name for name in zf.namelist() if name.endswith(".md")]
84+
assert len(md_files) == 2
85+
86+
87+
def test_bulk_export_partial_fail_returns_warning_header(client, monkeypatch):
88+
real_parse = parse_session
89+
90+
def flaky_parse(path: str):
91+
if path.endswith("session_def456.jsonl"):
92+
raise json.JSONDecodeError("bad", "doc", 0)
93+
return real_parse(path)
94+
95+
monkeypatch.setattr("utils.export_engine.parse_session", flaky_parse)
96+
resp = client.post("/api/export", json={"since": "all"})
97+
assert resp.status_code == 200
98+
assert "X-Export-Warnings" in resp.headers
99+
header = json.loads(resp.headers["X-Export-Warnings"])
100+
assert header["total_failures"] == 1
101+
assert header["truncated"] is False
102+
assert len(header["failures"]) == 1
103+
assert header["failures"][0]["session_id"] == "session_def456"
104+
assert header["failures"][0]["code"] == "PARSE_ERROR"
105+
assert header["failures"][0]["message"] == "Failed to parse session"
106+
zf = zipfile.ZipFile(io.BytesIO(resp.data))
107+
assert len([name for name in zf.namelist() if name.endswith(".md")]) == 1
108+
zip_warnings = json.loads(zf.read("export-warnings.json").decode("utf-8"))
109+
assert len(zip_warnings) == 1
110+
assert zip_warnings[0]["session_id"] == "session_def456"
111+
assert "bad" not in zip_warnings[0]["message"]
112+
113+
114+
def test_bulk_export_all_fail_returns_422(client, monkeypatch):
115+
def always_fail(path: str):
116+
raise json.JSONDecodeError("bad", "doc", 0)
117+
118+
monkeypatch.setattr("utils.export_engine.parse_session", always_fail)
119+
resp = client.post("/api/export", json={"since": "all"})
120+
assert resp.status_code == 422
121+
body = resp.get_json()
122+
assert body["code"] == "EXPORT_ALL_FAILED"
123+
assert body["since"] == "all"
124+
assert len(body["failures"]) == 2
125+
assert {item["code"] for item in body["failures"]} == {"PARSE_ERROR"}
126+
assert all(item["message"] == "Failed to parse session" for item in body["failures"])
127+
128+
129+
def test_export_warnings_header_payload_truncates_at_entry_limit():
130+
failures = [
131+
ExportFailure(
132+
session_id=f"sess_{i:04d}",
133+
message="Failed to parse session",
134+
code=ErrorCode.PARSE_ERROR,
135+
)
136+
for i in range(25)
137+
]
138+
payload = _export_warnings_header_payload(failures)
139+
assert payload["total_failures"] == 25
140+
assert payload["truncated"] is True
141+
assert len(payload["failures"]) <= 20
142+
143+
144+
def test_export_warnings_header_payload_byte_overflow_fallback(monkeypatch):
145+
monkeypatch.setattr("api.export_api._EXPORT_WARNINGS_HEADER_MAX_BYTES", 80)
146+
failures = [
147+
ExportFailure(
148+
session_id="x" * 200,
149+
message="Failed to parse session",
150+
code=ErrorCode.PARSE_ERROR,
151+
)
152+
]
153+
payload = _export_warnings_header_payload(failures)
154+
assert payload["truncated"] is True
155+
assert payload["failures"] == []
156+
assert len(json.dumps(payload, separators=(",", ":"))) <= 80
157+
158+
159+
def test_bulk_export_partial_fail_incremental_excludes_failed_from_state(
160+
client, monkeypatch, export_state_file
161+
):
162+
export_state_file.write_text(
163+
json.dumps({"sessions": {}, "exportedCount": 0}),
164+
encoding="utf-8",
165+
)
166+
real_parse = parse_session
167+
168+
def flaky_parse(path: str):
169+
if path.endswith("session_def456.jsonl"):
170+
raise json.JSONDecodeError("bad", "doc", 0)
171+
return real_parse(path)
172+
173+
monkeypatch.setattr("utils.export_engine.parse_session", flaky_parse)
174+
resp = client.post("/api/export", json={"since": "incremental"})
175+
assert resp.status_code == 200
176+
177+
state = json.loads(export_state_file.read_text(encoding="utf-8"))
178+
sessions = state.get("sessions", {})
179+
assert "session_abc123" in sessions
180+
assert "session_def456" not in sessions
181+
182+
183+
def test_bulk_export_partial_fail_excludes_failed_from_state(
184+
client, monkeypatch, export_state_file
185+
):
186+
real_parse = parse_session
187+
188+
def flaky_parse(path: str):
189+
if path.endswith("session_def456.jsonl"):
190+
raise json.JSONDecodeError("bad", "doc", 0)
191+
return real_parse(path)
192+
193+
monkeypatch.setattr("utils.export_engine.parse_session", flaky_parse)
194+
resp = client.post("/api/export", json={"since": "all"})
195+
assert resp.status_code == 200
196+
197+
state = json.loads(export_state_file.read_text(encoding="utf-8"))
198+
sessions = state.get("sessions", {})
199+
assert "session_abc123" in sessions
200+
assert "session_def456" not in sessions
201+
202+
72203
def test_export_state_json_fields(isolated_state):
73204
isolated_state.write_text(
74205
json.dumps(

0 commit comments

Comments
 (0)