Skip to content

Commit 2dc2d25

Browse files
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.
1 parent 1029d31 commit 2dc2d25

4 files changed

Lines changed: 72 additions & 7 deletions

File tree

api/export_api.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ def _read_state() -> ExportStateDict:
5959
return _load_state_from_disk()
6060

6161

62-
def _serialize_export_failures(failures: list[ExportFailure]) -> list[dict[str, str]]:
62+
def _serialize_export_failures(failures: list[ExportFailure]) -> list[dict[str, object]]:
6363
return [
6464
{
6565
"session_id": item.session_id,
@@ -90,6 +90,8 @@ def _export_warnings_header_payload(
9090
sample = sample[:-1]
9191
truncated = True
9292
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": []}
9395
return payload
9496

9597

docs/api-reference.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -389,7 +389,7 @@ When some sessions fail but at least one succeeds, the response is still **`200`
389389
| 400 | `INVALID_REQUEST_BODY` | Body is not a JSON object ||
390390
| 400 | `INVALID_SINCE_MODE` | Invalid `since` value | `since` echoes rejected value |
391391
| 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` (same shape as `X-Export-Warnings` entries) |
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) |
393393

394394
```bash
395395
curl -X POST -H "Content-Type: application/json" \

tests/test_export_api_bulk.py

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@
1515

1616
from flask import Flask
1717

18-
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
1921
from utils.jsonl_parser import parse_session
2022

2123

@@ -124,6 +126,60 @@ def always_fail(path: str):
124126
assert all(item["message"] == "Failed to parse session" for item in body["failures"])
125127

126128

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+
127183
def test_bulk_export_partial_fail_excludes_failed_from_state(
128184
client, monkeypatch, export_state_file
129185
):

utils/export_engine.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,12 +69,14 @@ class ExportFailure:
6969
code: ErrorCode
7070

7171

72-
def failure_code_for_exception(exc: Exception, *, phase: str = "parse") -> ErrorCode:
72+
def failure_code_for_exception(
73+
exc: Exception,
74+
*,
75+
phase: Literal["parse", "export"] = "parse",
76+
) -> ErrorCode:
7377
"""Map an export exception to a stable :class:`ErrorCode`."""
7478
if phase == "export":
7579
return ErrorCode.INTERNAL_ERROR
76-
if isinstance(exc, json.JSONDecodeError):
77-
return ErrorCode.PARSE_ERROR
7880
if isinstance(exc, EXPORT_ERRORS):
7981
return ErrorCode.PARSE_ERROR
8082
return ErrorCode.INTERNAL_ERROR
@@ -293,7 +295,12 @@ def run_bulk_export(
293295
result = BulkExportResult()
294296
manifest: list[dict[str, Any]] = []
295297

296-
def _record_failure(sid: str, exc: Exception, *, phase: str = "parse") -> None:
298+
def _record_failure(
299+
sid: str,
300+
exc: Exception,
301+
*,
302+
phase: Literal["parse", "export"] = "parse",
303+
) -> None:
297304
result.failure_count += 1
298305
code = failure_code_for_exception(exc, phase=phase)
299306
result.failures.append(

0 commit comments

Comments
 (0)