Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions api/error_codes.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ class ErrorCode(StrEnum):
INVALID_SINCE_MODE = "INVALID_SINCE_MODE"
PARSE_ERROR = "PARSE_ERROR"
EXPORT_NOTHING_TO_EXPORT = "EXPORT_NOTHING_TO_EXPORT"
EXPORT_ALL_FAILED = "EXPORT_ALL_FAILED"
INTERNAL_ERROR = "INTERNAL_ERROR"


Expand Down
71 changes: 69 additions & 2 deletions api/export_api.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Export endpoints -- bulk zip download and single-session md/json."""

import io
import json
import os
import zipfile
from datetime import datetime
Expand All @@ -12,7 +13,12 @@
from api.error_codes import ErrorCode, error_response
from models.export import ExportStateDict
from utils.exclusion_rules import is_session_excluded
from utils.export_engine import EXPORT_ERRORS as _EXPORT_ERRORS, ZipSink, run_bulk_export
from utils.export_engine import (
EXPORT_ERRORS as _EXPORT_ERRORS,
ExportFailure,
ZipSink,
run_bulk_export,
)
from utils.export_state_store import (
EXPORT_STATE_FILE,
atomic_write_export_state,
Expand All @@ -31,6 +37,10 @@
# Tests monkeypatch this path; keep in sync with utils.export_state_store.
_STATE_FILE = EXPORT_STATE_FILE

_EXPORT_WARNINGS_ZIP_NAME = "export-warnings.json"
_EXPORT_WARNINGS_HEADER_MAX_ENTRIES = 20
_EXPORT_WARNINGS_HEADER_MAX_BYTES = 8192


def _state_lock() -> Any:
return export_state_lock(_STATE_FILE)
Expand All @@ -49,6 +59,42 @@ def _read_state() -> ExportStateDict:
return _load_state_from_disk()


def _serialize_export_failures(failures: list[ExportFailure]) -> list[dict[str, object]]:
return [
{
"session_id": item.session_id,
"code": str(item.code),
"message": item.message,
}
for item in failures
]


def _export_warnings_header_payload(
failures: list[ExportFailure],
) -> dict[str, object]:
"""Bounded summary for X-Export-Warnings; full list lives in export-warnings.json."""
entries = _serialize_export_failures(failures)
total = len(entries)
sample = entries[:_EXPORT_WARNINGS_HEADER_MAX_ENTRIES]
truncated = total > len(sample)
payload: dict[str, object] = {
"total_failures": total,
"truncated": truncated,
"failures": sample,
}
while (
len(json.dumps(payload, separators=(",", ":"))) > _EXPORT_WARNINGS_HEADER_MAX_BYTES
and len(sample) > 1
):
sample = sample[:-1]
truncated = True
payload = {"total_failures": total, "truncated": truncated, "failures": sample}
if len(json.dumps(payload, separators=(",", ":"))) > _EXPORT_WARNINGS_HEADER_MAX_BYTES:
payload = {"total_failures": total, "truncated": True, "failures": []}
return payload


def _write_state(sessions_map: dict[str, float], count: int) -> None:
"""Persist merge of *sessions_map* and update last-export metadata (*count* = this run only)."""
with _state_lock():
Expand Down Expand Up @@ -119,12 +165,27 @@ def _on_export_error(sid: str, exc: Exception) -> None:
manifest_style="api",
on_export_error=_on_export_error,
)
if result.failures and result.exported_session_count > 0:
full_warnings = _serialize_export_failures(result.failures)
zf.writestr(
_EXPORT_WARNINGS_ZIP_NAME,
json.dumps(full_warnings, separators=(",", ":")) + "\n",
)

count = result.exported_session_count
new_sessions_map = result.new_sessions_map
latest_day = result.latest_day
failure_payload = _serialize_export_failures(result.failures)

if count == 0:
if result.failures:
return error_response(
ErrorCode.EXPORT_ALL_FAILED,
"All export candidates failed",
422,
since=since,
failures=failure_payload,
)
return error_response(
ErrorCode.EXPORT_NOTHING_TO_EXPORT,
"Nothing to export",
Expand All @@ -145,12 +206,18 @@ def _on_export_error(sid: str, exc: Exception) -> None:
suffix = "-incremental"
else:
suffix = ""
return send_file(
resp = send_file(
buf,
mimetype="application/zip",
as_attachment=True,
download_name=f"claude-code-export{suffix}-{date_tag}.zip", # type: ignore[call-arg]
)
if result.failures:
resp.headers["X-Export-Warnings"] = json.dumps(
_export_warnings_header_payload(result.failures),
separators=(",", ":"),
)
return resp
Comment thread
coderabbitai[bot] marked this conversation as resolved.


@export_bp.route("/api/export/session/<path:project_name>/<session_id>")
Expand Down
13 changes: 12 additions & 1 deletion docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ Extra fields may appear for specific codes (for example `since` on invalid bulk-
| `INVALID_SINCE_MODE` | 400 | `POST /api/export` | `since` is not `all`, `last`, or `incremental` |
| `PARSE_ERROR` | 500 | Session, stats, export session | JSONL file could not be parsed |
| `EXPORT_NOTHING_TO_EXPORT` | 422 | `POST /api/export` | No sessions matched the requested slice |
| `EXPORT_ALL_FAILED` | 422 | `POST /api/export` | At least one session was attempted but every candidate failed |
| `INTERNAL_ERROR` | 500 | `GET .../stats`, export session | Unexpected failure after parse (e.g. stats computation) |

---
Expand Down Expand Up @@ -372,13 +373,23 @@ Filename pattern:

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

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:

| Channel | When | Value |
|---------|------|--------|
| `X-Export-Warnings` header | Partial export (≥1 success, ≥1 failure) | JSON object: `{ "total_failures", "truncated", "failures" }` where `failures` is a capped sample |
| `export-warnings.json` in the ZIP | Same | Full array of `{ "session_id", "code", "message" }` |

`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.).

#### Errors

| Status | `code` | When | Extra fields |
|--------|--------|------|--------------|
| 400 | `INVALID_REQUEST_BODY` | Body is not a JSON object | — |
| 400 | `INVALID_SINCE_MODE` | Invalid `since` value | `since` echoes rejected value |
| 422 | `EXPORT_NOTHING_TO_EXPORT` | Zero sessions matched | `since` echoes request mode |
| 422 | `EXPORT_NOTHING_TO_EXPORT` | Zero sessions matched (none attempted) | `since` echoes request mode |
| 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) |

```bash
curl -X POST -H "Content-Type: application/json" \
Expand Down
133 changes: 132 additions & 1 deletion tests/test_export_api_bulk.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

from __future__ import annotations

import io
import json
import sys
import zipfile
from pathlib import Path

import pytest
Expand All @@ -13,7 +15,10 @@

from flask import Flask

from api.export_api import export_bp
from api.error_codes import ErrorCode
from api.export_api import _export_warnings_header_payload, export_bp
from utils.export_engine import ExportFailure
from utils.jsonl_parser import parse_session


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


def test_bulk_export_all_succeed_no_warnings_header(client):
resp = client.post("/api/export", json={"since": "all"})
assert resp.status_code == 200
assert resp.content_type.startswith("application/zip")
assert "X-Export-Warnings" not in resp.headers
zf = zipfile.ZipFile(io.BytesIO(resp.data))
md_files = [name for name in zf.namelist() if name.endswith(".md")]
assert len(md_files) == 2


def test_bulk_export_partial_fail_returns_warning_header(client, monkeypatch):
real_parse = parse_session

def flaky_parse(path: str):
if path.endswith("session_def456.jsonl"):
raise json.JSONDecodeError("bad", "doc", 0)
return real_parse(path)

monkeypatch.setattr("utils.export_engine.parse_session", flaky_parse)
resp = client.post("/api/export", json={"since": "all"})
assert resp.status_code == 200
assert "X-Export-Warnings" in resp.headers
header = json.loads(resp.headers["X-Export-Warnings"])
assert header["total_failures"] == 1
assert header["truncated"] is False
assert len(header["failures"]) == 1
assert header["failures"][0]["session_id"] == "session_def456"
assert header["failures"][0]["code"] == "PARSE_ERROR"
assert header["failures"][0]["message"] == "Failed to parse session"
zf = zipfile.ZipFile(io.BytesIO(resp.data))
assert len([name for name in zf.namelist() if name.endswith(".md")]) == 1
zip_warnings = json.loads(zf.read("export-warnings.json").decode("utf-8"))
assert len(zip_warnings) == 1
assert zip_warnings[0]["session_id"] == "session_def456"
assert "bad" not in zip_warnings[0]["message"]


def test_bulk_export_all_fail_returns_422(client, monkeypatch):
def always_fail(path: str):
raise json.JSONDecodeError("bad", "doc", 0)

monkeypatch.setattr("utils.export_engine.parse_session", always_fail)
resp = client.post("/api/export", json={"since": "all"})
assert resp.status_code == 422
body = resp.get_json()
assert body["code"] == "EXPORT_ALL_FAILED"
assert body["since"] == "all"
assert len(body["failures"]) == 2
assert {item["code"] for item in body["failures"]} == {"PARSE_ERROR"}
assert all(item["message"] == "Failed to parse session" for item in body["failures"])


def test_export_warnings_header_payload_truncates_at_entry_limit():
failures = [
ExportFailure(
session_id=f"sess_{i:04d}",
message="Failed to parse session",
code=ErrorCode.PARSE_ERROR,
)
for i in range(25)
]
payload = _export_warnings_header_payload(failures)
assert payload["total_failures"] == 25
assert payload["truncated"] is True
assert len(payload["failures"]) <= 20


def test_export_warnings_header_payload_byte_overflow_fallback(monkeypatch):
monkeypatch.setattr("api.export_api._EXPORT_WARNINGS_HEADER_MAX_BYTES", 80)
failures = [
ExportFailure(
session_id="x" * 200,
message="Failed to parse session",
code=ErrorCode.PARSE_ERROR,
)
]
payload = _export_warnings_header_payload(failures)
assert payload["truncated"] is True
assert payload["failures"] == []
assert len(json.dumps(payload, separators=(",", ":"))) <= 80


def test_bulk_export_partial_fail_incremental_excludes_failed_from_state(
client, monkeypatch, export_state_file
):
export_state_file.write_text(
json.dumps({"sessions": {}, "exportedCount": 0}),
encoding="utf-8",
)
real_parse = parse_session

def flaky_parse(path: str):
if path.endswith("session_def456.jsonl"):
raise json.JSONDecodeError("bad", "doc", 0)
return real_parse(path)

monkeypatch.setattr("utils.export_engine.parse_session", flaky_parse)
resp = client.post("/api/export", json={"since": "incremental"})
assert resp.status_code == 200

state = json.loads(export_state_file.read_text(encoding="utf-8"))
sessions = state.get("sessions", {})
assert "session_abc123" in sessions
assert "session_def456" not in sessions


def test_bulk_export_partial_fail_excludes_failed_from_state(
client, monkeypatch, export_state_file
):
real_parse = parse_session

def flaky_parse(path: str):
if path.endswith("session_def456.jsonl"):
raise json.JSONDecodeError("bad", "doc", 0)
return real_parse(path)

monkeypatch.setattr("utils.export_engine.parse_session", flaky_parse)
resp = client.post("/api/export", json={"since": "all"})
assert resp.status_code == 200

state = json.loads(export_state_file.read_text(encoding="utf-8"))
sessions = state.get("sessions", {})
assert "session_abc123" in sessions
assert "session_def456" not in sessions


def test_export_state_json_fields(isolated_state):
isolated_state.write_text(
json.dumps(
Expand Down
Loading
Loading