chunk-engine

Error Handling

What raises, when, with which exact message — and where the three SDKs still differ.

View raw

The same failure modes apply across all three SDKs — only how they surface differs:

LanguageHow errors surface
PythonRaises exceptions (ValueError, TypeError, FileNotFoundError, RuntimeError, OSError).
JavaScriptThrows / rejects with a ChunkError — an Error subclass with a kind. Every failure, engine-side and host-side, arrives this way.
RustReturns Err(ChunkError). Adversarial inputs fail cleanly and never panic (panic-prone third-party parsers are wrapped in a catch_unwind boundary).
Showing examples for Python
— your choice follows you across the docs.
from py_chunks import get_chunks

try:
    chunks = get_chunks("report.xyz")
except FileNotFoundError as e:
    ...   # File not found: report.xyz
except ValueError as e:
    ...   # unsupported extension, bad mode, bad window/overlap
except RuntimeError as e:
    ...   # the document could not be parsed

# Real messages:
# ValueError: Unsupported file type '.xyz'. Supported: .csv, .doc, .docm, ...
# ValueError: mode must be one of ['default', 'page_aware', 'section',
#             'semantic', 'sentence', 'sliding_window', 'structural'] for MD,
#             got: 'nope'
# ValueError: overlap must be less than window_size
# ValueError: filename is required when source is bytes

The variant table

The engine has four error variants. Each SDK expresses them in its own idiom; this is the mapping, read from the bindings:

Rust variantJS kindPython exceptionRaised when
ChunkError::Unsupported"unsupported"ValueErrorUnsupported extension or capability.
ChunkError::InvalidArg"invalid-arg"ValueErrorBad mode or parameter.
ChunkError::Parse"parse"RuntimeErrorThe document failed to parse or decode.
ChunkError::Io"io"OSError (IOError is the same class)An I/O failure inside the engine.
(a future variant)"unknown"RuntimeErrorThe failure carried no recognised variant tag. ChunkError is #[non_exhaustive], so both SDKs have a fallback arm rather than a build break.

These last two rows used to say “—”

They were never blank in the code. ChunkError::Io maps to PyIOError and the non_exhaustive fallback maps to PyRuntimeError, both in py_chunks/src/engine.rs.

Message parity — and where it stops

Parse messages produced by the engine are byte-identical across the SDKs. A corrupt DOCX raises exactly the same sentence everywhere — verified live, on both the path and the bytes route:

DOCX is not a valid zip archive: invalid Zip archive: Could not find EOCD

Branch on `kind`, not on message text

The parameter-range sentences now agree. Some formats validate their numeric ranges in the SDK's own host layer rather than in the engine, but every one of those layers has been aligned on the engine's own wording, so window_size must be greater than 0, sentences_per_chunk must be greater than 0, paragraphs_per_page must be greater than 0 and overlap must be less than window_size are byte-identical across all three SDKs and all formats. (Where a format renames the parameter the sentence names the format's own knob — CSV/TSV report rows_per_chunk must be greater than 0 for a zero paragraphs_per_page, because that is what the value maps onto.)

Two host-layer messages still differ between SDKs, and they are covered below: the unsupported-extension message and the invalid-mode message.

So still do not match on message text. The reliable contract is the kind ("invalid-arg") in JavaScript and Rust and the exception type (ValueError) in Python — those agree across all three SDKs and all formats. Treat message as something to log and show, not something to branch on.

Two further gaps sit in messages Python builds in its own validation layer, before the engine is reached.

1. The unsupported-extension message. Python appends the full supported set, JavaScript does not:

py:  ValueError: Unsupported file type '.zip'. Supported: .csv, .doc, .docm, … .xltx
js:  ChunkError: Unsupported file type '.zip'          (kind "unsupported")

2. The invalid-mode message. Python rejects an unknown mode in its own dispatch layer using one uniform template with a sorted list; JavaScript passes the mode through and surfaces whatever the engine's per-format check says. The wording therefore differs per format, not just in ordering and quoting:

md    py: mode must be one of ['default', 'page_aware', 'section', 'semantic', 'sentence', 'sliding_window', 'structural'] for MD, got: 'nope'
      js: mode must be one of ["default", "structural", "section", "semantic", "sentence", "page_aware", "sliding_window"] for MD, got: 'nope'

txt   py: mode must be one of ['default', 'page_aware', … 'structural'] for TXT, got: 'nope'
      js: Unknown TXT mode: nope

csv   py: mode must be one of ['default', 'page_aware', 'row', 'sliding_window'] for CSV, got: 'nope'
      js: mode must be 'row', 'default', 'sliding_window', or 'page_aware' for CSV

Both SDKs agree on the classificationValueError in Python, kind: "invalid-arg" in JavaScript — for every format. Only the sentence differs. Do not pattern-match an invalid-mode message across SDKs.

`kind` is a reliable argument/parse split

Every caller-argument mistake — unknown mode, overlap >= window_size, window_size = 0, sentences_per_chunk = 0, paragraphs_per_page = 0 — arrives as kind: "invalid-arg" in JavaScript and ValueError in Python, for every format and on both the path and the bytes route. Branch on kind (or on the exception type in Python); it is the stable signal. Engine-raised message text is byte-identical across SDKs and safe to match on too, but the two host-layer messages above are not.

Range checks are scoped to the mode that reads the parameter, which is what you want: paragraphs_per_page = 0 is rejected in page_aware and ignored in default, exactly as window_size = 0 is rejected in sliding_window and ignored elsewhere. A parameter a mode never reads is never validated against.

EPUB validates its arguments (fixed)

EPUB used to be the one format whose chunkers skipped the shared argument check: getChunks("book.epub", { mode: "sliding_window", windowSize: 100, overlap: 100 }) returned an empty array instead of throwing, and it did so in all three SDKs — Python's chunkers/epub.py checked the mode string and nothing else, so get_chunks(..., window_size=100, overlap=100) also returned []. (An earlier version of this page said Python still raised ValueError. It did not.)

EPUB now runs the same validator as every other format, at all four entry points and before the book is parsed, on both the path and the bytes route:

py: ValueError: overlap must be less than window_size
js: ChunkError: overlap must be less than window_size     (kind "invalid-arg")

Valid calls are unaffected — Moby Dick still returns 1,440 chunks at the defaults. There is no longer any format for which a bad argument is silent.

JavaScript: ChunkError covers the whole surface

import { getChunks, ChunkError, type ChunkErrorKind } from "js-chunks";

try {
  await getChunks(bytes, { filename: "data.xyz" });
} catch (e) {
  if (e instanceof ChunkError) {
    e.kind;    // "unsupported" | "invalid-arg" | "parse" | "io" | "unknown"
    e.message; // the engine's message, byte-identical to py-chunks
  } else {
    throw e;   // see below
  }
}

Every engine failure is a ChunkError, and so is every host-side throw the wrapper performs before the engine is reached:

Host-side failurekindExample message
A filesystem path used off Node"invalid-arg"
An unsupported source type"invalid-arg"
Byte input with no resolvable filename"invalid-arg"A filename is required for byte sources …
opts is not an object (a string, a number)"invalid-arg"opts must be an object, got string.
A getter on opts throws"invalid-arg"opts.windowSize could not be read: boom
filename / mode is not a string"invalid-arg"filename must be a string, got number.
A numeric option is not a number, or is NaN"invalid-arg"windowSize must be a number, got string.
A numeric option is fractional"invalid-arg"windowSize must be an integer, got 2.5.
A numeric option is negative"invalid-arg"window_size must be greater than 0
A path that cannot be read (missing file, permissions, a directory)"io"ENOENT: no such file or directory, open './missing.md'
A Blob whose arrayBuffer() rejects"io"the rejection's own message

The contract is complete: there is no input for which getChunks / getMarkdown / streamChunks throws something that is not a ChunkError. getChunks(bytes, null) is treated as getChunks(bytes)null is how an untyped caller spells "no options" — so it fails on the missing filename, with kind: "invalid-arg", rather than on a dereference.

Two deliberate choices in that table

Out-of-range numbers reuse the engine's sentence (window_size must be greater than 0, snake_case), so the message is the same whichever side rejects. Type mistakes name the camelCase key you actually typed (windowSize must be an integer), because they have no engine counterpart.

Zero is left to the engine. windowSize: 0 and paragraphsPerPage: 0 are not caught host-side, so the message names the parameter the target format uses — paragraphsPerPage: 0 on a CSV reports rows_per_chunk must be greater than 0, which is the knob that value actually maps onto. A host-side duplicate would have replaced that with a misleading sentence. overlap: 0 remains legal.

A failed Blob read reports kind: "io", matching what the path branch reports for the same condition: the source's bytes could not be obtained.

A missing file is a ChunkError with kind io

getChunks("./missing.md") reads the path with node:fs, and that read is now wrapped. Node's message is preserved verbatim:

ChunkError: ENOENT: no such file or directory, open './missing.md'
  e instanceof ChunkError → true
  e.kind                  → "io"

Verified live against the current build. Python raises FileNotFoundError: File not found: ./missing.md for the same input — the classification now matches (both say "this path could not be read"), but the message text still differs, so do not match on it across SDKs.

Keeping an else { throw e } in a catch that narrows on ChunkError is still good practice — it lets genuinely unexpected errors (a bug in your own callback, an AbortError) propagate instead of being swallowed.

Upgrading

Earlier releases threw bare strings across the WASM boundary, so e instanceof Error was false and there was no .stack. Engine failures are now always real Error instances. Code that only did String(e) keeps working; narrowing with e instanceof ChunkError is what gets you kind.

Python exception reference

Every exception py-chunks raises in its own Python layer, before the engine is reached. Read from py_chunks/_sources.py and py_chunks/_dispatch.py.

ExceptionMessageRaised when
FileNotFoundErrorFile not found: {path}The path does not exist. Checked before the extension, so a missing archive.zip is a FileNotFoundError, not an unsupported-type error.
ValueErrordata is emptyA bytes source is empty (b"").
ValueErrorfilename is required when source is bytesBytes passed with no filename=.
ValueErrorfilename is required when file object has no nameA file-like object with no .name and no filename=.
ValueErrorfilename is required when URL path has no filenameA URL whose path has no final segment.
ValueErrorupload_file.filename is requiredAn upload object whose .filename is empty.
ValueErrorUnsupported file type '{ext}'. Supported: {list}Extension not in the dispatch table.
ValueErrorget_markdown does not support '{ext}'. Supported: {list}Extension has no Markdown converter.
TypeErrorUnsupported source type. Use path/URL, bytes, file-like object, or upload object.The source matched none of the accepted shapes.
TypeErrorupload_file.read() is async; pass upload_file.file or use bytes API.read() returned an awaitable.
TypeError{what}.read() must return bytes or strA file-like or upload .read() returned something else.
TypeErrorupload_file must provide .file.read() or .read()The upload object exposes neither.
NotImplementedErrorStreaming not yet supported for {ext} filesNo streaming entry point for the extension. All 36 supported extensions have one, so this is currently unreachable.

Every stream_chunks_from_* helper raises the same set as its get_chunks_from_* counterpart, and every format-specific stream_chunk_<fmt>() helper now runs the same mode and range validation as its batch counterpart. A mis-typed mode or a zero count raises ValueError from the streaming helpers too, on the first call rather than on the first next()stream_chunk_doc and stream_chunk_ppt used to silently stream default-mode chunks for an unknown mode, and stream_chunk_docx used to raise NotImplementedError: Streaming for <mode> mode coming soon. Neither happens any more.

bytes and path validate identically

Passing bytes is not a way around validation. The bytes route runs the same Python-side option validators as the path route, so a bad mode, overlap >= window_size, sentences_per_chunk = 0 and an unsupported extension all produce the same exception type and the same message string whether you passed a path or (data, filename). Verified live for all four.

One asymmetry remains, and it is Python-only: get_chunks(b"", filename="a.md") raises ValueError: data is empty from the host layer, while JavaScript has no such generic check and lets the engine answer — ChunkError(kind: "parse"): Markdown file is empty after decoding.

Unsupported or mismatched extension

Dispatch is by file extension. If the extension isn't supported — or the filename you pass with bytes doesn't match the actual content — you get an error naming the unsupported type.

Bytes need a filename

When passing raw bytes, always include filename= (or use get_chunks_from_bytes(data, filename) / a named Blob). Without an extension there is no way to pick a chunker.

Invalid mode parameters

  • sliding_window: window_size must be > 0 and overlap must be strictly less than window_size — it is not clamped.
  • sentence: sentences_per_chunk must be > 0.
  • page_aware: paragraphs_per_page must be > 0.
  • Passing a mode a format doesn't support raises, and the message names the modes that format does accept.

All of these are ValueError in Python and kind: "invalid-arg" in JavaScript, for every format — EPUB included — and on both the path and the bytes route. The range sentences are uniform too: the document formats and the spreadsheet family now use the same wording, and so do all three SDKs.

overlap must be less than window_size
window_size must be greater than 0
sentences_per_chunk must be greater than 0
paragraphs_per_page must be greater than 0
rows_per_chunk must be greater than 0     # CSV / TSV, which is what
                                          # paragraphs_per_page maps onto there

The invalid-mode sentence is the one that still varies — the two samples below are the JavaScript/engine form, and Python words it differently (see the message-parity section above). Treat them as illustrative, not a contract: branch on kind / the exception type.

mode must be one of [page_aware, row, semantic, sheet, sliding_window, table] for XLSX, got: 'nope'
mode must be 'row', 'default', 'sliding_window', or 'page_aware' for CSV

Async uploads

If you pass an upload object whose read() is a coroutine, py-chunks raises a TypeError:

upload_file.read() is async; pass upload_file.file or use bytes API

In async handlers, read the bytes yourself and use the bytes API:

data = await file.read()
chunks = get_chunks_from_bytes(data, file.filename)

For the formats that render through the shared Markdown pipeline — .md, .eml, .mbox, .msg, .odt, .odp, .rtf, .json/.jsonl/.ndjson, .ipynb, .pdf and .epubget_chunks follows CommonMark on angle brackets. DOCX, TXT, HTML and the spreadsheet family use separate chunkers and never had this behaviour.

in the sourcein chunk contentwhy
<bbb@ddd.com>kept as bbb@ddd.comCommonMark email autolink
<https://example.com>kept as the URLCommonMark URI autolink
<span class="x">text</span>tags removed, text keptraw inline HTML
<Placeholder>, generic<T>removedraw inline HTML by specification

Fixed in 0.6.3

Autolinks used to be classified as raw HTML and deleted, which silently removed 34 email addresses from .eml chunk content, 210 from .mbox and 10 from .msg**From:** John X. Doe <bbb@ddd.com> arrived as From: John X. Doe. They are now preserved. get_markdown was never affected and is unchanged.

The remaining case is deliberate and correct for real Markdown: <…> is raw inline HTML by specification, so a text-only chunk is right to strip it. Plan for it if you write about <T>, <placeholder> or <your-api-key> — escape them (\<T\>), fence them as code (`<T>`), or read the text from get_markdown, which returns everything verbatim.

Corrupt or unreadable files

Malformed documents surface parsing errors from the underlying engine. Treat failures per-document so one bad file doesn't halt a batch — see the batch recipe.

Scanned / image-only PDFs

A PDF with no text layer (a scan, or pages rendered as images) can't be chunked as text — the text modes raise:

RuntimeError: PDF contains no extractable text (100 page(s) scanned or
image-only). OCR is not enabled; pass list_images to get one rendered image
per page.

The image path is the exception: list_images=True succeeds and returns one image per page instead of raising.

A scanned page is usually a single embedded image, and that image is returned as it is stored — named image_p1_1.png, image_p2_1.png, … after the page it sits on. Only a page with no embedded image is rasterised, and those renders are named page_1.png, page_2.png, … Rendering is native-only: in the browser there is no rasteriser, so such a PDF reports that it has no text.

try:
    chunks = get_chunks("scanned.pdf")           # raises — no text layer
except RuntimeError:
    result = get_chunks("scanned.pdf", list_images=True)  # succeeds
    pages = [c for c in result.chunks if c["content_type"] == "image"]
    # → one PNG per page, ready for OCR downstream
    result.images["image_p1_1.png"]              # bytes for the first page

Page renders are large

Rendering is only used when there is no text to extract, and the rasters are full-resolution PNGs — roughly 600 KB per A4 page, so a 100-page scan is around 60 MB held in memory at once. A PDF that does have text is never rasterised: it returns its own embedded images as usual.

Legacy .doc too old

Only Word 97–2003 .doc files are supported. A pre-Word 97 binary raises RuntimeError: Pre-Word 97 .doc files are not supported. Convert to .docx first. — convert it (e.g. via LibreOffice) and retry.

HTML that is not UTF-8

An HTML document is decoded using its own declared encoding, so a windows-1251 or iso-8859-6 page reads correctly rather than failing or arriving as mojibake.

The order is:

  1. a BOM, if present — decisive
  2. valid UTF-8 — if the bytes decode as UTF-8, that wins
  3. the document's <meta charset=…> or <meta http-equiv="Content-Type" content="…; charset=…">, read from the first 1 KiB
  4. detection — the same ladder .txt uses: BOM-less UTF-16 from the NUL pattern, then a statistical charset detector for 8-bit encodings

Step 3 reads only the first 1 KiB because that is what the HTML standard's prescan specifies. A declaration further into the file is therefore missed by step 3 and settled by step 4, which is why the detector matters: it previously decoded every 8-bit document as Windows-1252 regardless of content, so a windows-1251 page returned mojibake rather than Cyrillic.

Step 2 comes before the declaration on purpose. A file that is valid UTF-8 while declaring something else is mislabelled, and trusting the bytes that actually decode means this behaviour can never change the output of a document that was already correct.

Changed in 0.6.3

Earlier releases assumed UTF-8. Depending on which function you called, a non-UTF-8 page either raised an I/O error or came back with every non-ASCII byte replaced by U+FFFD. Both are fixed, and every entry point — path, bytes, Markdown, and all seven chunking modes — now decodes identically.

Files other parsers read that we do not

A small number of well-formed files are rejected because the underlying parser does not cover that corner of the format. They fail cleanly with a typed exception — nothing crashes, nothing is silently truncated — but they will not chunk. Verified 2026-08-16 against py-chunks 0.6.2:

inputerrorstatus
Some .ods with unusual table structure (e.g. LibreOffice's matrix.ods, ManualColWidthRowHeight.ods)Ods error: Expecting 'table-cell', found Text(…)open — spreadsheet-reader limitation
Some .xlsb (e.g. Tika's testEXCEL.xlsb)malformed or unsupported spreadsheet (parser panic) — caught, re-raised as a normal exceptionopen
Certain damaged-xref PDFsFailed to parse PDF: Invalid cross-reference tableopen

Fixed in 0.6.2

Xls error: Invalid rich extended string length (POI's 57456.xls) is resolved — the spreadsheet reader was upgraded and that file now opens. If you hit a spreadsheet error on an older release, retry on current.

The practical guidance is the same as for any parse failure: catch the exception, log the path, and fall back to a converted copy (LibreOffice round-trips all of these). Do not treat a rejection as "the document is empty" — that distinction is what the typed exception exists to give you.

Images are batch-only

list_images / listImages is not accepted by the streaming entry points — image extraction needs the whole document. If you need image bytes, use get_chunks(..., list_images=True) (batch) — see Streaming.

On this page