Skip to content

Commit 996e1e8

Browse files
badGarnetclaude
andcommitted
fix: raise on an empty HTTP-200 split chunk in NDJSON mode (0.46.1)
In NDJSON elements-file mode a split-PDF chunk that returned 200 with an empty body was logged and skipped, so the combined elements_file was short by those pages while the call still succeeded. `split_pdf_allow_failed` does not cover it -- an empty 200 counts as a successful chunk -- and `combine_chunk_files_to_ndjson` returns only a path, so nothing downstream could detect the truncation. Recombination now raises `EmptyChunkResponseError` (a `ValueError`) instead, matching the buffered path, which raises `JSONDecodeError` on the same response. An empty JSON array is still accepted: `[]` and an empty body are different responses on the wire and only the latter is a defect. Refs ML-1672. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 6314d9a commit 996e1e8

5 files changed

Lines changed: 91 additions & 14 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 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. A chunk that legitimately produced no elements returns an empty JSON array and is still accepted.
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: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -118,22 +118,38 @@ 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+
def test_empty_chunk_files_raise(tmp_path, body):
122+
"""An empty 200 body must fail, not be skipped.
123+
124+
Skipping it made the document silently short: this function returns only a path and an
125+
element count, so nothing downstream could tell a truncated document from a complete
126+
one. `split_pdf_allow_failed` does not cover it either -- the chunk reported success.
127+
"""
122128
chunk_a = tmp_path / "a.json"
123129
chunk_empty = tmp_path / "empty.json"
124-
elements_a = _elements("a", 2)
125-
_write_json_array(chunk_a, elements_a)
130+
_write_json_array(chunk_a, _elements("a", 2))
126131
chunk_empty.write_text(body, encoding="utf-8")
127132

128133
out = tmp_path / "combined.ndjson"
129-
written = combine_chunk_files_to_ndjson([str(chunk_a), str(chunk_empty)], str(out))
134+
with pytest.raises(request_utils.EmptyChunkResponseError, match="empty.json"):
135+
combine_chunk_files_to_ndjson([str(chunk_a), str(chunk_empty)], str(out))
136+
130137

131-
assert written == 2
132-
assert _read_ndjson(out) == elements_a
138+
def test_empty_chunk_error_is_a_value_error(tmp_path):
139+
"""Parity with the buffered path, which raises `JSONDecodeError` (a `ValueError`)."""
140+
chunk_empty = tmp_path / "empty.json"
141+
chunk_empty.write_text("", encoding="utf-8")
142+
143+
with pytest.raises(ValueError):
144+
combine_chunk_files_to_ndjson([str(chunk_empty)], str(tmp_path / "combined.ndjson"))
133145

134146

135147
def test_empty_array_chunk_contributes_nothing(tmp_path):
136-
"""A chunk that legitimately produced no elements (e.g. blank pages)."""
148+
"""A chunk that legitimately produced no elements (e.g. blank pages).
149+
150+
The counterpart to `test_empty_chunk_files_raise`: `[]` and an empty body are
151+
different responses on the wire, and only the latter is a defect.
152+
"""
137153
chunk_a = tmp_path / "a.json"
138154
chunk_b = tmp_path / "b.json"
139155
_write_json_array(chunk_a, [])
@@ -546,6 +562,29 @@ def test_malformed_chunk_leaves_no_partial_output_behind(tmp_path):
546562
assert list(Path(tmp_path).glob("*.partial")) == []
547563

548564

565+
def test_empty_chunk_response_fails_the_operation(tmp_path):
566+
"""An empty 200 chunk must fail the whole partition, as the buffered path does.
567+
568+
Driven through the hook because that is where the consequence lives: previously the
569+
chunk was skipped and `_build_after_success_response` handed back a combined file that
570+
was short by those pages, with a 200 alongside it. Also asserts no partial or spilled
571+
file survives, since the raise happens mid-recombination.
572+
"""
573+
operation_id = "op-empty-chunk"
574+
hook = _hook_in_ndjson_mode(operation_id, tmp_path)
575+
responses = [
576+
(0, _ndjson_response(_elements("a", 2))),
577+
(1, httpx.Response(status_code=200, content=b"")),
578+
]
579+
580+
with pytest.raises(request_utils.EmptyChunkResponseError):
581+
hook._elements_from_task_responses(operation_id, responses, started_at=0.0)
582+
583+
assert operation_id not in hook.ndjson_output_path
584+
assert _ndjson_files_in(tmp_path) == []
585+
assert list(Path(tmp_path).glob("*.partial")) == []
586+
587+
549588
def test_no_combined_file_is_created_when_every_chunk_failed(tmp_path):
550589
operation_id = "op-all-failed"
551590
hook = _hook_in_ndjson_mode(operation_id, tmp_path)

‎src/unstructured_client/_hooks/custom/request_utils.py‎

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,19 @@ 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 that holds no elements at all.
295+
296+
An empty body is not the same wire response as an empty JSON array: `[]` is a chunk
297+
that legitimately produced no elements (blank pages) and is accepted, while nothing
298+
at all is a defect. Skipping it would make the document silently short, and
299+
`split_pdf_allow_failed` cannot catch it because the chunk reports success.
300+
301+
Subclasses `ValueError` so that it lands where the buffered path's `JSONDecodeError`
302+
(also a `ValueError`) already does for callers that guard the parse.
303+
"""
304+
305+
293306
def _first_non_space_char(stream: TextIO) -> str:
294307
"""Return the first non-whitespace character in `stream`, or "" if there is none."""
295308
while True:
@@ -322,21 +335,31 @@ def combine_chunk_files_to_ndjson(chunk_paths: list[str], out_path: str) -> int:
322335
323336
Returns:
324337
The number of elements written.
338+
339+
Raises:
340+
EmptyChunkResponseError: A chunk returned 200 with an empty body.
325341
"""
326342
total = 0
327343
with open(out_path, "w", encoding="utf-8") as out:
328344
for chunk_path in chunk_paths:
329345
with open(chunk_path, "r", encoding="utf-8") as chunk:
330346
first_char = _first_non_space_char(chunk)
331347
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(
348+
# A 200 with an empty body. Fail loudly: skipping it makes the document
349+
# silently short with no way for the caller to notice, since this
350+
# function's only output is the combined path. The buffered path raises
351+
# `JSONDecodeError` on the same response, so NDJSON mode must not turn
352+
# a hard failure into truncation.
353+
logger.error(
336354
"split_pdf event=ndjson_empty_chunk file=%s",
337355
os.path.basename(chunk_path),
338356
)
339-
continue
357+
raise EmptyChunkResponseError(
358+
"A split-PDF chunk returned HTTP 200 with an empty body "
359+
f"({os.path.basename(chunk_path)}); its elements would be missing "
360+
"from the combined output. An empty result must be an empty JSON "
361+
"array."
362+
)
340363
chunk.seek(0)
341364

342365
if first_char == "[":

‎src/unstructured_client/_version.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@
33
import importlib.metadata
44

55
__title__: str = "unstructured-client"
6-
__version__: str = "0.46.0"
6+
__version__: str = "0.46.1"
77
__openapi_doc_version__: str = "1.2.31"
88
__gen_version__: str = "2.680.0"
9-
__user_agent__: str = "speakeasy-sdk/python 0.46.0 2.680.0 1.2.31 unstructured-client"
9+
__user_agent__: str = "speakeasy-sdk/python 0.46.1 2.680.0 1.2.31 unstructured-client"
1010

1111
try:
1212
if __package__ is not None:

0 commit comments

Comments
 (0)