Skip to content

Failure Codes

Nabeel Wasif edited this page Sep 29, 2026 · 1 revision

Failure Codes

Every failed document carries one of sixteen stable codes, stored in Document.errorCode, with a fixed sentence written for a person in Document.error.

Canonical source: docs/failure-codes.md · code: lib/workflows/failure.ts.


Two rules

  1. The message a user sees always comes from the table below, never from the thrown error. The raw text is read only to pick a code and is then discarded. This matters beyond tidiness: a parser or driver error is not guaranteed to be free of document content, and invariant 6 says nothing logs or stores document content. (Before #62, the screen showed things like FatalError: Unsupported file type, or a Prisma error.)
  2. retryable answers one question: could running the same pipeline again, right now, produce a different result? An unsupported file type does not become supported on the second attempt, and a button that cannot work is worse than no button.

The module imports nothing, so the browser can classify a stored code without pulling the server in behind it.


The 16 codes

Code Meaning Retryable What to do
unsupported-type Not a format Anonify can read. The supported list is generated from the format register. No See Supported Formats.
extension-mismatch The contents do not match the extension, e.g. a .pdf that is really a zip. No Check the file is what its name says. Magic bytes decide, not the name.
empty-file Zero bytes. No Upload the real file.
too-large Larger than MAX_UPLOAD_BYTES (50 MiB). No Split it, or raise the ceiling in lib/config.ts on your own instance.
corrupt-source The stored copy no longer matches the checksum taken at upload, so it was not read. No Upload again. If it recurs, your storage is returning different bytes than it was given.
missing-upload The upload vanished before ingest, or the row is gone. No Upload again.
too-complex More messages, parts, nesting or content than this instance will read or expand. Refused whole, because a partly processed file would look complete and would not be. No An administrator can raise the EML parser, attachment-expansion or mailbox limits (Configuration).
empty-container A mailbox with bytes in it but no readable messages. Distinct from empty-file: telling somebody their 30 MiB archive is empty would send them looking for the wrong problem. No Check the export; it may not be mbox.
quota The document needs more of today's allowance than is left. The allowance resets at midnight UTC. Not retryable even so: a retry pressed before the reset spends a rate-limit token to fail the same way. No Wait for the reset. Self-hosted installs have no quotas; if you see this on your own machine, check ANONIFY_PROFILE.
rule-too-broad A batch or global rule, carried into the document at the end of processing, matched too much (over 10,000 matches or 5 s of matching), so none of it was applied here. The document failed after extraction, so it still opens for review. Yes Narrow the rule or switch it off in the Rules panel, then retry.
internal-state Processing stopped partway and cannot resume. Also the fallback for an unrecognised FatalError the pipeline threw itself. No Upload again; report it if it recurs.
configuration The instance is not fully configured, e.g. OCR_PROVIDER=mistral with no MISTRAL_API_KEY. Needs an administrator, not a retry. No Fix the environment and restart. A misspelled Mistral key (e.g. MISTRAL_AI_KEY) is named in the log.
storage The file store could not be reached. Usually temporary. Yes Retry.
database The database could not be reached. Usually temporary. Yes Retry.
timeout The work took longer than allowed. Usually temporary. Yes Retry; on Vercel Hobby, see the step limits in Deployment.
unknown Anything the matchers do not recognise. The original file was not modified. Yes Retry; report it if it recurs.

A document that failed before the code column existed has no code at all. Those stay retryable, because that is the old behaviour and a retry is cheap.


How a thrown error becomes a code

MATCHERS: first match wins

describeFailure does not read the error's class or stack. It reads the message text and walks an ordered list of regexes:

Code Matches
extension-mismatch contents do not match its extension
unsupported-type unsupported file type · no extractor registered
empty-file file is empty
too-large file is too large
corrupt-source checksum mismatch
too-complex exceeds the … (expansion) limit of
empty-container no messages found in this mailbox
missing-upload no upload to ingest · document no longer exists
quota daily demo limit reached
internal-state has not been ingested/normalized
configuration environment variable · must decode to · is not configured · SOME_VAR is not set · OCR_PROVIDER
database database_url · econnrefused · prisma · connection pool/terminated
storage blob · s3 · fetch failed · enoent · no such file
timeout timeout · etimedout · timed out · aborted

Ordering is load-bearing. The pipeline's own specific messages come before the broad infrastructure patterns, so "no such file" is read as storage only once it is known not to be one of the specific cases. Anything that matches nothing is unknown. rule-too-broad is set directly by the rule-carry step.

too-complex is deliberately worded to cover the MIME parser's limits, attachment expansion's and the mailbox's. To the person holding the file, they are one refusal with one remedy.

unwrap: fatal or retried?

The error that crosses the durable boundary is not the instance that was thrown. Its class name is folded into the message, and the wrappers nest:

FatalError: Step "…//extractAndNormalize" failed after 3 retries: <cause>

unwrap peels them in a bounded loop (depth 4) and returns:

  • message: the cause underneath, which is what gets classified;
  • fatal: true only when a FatalError prefix was seen and no "failed after N retries" wrapper was;
  • retried: true when the step exhausted its retries.

A step that exhausted its retries is re-thrown as a FatalError too, because from the runtime's side there is nothing left to try. But the cause underneath was transient enough to be retried, and reporting it as a verdict would remove the retry button in exactly the case that wants it. That is why fatal is not simply "the message said FatalError".

The extra rule

An unrecognised FatalError is still fatal. The pipeline throws one only where it has decided retrying is pointless, so it maps to internal-state, not unknown.

Helpers

Function Does
describeFailure(error) unwrap → classify → { code, message, retryable }; what the fail step calls
failureForCode(code) the stored code → its message and retryability; unknown → unknown
isRetryable(code) whether to offer Retry; no code → true

Where codes appear

  • Document.error / Document.errorCode, and the document.failed stream event's payload: { code, retryable }.
  • The workspace: failed before extraction shows the failure screen; failed after extraction opens the editor with a FailureNotice banner.
  • The documents list and batch view: a Retry button, only for retryable codes.
  • POST /api/documents/:id/retry: refuses a non-retryable code with 409 and retryable: false.

Export failures are separate: a verification refusal is 500 "did not pass verification and was not saved", and batch-export skips have their own named reasons (Batches and Mailboxes).


See also

Clone this wiki locally