Error Handling
What raises, when, with which exact message — and where the three SDKs still differ.
The same failure modes apply across all three SDKs — only how they surface differs:
| Language | How errors surface |
|---|---|
| Python | Raises exceptions (ValueError, TypeError, FileNotFoundError, RuntimeError, OSError). |
| JavaScript | Throws / rejects with a ChunkError — an Error subclass with a kind. Every failure, engine-side and host-side, arrives this way. |
| Rust | Returns Err(ChunkError). Adversarial inputs fail cleanly and never panic (panic-prone third-party parsers are wrapped in a catch_unwind boundary). |
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 bytesThe 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 variant | JS kind | Python exception | Raised when |
|---|---|---|---|
ChunkError::Unsupported | "unsupported" | ValueError | Unsupported extension or capability. |
ChunkError::InvalidArg | "invalid-arg" | ValueError | Bad mode or parameter. |
ChunkError::Parse | "parse" | RuntimeError | The 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" | RuntimeError | The 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 EOCDBranch 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 CSVBoth SDKs agree on the classification — ValueError 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 failure | kind | Example 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.
| Exception | Message | Raised when |
|---|---|---|
FileNotFoundError | File 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. |
ValueError | data is empty | A bytes source is empty (b""). |
ValueError | filename is required when source is bytes | Bytes passed with no filename=. |
ValueError | filename is required when file object has no name | A file-like object with no .name and no filename=. |
ValueError | filename is required when URL path has no filename | A URL whose path has no final segment. |
ValueError | upload_file.filename is required | An upload object whose .filename is empty. |
ValueError | Unsupported file type '{ext}'. Supported: {list} | Extension not in the dispatch table. |
ValueError | get_markdown does not support '{ext}'. Supported: {list} | Extension has no Markdown converter. |
TypeError | Unsupported source type. Use path/URL, bytes, file-like object, or upload object. | The source matched none of the accepted shapes. |
TypeError | upload_file.read() is async; pass upload_file.file or use bytes API | .read() returned an awaitable. |
TypeError | {what}.read() must return bytes or str | A file-like or upload .read() returned something else. |
TypeError | upload_file must provide .file.read() or .read() | The upload object exposes neither. |
NotImplementedError | Streaming not yet supported for {ext} files | No 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_sizemust be > 0 andoverlapmust be strictly less thanwindow_size— it is not clamped.sentence:sentences_per_chunkmust be > 0.page_aware:paragraphs_per_pagemust be > 0.- Passing a
modea 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 thereThe 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 CSVAsync 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)Angle brackets: autolinks are kept, raw HTML is not
For the formats that render through the shared Markdown pipeline — .md,
.eml, .mbox, .msg, .odt, .odp, .rtf, .json/.jsonl/.ndjson,
.ipynb, .pdf and .epub — get_chunks follows CommonMark on angle
brackets. DOCX, TXT, HTML and the spreadsheet family use separate chunkers and
never had this behaviour.
| in the source | in chunk content | why |
|---|---|---|
<bbb@ddd.com> | kept as bbb@ddd.com | CommonMark email autolink |
<https://example.com> | kept as the URL | CommonMark URI autolink |
<span class="x">text</span> | tags removed, text kept | raw inline HTML |
<Placeholder>, generic<T> | removed | raw 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 pagePage 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:
- a BOM, if present — decisive
- valid UTF-8 — if the bytes decode as UTF-8, that wins
- the document's
<meta charset=…>or<meta http-equiv="Content-Type" content="…; charset=…">, read from the first 1 KiB - detection — the same ladder
.txtuses: 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:
| input | error | status |
|---|---|---|
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 exception | open |
| Certain damaged-xref PDFs | Failed to parse PDF: Invalid cross-reference table | open |
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.