Skip to content

Commit 56d2aaa

Browse files
badGarnetclaude
andauthored
fix: raise on an empty HTTP-200 split chunk in NDJSON mode (0.46.1) (#348)
## The bug In NDJSON elements-file mode, a split-PDF chunk that returned HTTP 200 with an empty body was logged and skipped, so the combined `elements_file` was silently short by that chunk's pages while the call still returned 200. Nothing downstream could detect it: `combine_chunk_files_to_ndjson` hands back only the combined path, and `split_pdf_allow_failed=False` does not cover the case because an empty 200 counts as a *successful* chunk. The buffered path fails outright on the same response (`res.json()` raises `JSONDecodeError`), so enabling NDJSON mode converted a hard failure into silent truncation. ## The fix Recombination now raises `EmptyChunkResponseError` (a `ValueError`, matching where the buffered path's `JSONDecodeError` lands) instead of skipping. Emptiness is judged against the chunk's own `Content-Type`, because the two formats disagree about what an empty body means: - **JSON** has no empty document — a chunk with no elements is `[]` — so an empty body is malformed and raises. - **`application/x-ndjson`** encodes zero records as zero lines, so an empty body is well formed. It contributes nothing and does not fail the partition; otherwise a split whose pages are blank would break once a server honors the accept header. - An **unknown or missing** media type is read as JSON. That is what the deployed API returns, and guessing NDJSON would reinstate the silent truncation. `combine_chunk_files_to_ndjson` takes an optional `media_types` list, positionally matched to `chunk_paths`; omitting it keeps the strict reading. `_elements_from_task_responses` collects each chunk's `Content-Type` before the cached branch overwrites the body with a temp-file path — the header survives both cache branches, so the cached case carries a real media type too. ## Tests `_test_unstructured_client/unit/test_ndjson_elements_file.py`: - empty JSON chunk raises, over 3 empty-ish bodies × `application/json` / unset / with-charset - empty NDJSON chunk is zero records, over 3 bodies × 3 media-type spellings (parameters, casing) - every chunk empty yields an empty output rather than an error - `ValueError` parity with the buffered path, and the strict default when `media_types` is omitted - hook level, both cache modes: an empty JSON chunk fails the operation and leaves no partial, spilled, or combined file behind; an empty NDJSON chunk is accepted with the surrounding chunks' elements intact Mutating `_is_ndjson_media_type` to `return False` fails 12 of these, including both hook-level cache-mode tests, so the coverage is load-bearing. 271 unit + 64 contract tests pass; pylint 10/10 on both changed modules. ## Release Bumped to 0.46.1 with CHANGELOG and RELEASES entries. Consumers that pin `unstructured-client >=0.46.0` for NDJSON elements-file mode should raise the floor to `>=0.46.1`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 6314d9a commit 56d2aaa

6 files changed

Lines changed: 267 additions & 21 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
## 0.46.1
2+
3+
### Fixes
4+
* Fail loudly when a split-PDF chunk returns HTTP 200 with an empty JSON body in NDJSON elements-file mode. The chunk used to be logged and skipped, so the combined `elements_file` was silently short by those pages while the call still returned 200 — and `split_pdf_allow_failed=False` did not catch it, because an empty 200 counts as a successful chunk. Recombination now raises `EmptyChunkResponseError` (a `ValueError`), matching the buffered path, which raises `JSONDecodeError` on the same response. Emptiness is judged against the chunk's own `Content-Type`: JSON has no empty document (a chunk with no elements is `[]`), while `application/x-ndjson` encodes zero records as zero lines, so an empty NDJSON chunk is well formed and still contributes nothing.
5+
16
## 0.46.0
27

38
### Features

RELEASES.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1251,3 +1251,13 @@ Based on:
12511251
- [python v0.46.0] .
12521252
### Releases
12531253
- [PyPI v0.46.0] https://pypi.org/project/unstructured-client/0.46.0 - .
1254+
1255+
## 2026-08-04 00:00:00
1256+
### Changes
1257+
Based on:
1258+
- OpenAPI Doc
1259+
- Speakeasy CLI 1.601.0 (2.680.0) https://github.com/speakeasy-api/speakeasy
1260+
### Generated
1261+
- [python v0.46.1] .
1262+
### Releases
1263+
- [PyPI v0.46.1] https://pypi.org/project/unstructured-client/0.46.1 - .

_test_unstructured_client/unit/test_ndjson_elements_file.py

Lines changed: 169 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -118,22 +118,98 @@ def test_mixed_chunk_formats(tmp_path):
118118

119119

120120
@pytest.mark.parametrize("body", ["", " ", "\n\n"])
121-
def test_empty_chunk_files_are_skipped(tmp_path, body):
121+
@pytest.mark.parametrize("media_type", [None, "application/json", "application/json; charset=utf-8"])
122+
def test_empty_json_chunk_files_raise(tmp_path, body, media_type):
123+
"""An empty 200 body from a JSON chunk must fail, not be skipped.
124+
125+
JSON has no empty document -- a chunk with no elements is `[]` -- so this is a
126+
malformed response. Skipping it made the document silently short: this function returns
127+
only a path and an element count, so nothing downstream could tell a truncated document
128+
from a complete one. `split_pdf_allow_failed` does not cover it either -- the chunk
129+
reported success. An unset `Content-Type` is treated as JSON, which is what the
130+
deployed API returns.
131+
"""
122132
chunk_a = tmp_path / "a.json"
123133
chunk_empty = tmp_path / "empty.json"
134+
_write_json_array(chunk_a, _elements("a", 2))
135+
chunk_empty.write_text(body, encoding="utf-8")
136+
137+
out = tmp_path / "combined.ndjson"
138+
with pytest.raises(request_utils.EmptyChunkResponseError, match="empty.json"):
139+
combine_chunk_files_to_ndjson(
140+
[str(chunk_a), str(chunk_empty)], str(out), ["application/json", media_type]
141+
)
142+
143+
144+
def test_empty_chunk_media_types_default_to_json_when_not_passed(tmp_path):
145+
"""Callers that cannot supply media types must still get the strict reading."""
146+
chunk_empty = tmp_path / "empty.json"
147+
chunk_empty.write_text("", encoding="utf-8")
148+
149+
with pytest.raises(request_utils.EmptyChunkResponseError):
150+
combine_chunk_files_to_ndjson([str(chunk_empty)], str(tmp_path / "combined.ndjson"))
151+
152+
153+
def test_empty_chunk_error_is_a_value_error(tmp_path):
154+
"""Parity with the buffered path, which raises `JSONDecodeError` (a `ValueError`)."""
155+
chunk_empty = tmp_path / "empty.json"
156+
chunk_empty.write_text("", encoding="utf-8")
157+
158+
with pytest.raises(ValueError):
159+
combine_chunk_files_to_ndjson(
160+
[str(chunk_empty)], str(tmp_path / "combined.ndjson"), ["application/json"]
161+
)
162+
163+
164+
@pytest.mark.parametrize("body", ["", " ", "\n\n"])
165+
@pytest.mark.parametrize(
166+
"media_type", ["application/x-ndjson", "application/x-ndjson; charset=utf-8", "APPLICATION/X-NDJSON"]
167+
)
168+
def test_empty_ndjson_chunk_is_zero_records(tmp_path, body, media_type):
169+
"""NDJSON spells zero records as zero lines, so an empty body is well formed.
170+
171+
The counterpart to `test_empty_json_chunk_files_raise`: the same empty file on disk is
172+
a defect from a JSON chunk and a legitimate result from an NDJSON one, and the declared
173+
media type is the only thing that separates them. Failing here would break a split
174+
whose pages are blank.
175+
"""
176+
chunk_a = tmp_path / "a.ndjson"
177+
chunk_empty = tmp_path / "empty.ndjson"
124178
elements_a = _elements("a", 2)
125-
_write_json_array(chunk_a, elements_a)
179+
_write_ndjson(chunk_a, elements_a)
126180
chunk_empty.write_text(body, encoding="utf-8")
127181

128182
out = tmp_path / "combined.ndjson"
129-
written = combine_chunk_files_to_ndjson([str(chunk_a), str(chunk_empty)], str(out))
183+
written = combine_chunk_files_to_ndjson(
184+
[str(chunk_a), str(chunk_empty)], str(out), ["application/x-ndjson", media_type]
185+
)
130186

131187
assert written == 2
132188
assert _read_ndjson(out) == elements_a
133189

134190

191+
def test_every_ndjson_chunk_empty_yields_an_empty_output(tmp_path):
192+
"""A document whose every chunk produced no elements is empty, not an error."""
193+
chunk_a = tmp_path / "a.ndjson"
194+
chunk_b = tmp_path / "b.ndjson"
195+
chunk_a.write_text("", encoding="utf-8")
196+
chunk_b.write_text("", encoding="utf-8")
197+
198+
out = tmp_path / "combined.ndjson"
199+
written = combine_chunk_files_to_ndjson(
200+
[str(chunk_a), str(chunk_b)], str(out), ["application/x-ndjson"] * 2
201+
)
202+
203+
assert written == 0
204+
assert out.read_text(encoding="utf-8") == ""
205+
206+
135207
def test_empty_array_chunk_contributes_nothing(tmp_path):
136-
"""A chunk that legitimately produced no elements (e.g. blank pages)."""
208+
"""A chunk that legitimately produced no elements (e.g. blank pages).
209+
210+
The JSON counterpart to `test_empty_ndjson_chunk_is_zero_records`: `[]` and an empty
211+
body are different responses on the wire, and only the latter is a defect.
212+
"""
137213
chunk_a = tmp_path / "a.json"
138214
chunk_b = tmp_path / "b.json"
139215
_write_json_array(chunk_a, [])
@@ -380,16 +456,31 @@ def test_server_cannot_name_a_local_file_via_a_response_header(tmp_path):
380456
# --- hook-level temp-file lifecycle ------------------------------------------------
381457

382458

383-
def _ndjson_response(elements):
459+
def _ndjson_response(elements, media_type="application/x-ndjson"):
384460
body = "".join(json.dumps(e) + "\n" for e in elements).encode()
385-
return httpx.Response(status_code=200, content=body)
461+
headers = {"content-type": media_type} if media_type else {}
462+
return httpx.Response(status_code=200, headers=headers, content=body)
463+
386464

465+
def _cached_chunk_response(path, media_type="application/x-ndjson"):
466+
"""What the cached path leaves behind: the body replaced by its temp-file path.
387467
388-
def _hook_in_ndjson_mode(operation_id, tmp_path):
468+
Mirrors `_await_elements`' cached branch, which streams the body to disk and rebuilds
469+
the response with `content=temp_file_name` and the server's original headers -- so the
470+
declared media type still describes the file's contents, not the path in the body.
471+
"""
472+
return httpx.Response(
473+
status_code=200,
474+
headers={"content-type": media_type} if media_type else {},
475+
content=str(path).encode(),
476+
)
477+
478+
479+
def _hook_in_ndjson_mode(operation_id, tmp_path, cache_tmp_data=False):
389480
"""A hook set up as `before_request` would leave it for an uncached NDJSON run."""
390481
hook = SplitPdfHook()
391482
hook.ndjson_mode[operation_id] = True
392-
hook.cache_tmp_data_feature[operation_id] = False
483+
hook.cache_tmp_data_feature[operation_id] = cache_tmp_data
393484
hook.cache_tmp_data_dir[operation_id] = str(tmp_path)
394485
hook.allow_failed[operation_id] = False
395486
# Marks the operation live; `_clear_operation` removing it is what signals teardown.
@@ -546,6 +637,76 @@ def test_malformed_chunk_leaves_no_partial_output_behind(tmp_path):
546637
assert list(Path(tmp_path).glob("*.partial")) == []
547638

548639

640+
@pytest.mark.parametrize("media_type", ["application/json", None])
641+
def test_empty_json_chunk_response_fails_the_operation(tmp_path, media_type):
642+
"""An empty 200 JSON chunk must fail the whole partition, as the buffered path does.
643+
644+
Driven through the hook because that is where the consequence lives: previously the
645+
chunk was skipped and `_build_after_success_response` handed back a combined file that
646+
was short by those pages, with a 200 alongside it. Also asserts no partial or spilled
647+
file survives, since the raise happens mid-recombination.
648+
"""
649+
operation_id = "op-empty-chunk"
650+
hook = _hook_in_ndjson_mode(operation_id, tmp_path)
651+
headers = {"content-type": media_type} if media_type else {}
652+
responses = [
653+
(0, _ndjson_response(_elements("a", 2))),
654+
(1, httpx.Response(status_code=200, headers=headers, content=b"")),
655+
]
656+
657+
with pytest.raises(request_utils.EmptyChunkResponseError):
658+
hook._elements_from_task_responses(operation_id, responses, started_at=0.0)
659+
660+
assert operation_id not in hook.ndjson_output_path
661+
assert _ndjson_files_in(tmp_path) == []
662+
assert list(Path(tmp_path).glob("*.partial")) == []
663+
664+
665+
def test_empty_ndjson_chunk_response_is_accepted_in_memory_mode(tmp_path):
666+
"""A zero-record NDJSON chunk is a valid result and must not fail the partition.
667+
668+
The default path (`cache_tmp_data` off): the empty body is spilled to disk, so the
669+
media type carried by the response is the only thing that distinguishes it from the
670+
malformed JSON case above.
671+
"""
672+
operation_id = "op-empty-ndjson-memory"
673+
hook = _hook_in_ndjson_mode(operation_id, tmp_path)
674+
elements_a, elements_c = _elements("a", 2), _elements("c", 3)
675+
responses = [
676+
(0, _ndjson_response(elements_a)),
677+
(1, _ndjson_response([])), # a chunk of blank pages
678+
(2, _ndjson_response(elements_c)),
679+
]
680+
681+
hook._elements_from_task_responses(operation_id, responses, started_at=0.0)
682+
683+
combined = hook.ndjson_output_path[operation_id]
684+
assert _read_ndjson(combined) == elements_a + elements_c
685+
# The spilled chunk files are gone; only the combined output survives.
686+
assert _ndjson_files_in(tmp_path) == [Path(combined).name]
687+
688+
689+
def test_empty_ndjson_chunk_response_is_accepted_in_cached_mode(tmp_path):
690+
"""Cached counterpart: the chunk file on disk is empty and the body is its path."""
691+
operation_id = "op-empty-ndjson-cached"
692+
hook = _hook_in_ndjson_mode(operation_id, tmp_path, cache_tmp_data=True)
693+
# Cached chunk files live in the operation's tempdir, kept out of the directory the
694+
# combined file lands in so the assertion below stays unambiguous.
695+
cache_dir = tmp_path / "cache"
696+
cache_dir.mkdir()
697+
elements_a = _elements("a", 2)
698+
chunk_a, chunk_empty = cache_dir / "a.json", cache_dir / "empty.json"
699+
_write_ndjson(chunk_a, elements_a)
700+
chunk_empty.write_text("", encoding="utf-8")
701+
responses = [(0, _cached_chunk_response(chunk_a)), (1, _cached_chunk_response(chunk_empty))]
702+
703+
hook._elements_from_task_responses(operation_id, responses, started_at=0.0)
704+
705+
combined = hook.ndjson_output_path[operation_id]
706+
assert _read_ndjson(combined) == elements_a
707+
assert _ndjson_files_in(tmp_path) == [Path(combined).name]
708+
709+
549710
def test_no_combined_file_is_created_when_every_chunk_failed(tmp_path):
550711
operation_id = "op-all-failed"
551712
hook = _hook_in_ndjson_mode(operation_id, tmp_path)

src/unstructured_client/_hooks/custom/request_utils.py

Lines changed: 64 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,28 @@ def create_response(elements: list) -> httpx.Response:
290290
_SNIFF_BLOCK_SIZE = 64
291291

292292

293+
class EmptyChunkResponseError(ValueError):
294+
"""A split-PDF chunk returned HTTP 200 with a body its own media type cannot produce.
295+
296+
Whether an empty body is a defect depends on the format the chunk claims. JSON has no
297+
empty document -- a chunk with no elements is `[]` -- so nothing at all is malformed,
298+
and skipping it would make the document silently short with no way for the caller to
299+
notice (`split_pdf_allow_failed` does not cover it, because the chunk reports
300+
success). NDJSON is a record per line, so an empty body is a well-formed zero-record
301+
response and is accepted.
302+
303+
Subclasses `ValueError` so that it lands where the buffered path's `JSONDecodeError`
304+
(also a `ValueError`) already does for callers that guard the parse.
305+
"""
306+
307+
308+
def _is_ndjson_media_type(media_type: Optional[str]) -> bool:
309+
"""Whether a chunk's `Content-Type` declares NDJSON, ignoring any parameters."""
310+
if not media_type:
311+
return False
312+
return media_type.split(";", 1)[0].strip().lower() == NDJSON_MEDIA_TYPE
313+
314+
293315
def _first_non_space_char(stream: TextIO) -> str:
294316
"""Return the first non-whitespace character in `stream`, or "" if there is none."""
295317
while True:
@@ -301,7 +323,11 @@ def _first_non_space_char(stream: TextIO) -> str:
301323
return stripped[0]
302324

303325

304-
def combine_chunk_files_to_ndjson(chunk_paths: list[str], out_path: str) -> int:
326+
def combine_chunk_files_to_ndjson(
327+
chunk_paths: list[str],
328+
out_path: str,
329+
media_types: Optional[list[Optional[str]]] = None,
330+
) -> int:
305331
"""Combine per-chunk split-PDF response files into one NDJSON file on disk.
306332
307333
Recombining chunks by parsing them builds four full copies of the document (a list
@@ -316,27 +342,57 @@ def combine_chunk_files_to_ndjson(chunk_paths: list[str], out_path: str) -> int:
316342
317343
NDJSON chunks are copied through without parsing.
318344
345+
An empty body is read against the chunk's declared media type, which is the only
346+
thing that distinguishes the two cases: NDJSON encodes zero records as zero lines, so
347+
an empty NDJSON chunk contributes nothing and is fine; JSON has no empty document, so
348+
an empty one is malformed and raises rather than silently shortening the result.
349+
A chunk whose media type is unknown is treated as JSON -- that is what the deployed
350+
API returns, and guessing NDJSON would reinstate the silent truncation.
351+
319352
Args:
320353
chunk_paths: Per-chunk response files, in element order.
321354
out_path: File to write the combined NDJSON to.
355+
media_types: The `Content-Type` each chunk was served with, positionally matching
356+
`chunk_paths`. Omit when the media types are not known.
322357
323358
Returns:
324359
The number of elements written.
360+
361+
Raises:
362+
EmptyChunkResponseError: A non-NDJSON chunk returned 200 with an empty body.
325363
"""
326364
total = 0
327365
with open(out_path, "w", encoding="utf-8") as out:
328-
for chunk_path in chunk_paths:
366+
for index, chunk_path in enumerate(chunk_paths):
367+
media_type = media_types[index] if media_types is not None else None
329368
with open(chunk_path, "r", encoding="utf-8") as chunk:
330369
first_char = _first_non_space_char(chunk)
331370
if not first_char:
332-
# A 200 with an empty body. Log it, because otherwise the document is
333-
# silently short and the element count cannot be reconciled against
334-
# the chunk count.
335-
logger.warning(
336-
"split_pdf event=ndjson_empty_chunk file=%s",
371+
if _is_ndjson_media_type(media_type):
372+
# Zero records, spelled the only way NDJSON can spell it.
373+
logger.debug(
374+
"split_pdf event=ndjson_empty_chunk file=%s media_type=%s",
375+
os.path.basename(chunk_path),
376+
media_type,
377+
)
378+
continue
379+
# A JSON 200 with an empty body. Fail loudly: skipping it makes the
380+
# document silently short with no way for the caller to notice, since
381+
# this function's only output is the combined path. The buffered path
382+
# raises `JSONDecodeError` on the same response, so NDJSON mode must
383+
# not turn a hard failure into truncation.
384+
logger.error(
385+
"split_pdf event=empty_chunk_body file=%s media_type=%s",
337386
os.path.basename(chunk_path),
387+
media_type,
388+
)
389+
raise EmptyChunkResponseError(
390+
"A split-PDF chunk returned HTTP 200 with an empty body "
391+
f"({os.path.basename(chunk_path)}, Content-Type: "
392+
f"{media_type or 'unset'}); its elements would be missing from the "
393+
"combined output. A JSON chunk with no elements must be an empty "
394+
"array."
338395
)
339-
continue
340396
chunk.seek(0)
341397

342398
if first_char == "[":

0 commit comments

Comments
 (0)