Skip to main content

Structured Errors in Run.Sequence

Examples below assume this module is imported with an imports: entry under alias Run. Kind references (Run.Sequence) follow that alias — if you import the module under a different name, substitute your alias accordingly.

Run.Sequence composes invocables and surfaces their structured failures (InvokeError) through try / catch. This page covers the three pieces that make that work end-to-end:

  • try: / catch: step binding the error context
  • the throw: step — throws an InvokeError at a specific point in the sequence
  • throws: { inherit: true } — how a sequence declares its effective throw union

try / catch / finally

Inside a catch: block, the CEL context gains:

  • error.code — the thrown InvokeError.code, or INTERNAL_ERROR for plain Error throws. Always a non-empty string, so throw: { code: "${{ error.code }}" } can safely rethrow it.
  • error.message — the thrown message
  • error.data — the InvokeError.data, or undefined for plain errors
  • error.step — the name of the step that threw

These four fields are statically type-checked: a typo like ${{ error.cdoe }} inside a catch (or finally) is reported as CEL_UNKNOWN_FIELD, at any nesting depth.

Inside a finally block, error is nullable — it is null when the try (and any catch) succeeded, and the caught failure only when a failure propagates. Accessing a field without a null-guard is a static error (CEL_NULLABLE_ACCESS); guard first: ${{ error == null ? 'OK' : error.code }} or ${{ error != null && error.code == 'X' }}.

kind: Run.Sequence
metadata: { name: PublishWithAudit }
steps:
- name: publish
try:
- name: auth
invoke: { kind: Auth.VerifyToken, name: VerifyPublishToken }
inputs:
authorization: "${{ request.headers.authorization }}"
- name: upload
invoke: { kind: S3.Put, bucketRef: { name: ModuleStore } }
inputs: { key: "${{ inputs.fileKey }}", body: "${{ inputs.body }}" }
catch:
# Log the failure before re-raising. Plain errors rethrow too.
- name: audit
invoke: { kind: Sql.Exec, connection: { kind: Sql.Connection, name: Db } }
inputs:
sql: "INSERT INTO publish_failures (code, message, data, step) VALUES ($1, $2, $3, $4)"
bindings:
- "${{ error.code }}"
- "${{ error.message }}"
- "${{ error.data }}"
- "${{ error.step }}"
- name: rethrow
throw:
code: "${{ error.code }}"
message: "${{ error.message }}"
data: "${{ error.data }}"

A catch block that falls through without re-throwing absorbs the error. A catch block that ends in a throw: step re-raises it — the step's code determines which codes propagate out of the sequence.

throw: step

A throw: step takes { code, message?, data? } and throws the matching InvokeError from inside the sequence. The analyzer statically narrows the step's contribution to the sequence's throw union using the same rules as passthrough call sites:

FormStatically resolves to
code: "UNAUTHORIZED"{ UNAUTHORIZED }
code: "${{ 'FOO' }}"{ FOO }
code: "${{ error.code }}" inside a catchthe enclosing try's propagated union

The enclosing try's propagated union includes INTERNAL_ERROR whenever the try block contains an invoke: step, since any invoked resource can throw a plain Error that the catch surfaces as error.code === 'INTERNAL_ERROR'. A surrounding catches: list must therefore cover INTERNAL_ERROR (or include a catch-all) for such a rethrow.

Any other CEL expression is an analyzer error — the throw union would be unbounded and any surrounding catches: list would have no way to cover it.

throws: { inherit: true }

A definition with inherit: true declares that its effective throw union is the union of everything it calls. Run.Sequence uses this: its declared throws is empty at the definition level; the actual union is computed per-manifest from the steps it runs.

The analyzer's dataflow pass walks every field on the definition annotated with x-telo-step-context (so future composers like Run.Parallel opt in the same way — no analyzer changes needed). For each step:

  1. If the step has a throw: block, resolve the thrown code at this call site.
  2. Otherwise, resolve the step's invoke.kind via the definition registry.
  3. If the invoked target is another inherit: true composer, recurse (memoised by manifest name, cycle-safe).
  4. If the invoked target is passthrough: true, resolve at this specific call site.
  5. Otherwise, use the target's declared throws.codes.

Inside a try / catch, the catch block's throws replace the try block's (a catch that runs to completion has absorbed the try error; a catch that ends in a throw: re-raises whatever it decides).

inherit: true is only legal on definitions whose schema declares at least one x-telo-step-context array — the analyzer rejects it otherwise.

Rules the analyzer enforces

  • Undeclared code in catches: when: — rejected against the handler's resolved union.
  • Uncovered declared code — every code in the resolved union must reach a catches: entry (explicit when: or catch-all).
  • Unbounded union requires catch-all — when inherit/passthrough resolution can't enumerate all codes, the catches: list must include a no-when: entry.
  • Typed error.data.<field> — validated against the per-code data: schema from the resolved union. Disjunctive when: clauses (error.code == 'A' || error.code == 'B') use the intersection of data schemas so only fields present on every covered code narrow through.
  • ${{ error.code }} outside catch: — using it in a throw: step outside an enclosing catch block is rejected (no enclosing try to source the union from).