Written 2026-09-04, about @stabrise/scaledp@0.1.1. Assumes TypeScript and async/await.
TL;DR: In a document pipeline, an exception is almost never worth the batch it destroys. So stages here never throw by default: every output schema carries an exception: string, a failed stage writes a well-formed empty value with the message in that field, and the pipeline completes. propagateError: true opts back into throwing. One sentence of requirement generates the whole design — and it has a real cost, which is the second half of the post.
The requirement
You have a 40-page PDF. Page 12 has a degenerate detection — a box of zero width, a crop that lands entirely outside the page, a region the recognizer chokes on. The natural implementation throws, the await rejects, and you have zero pages of output from a run that successfully processed thirty-nine.
That is the wrong outcome, and it is wrong in a way that gets worse with scale. The larger the batch, the higher the chance that something in it is malformed, and the more expensive it is to have lost the rest.
State it as a requirement and the design follows: one bad page must not lose the other forty.
The contract
Every output schema carries an exception field:
interface Document {
path: string
text: string
type: string
bboxes: Box[]
exception: string // <-- here
}
Same for ScaleDpImage, DetectorOutput and NerOutput. It is not an optional field and it is not Error | null — it is a string, empty when nothing went wrong, because it crosses postMessage to a worker and has to survive structured cloning.
The runner does the catching, once, for every stage:
try {
const expanded = await this.expand(input, row, ctx)
next = expanded ?? [{ ...row, [outputCol]: await this.apply(input, row, ctx) }]
} catch (error) {
if (propagateError) throw error
next = [{ ...row, [outputCol]: this.onError(formatException(this.name, error), row) }]
}
Which means a stage author writes ordinary code that throws when something is wrong. They do not write try/catch. They write one extra method — onError — that returns an empty instance of their output schema.
Reading it
const rows = await pipeline.transform(file) // never rejects for a page failure
for (const row of rows) {
if (row.text.exception) {
console.warn(`page ${row.page}:`, row.text.exception)
continue
}
index(row.text.text)
}
Here is that actually happening, in the live builder. The pipeline is PdfToImage → PaddleRecognizer, with the recognizer deliberately pointed at a column no stage writes:
Three things in that screenshot are the whole contract. The run completed — there is a result to look at. The page still rendered, because PdfToImage succeeded and its failure-free output is untouched by what happened downstream. And the failure is in the column, tagged on the tab with a red dot, carrying the message the stage wrote:
PaddleRecognizer: OcrError: No boxes in column "nosuchcolumn".
This stage reads a detector's output; run a text detector before it.
The stage name is prepended deliberately. With two recognizers in a pipeline, the column tells you which output failed and the message tells you which stage wrote it — and those are different questions once a pipeline has ten stages in it.
Check exception before you check the payload
This is a one-line ordering rule that appears verbatim in seven files, and it is the difference between an error message that names the cause and one that buries it.
A failed upstream stage does not return undefined — it returns a well-formed but empty value. So this looks reasonable and is wrong:
// WRONG: reports the symptom, hides the cause
if (image.data.length === 0) {
throw new OcrError('no decoded image bytes', this.name)
}
PdfToImage failed three stages ago because the pdf.js worker was not configured. What the user sees is "no decoded image bytes" from the recognizer, and they spend an afternoon debugging the wrong stage.
// RIGHT: the real cause propagates
if (image.exception) return this.onError(image.exception, row)
if (image.data.length === 0) {
throw new OcrError('no decoded image bytes', this.name)
}
The comment explaining this is copy-pasted into every stage that consumes another stage's output rather than abstracted into a helper. Seven small correct things beat one clever thing that a future stage forgets to call.
Opting into throwing
new PaddleTextRecognizer({ propagateError: true })
Per stage, not global, and useful in two opposite situations.
While developing. A mis-wired column name produces an empty result and no error, which is a genuinely annoying thing to debug. With propagateError it is a stack trace.
In a batch job where silence is worse than a crash. If you are OCRing a legal archive and a page fails, an empty string that flows into your search index is worse than a failed job, because nobody will ever notice it.
The error types all extend ScaleDpError and carry the stage they came from, so instanceof works when you have opted in:
import { OcrError, DetectionError, ConfigError } from '@stabrise/scaledp'
try {
await pipeline.transform(file)
} catch (error) {
if (error instanceof ConfigError) showSetupHelp(error)
else if (error instanceof OcrError) reportPage(error.stage)
else throw error
}
| Error | Raised by |
|---|---|
ImageError |
decoding, encoding, cropping, empty data |
OcrError |
recognition, missing input columns on OCR stages |
DetectionError |
detectors |
NerError |
GLiNER |
ConfigError |
a missing peer dependency, a bad configure() value |
What is deliberately not covered
Two failures happen outside the contract, on purpose.
Input normalisation. transform('https://example.com/doc.pdf') fetches, and a non-ok response throws before any stage runs. There is no row yet to write an exception onto.
Constructor validation. An unknown OCR preset or an out-of-range threshold throws RangeError when the stage is constructed:
new PaddleTextRecognizer({ preset: 'v6-smal' })
// RangeError: preset: unknown preset 'v6-smal'
That is deliberate. A parameter mistake is a programming error, not a data error. Reporting it at construction names the field; reporting it at row three names nothing useful, and reporting it as an empty column names nothing at all.
The distinction generalises: the non-throwing contract is for data failures, which are expected and per-row. Programming failures should still be loud.
Failures are timed
A small detail with a real diagnostic payoff:
// Failures are timed too: a stage that spent nine seconds before
// throwing still cost nine seconds.
const elapsed = performance.now() - started
Timing is recorded outside the try/catch. A stage that downloads a 580 MB model, times out and fails still shows 40 seconds in row_time. If failures were untimed, the pipeline that took a minute would report ten milliseconds of work, and the profile would be a lie exactly when you most need it.
Trade-offs
This is the part that matters, because the contract is not free.
-
Nothing forces you to check.
row.text.texton a failed row is'', and''flows happily into a search index, a diff, or a summary. The contract converts a loud failure into a quiet one, and the discipline of checkingexceptionmoves onto the caller. That is a real cost and I would not pretend otherwise. -
An empty result and a failed result look identical downstream. A detector that found nothing and a detector that crashed both hand on zero boxes. That is intentional — it is why downstream stages need no error branch — but it means "no entities on this page" is ambiguous unless you look at
exception. -
You lose the stack at the throw site unless you set
propagateError. The message includes the stack as a string, which is not the same as a debugger paused at the frame. -
onErroris one more method per stage, and a stage that returns the wrong shape from it converts one failure into a worse one — which is its own post.
The mitigation for the first two is not cleverness, it is a habit: treat exception as part of the result you destructure, not as an edge case. The display helpers cooperate here — anything with a non-empty exception renders as a red error block rather than an empty panel, so a failure is visible rather than merely absent.
Try it
npm install @stabrise/scaledp pdfjs-dist onnxruntime-web ppu-paddle-ocr
The screenshot above is reproducible in one click: open the mis-wired pipeline in the builder, then point the recognizer's inputCols at a column nothing writes and run it. The page still renders, the run still finishes, and the failure shows up where the contract says it will.
Further reading
- The error contract
-
The stage lifecycle — where
onErrorsits - Schemas
- Timings
- Repo: StabRise/scaledp-ts




Top comments (0)