# 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). |

```python
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
```

```ts
import { getChunks, ChunkError } from "js-chunks";

try {
  const chunks = await getChunks("./report.xyz");
} catch (e) {
  if (e instanceof ChunkError) {
    // kind: "unsupported" | "invalid-arg" | "parse" | "io" | "unknown"
    console.error(e.kind, e.message);
  } else {
    throw e;   // defensive: every js-chunks failure is a ChunkError
  }
}

// unsupported   Unsupported file type '.xyz'
// invalid-arg   A filename is required for byte sources (pass opts.filename
//               or a named Blob) so the engine can route by extension.
// invalid-arg   overlap must be less than window_size
// io            ENOENT: no such file or directory, open './report.xyz'
```

```rust
use chunks_rs::{get_chunks, ChunkError};

fn main() {
    match get_chunks("report.xyz", "default", 3, 1, 3, 15) {
        Ok(chunks) => println!("{} chunks", chunks.len()),
        Err(ChunkError::Unsupported(m)) => eprintln!("unsupported: {m}"),
        Err(ChunkError::InvalidArg(m)) => eprintln!("bad argument: {m}"),
        Err(ChunkError::Parse(m)) => eprintln!("parse failed: {m}"),
        Err(ChunkError::Io(e)) => eprintln!("io: {e}"),
        // ChunkError is #[non_exhaustive] — a wildcard arm is required.
        Err(e) => eprintln!("other: {e}"),
    }
}

// unsupported: Unsupported file type '.xyz'
```

## 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 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. |

  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
```

  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 *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.

  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 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

```ts

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.

  **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.

  `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.

  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.

  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.

  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 &gt; 0 and `overlap` must be **strictly
  less than** `window_size` — it is not clamped.
- `sentence`: `sentences_per_chunk` must be &gt; 0.
- `page_aware`: `paragraphs_per_page` must be &gt; 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

**Python**

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:

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

**JavaScript** / **Rust**

This case is Python-specific — it exists because Python upload objects
(`UploadFile`, Django's `UploadedFile`, …) may expose an async `read()`. Switch
to Python above to read it.

## 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 |

  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](/docs/recipes#8-batch-a-folder-of-mixed-formats).

## 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.

```python
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
```

  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.

  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 |

  `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.

  `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](/docs/streaming).
