Skip to content

Error recourse

What a template does when a tag fails: onerror=, default=, where the strategy comes from, and the one failure that is never recoverable.

A prompt is usually assembled at the worst possible moment — while a request is waiting. So the interesting question is not whether a construct can fail, but what the renderer emits when one does.

Recourse is an execution-time mechanism. onerror= chooses what appears in a failing construct’s place instead of aborting the render. Lexer and parser errors sit outside it entirely: a construct that does not parse never reaches execution, so there is nothing to substitute. That boundary is the first thing to internalise — recourse cannot rescue a typo in the syntax, only a failure in the work.

The vocabulary since v0.24.0

onerror=EmitsNotes
thrownothing — the error propagates and the render failsThe default, and the fallback for any unrecognized value.
defaultthe construct’s default= attributeEmpty when no default= is present, i.e. remove with an escape hatch.
removeempty string
keeprawthe construct’s original source, verbatimEmpty when no source was captured — it fails closed rather than emit a wrong slice of the document.
logempty stringPlus one WARN line naming the tag and the error.
exons
{~exons.var name="tone" onerror="default" default="neutral" /~}

Where the strategy comes from

A document rarely states its own recourse for every tag. It does not have to — resolution walks three steps, and stops at the first that answers:

  1. The failing construct’s own onerror= (for a branch, the branch’s own — see below).
  2. The context default: the strategy the execution context reports, seeded from the engine’s WithErrorStrategy and inherited by a loop body’s child context.
  3. throw.

So the host decides the posture and the document overrides it locally. A renderer built for drafts can be lenient everywhere without a single document opting in; a validator can be strict over the same documents.

A misspelling does not fall through

Resolution stops as soon as the key is present, and an unrecognized value resolves to throw. So onerror="remov" hard-fails under a renderer configured never to hard-fail — and the misspelling is the only evidence. Engine.Validate reports it as an error on every shape that honours the attribute, which is why that check is worth running before you ship a template.

Which failures are governed since v0.24.0

ConstructFailure
any tagno resolver registered for the tag name
any tagthe resolver’s Validate refuses the attributes
any tagthe resolver’s Resolve returns an error
exons.if / exons.elseifthe branch condition fails to evaluate
exons.forthe in= path is not found in the context
exons.forthe value found is not iterable
exons.forthe host context cannot create a child context
exons.switchthe dispatch eval= expression fails to evaluate
exons.casethe case’s eval= expression fails to evaluate

Everything else is a hard failure. Parse-time refusals — a missing eval=, item= or in=, a non-numeric or negative limit=, an unclosed or mismatched construct — happen before execution. And an error raised inside a selected branch or loop body belongs to that node’s own site: the enclosing construct’s onerror= does not catch it.

The version marker on this section is load-bearing. Before v0.24.0 exons.if, exons.for and exons.switch read the keys they needed and let the rest of the attribute map fall out of scope, so onerror= and default= on a block construct were silently inert and every one of the failures above was an unconditional render error. Writing the attribute was not wrong; it simply did nothing.

Branch-level recourse since v0.24.0

An exons.elseif and an exons.case each carry their own onerror=, honoured at the branch’s own position rather than the opening tag’s:

exons
{~exons.if eval="input.verbose"~}
  long form
{~exons.elseif eval="input.terse" onerror="remove"~}
  short form
{~/exons.if~}

This matters because a condition is the most common thing to get wrong, and a broken condition in the third branch of a chain should not be reported against the first.

default= has two readings, and they never collide

On exons.var and exons.input the attribute name also carries a resolver-level meaning — a lookup miss, or an empty declared value. That path returns a value rather than an error, so it resolves before recourse is ever consulted. default= as recourse is read only by onerror="default".

Inheritance is never recoverable since v0.25.0

{~exons.extends~} carries no onerror=, and no strategy — not even remove — makes it survivable.

Inheritance is resolved before any tag executes, because the parent chain decides which document is executed at all. There is no node whose failure a strategy could absorb. So an unresolvable extends fails the render outright, under every configuration.

That is deliberate rather than unfinished. Degrading it would render the child’s bare block bodies — a different document than the author wrote, handed back as a success. A caller has no way to detect that, which makes it strictly worse than an error.

Telling the two apart in a registry

A consumer distinguishes an inheritance failure from every other execution failure by two facts: the error is a cuserr and its tag metadata is extends. That holds for all three ways resolution can fail — the declaration cannot be read, there is no engine to resolve through, or the chain cannot be walked (missing parent, circular, too deep). Do not branch on the error’s code: it is derived from the underlying message’s wording, not set by the library.