DEV Community

Mykola Melnyk
Mykola Melnyk

Posted on

One bad page must not lose the other forty

One bad page must not lose the other forty

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.

Throwing loses all forty pages; recording the failure in the exception field keeps thirty-nine

The contract

Every output schema carries an exception field:

interface Document {
  path: string
  text: string
  type: string
  bboxes: Box[]
  exception: string     // <-- here
}
Enter fullscreen mode Exit fullscreen mode

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) }]
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

Here is that actually happening, in the live builder. The pipeline is PdfToImagePaddleRecognizer, with the recognizer deliberately pointed at a column no stage writes:

The page rendered and the run completed; the failure is recorded in the text column, which is marked with a red dot and carries the message and stack

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.
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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.

Checking the payload first blames the recognizer; checking exception first names the stage that actually failed

Opting into throwing

new PaddleTextRecognizer({ propagateError: true })
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode
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'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.text on 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 checking exception moves 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.
  • onError is 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
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)