Structured Errors in Run.Sequence
Examples below assume this module is imported with an
imports:entry under aliasRun. 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 theerrorcontext- the
throw:step — throws anInvokeErrorat 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 thrownInvokeError.code, orINTERNAL_ERRORfor plainErrorthrows. Always a non-empty string, sothrow: { code: "${{ error.code }}" }can safely rethrow it.error.message— the thrown messageerror.data— theInvokeError.data, orundefinedfor plain errorserror.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:
| Form | Statically resolves to |
|---|---|
code: "UNAUTHORIZED" | { UNAUTHORIZED } |
code: "${{ 'FOO' }}" | { FOO } |
code: "${{ error.code }}" inside a catch | the 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:
- If the step has a
throw:block, resolve the thrown code at this call site. - Otherwise, resolve the step's
invoke.kindvia the definition registry. - If the invoked target is another
inherit: truecomposer, recurse (memoised by manifest name, cycle-safe). - If the invoked target is
passthrough: true, resolve at this specific call site. - 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 (explicitwhen: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-codedata:schema from the resolved union. Disjunctivewhen: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 }}outsidecatch:— using it in athrow:step outside an enclosingcatchblock is rejected (no enclosing try to source the union from).