Error handling

Failures are part of the graph, not an afterthought. Flowdrome gives you layered tools, from node-local to run-global.

The result ledger — every outcome rides the wire

Every node’s outcome — succeeded, failed, skipped or canceled, with the error code and message when there is one — is appended to the envelope’s meta.result ledger as the run progresses. A failed node still completes an output envelope, so the reason travels in-band wherever the flow goes next. The Result node reads the ledger and routes down one of four ports (succeeded / failed / skipped / canceled), with a Decide by setting: the literal last entry, the last real step (frames and triggers skipped), or any failed.

Result rules — On error / On cancelled / On skipped

Every node’s properties carry three result rules, each Stop (default) · Continue (with the real status) · Continue (with success status):

  • On error — Continue lets a failing node keep the run alive: its failure envelope (reason on the ledger) flows out every wired output port, and downstream nodes — a Result node above all — can see what happened and route on it. The node still shows failed in the run detail; the run itself stays green, the same rule a Try catch follows (“a caught failure is not a run failure”). Continue as success stamps the ledger entry succeeded while keeping the error recorded. The rule wins over an enclosing Try — it sits on the node itself.
  • On skipped — Continue makes a dead branch observable: instead of skipping silently, the node emits a null-payload envelope whose ledger entry says skipped, so a Result node after it can react to “this path never ran”.
  • On cancelled only affects the ledger stamp — pressing Stop always still ends the run.

A node with any rule set shows a small shield badge on its card, so changed behavior is never invisible.

Retry without new machinery: wire the failing node (On error: Continue) into a Result node, its failed port back into a Junction’s Return, and the junction’s Limit port to your give-up path — a bounded retry loop from ordinary parts.

Try frames — catch around a section

Try is a frame: drag nodes into it and it becomes an error boundary. Members execute normally; when one throws, it is retried per the frame’s policy, and if it still fails the exception routes out of the frame’s error port — with the error details as data — instead of failing the run. Success flows out of the success port as usual.

Use it like you’d use try/catch: around the flaky HTTP call, the parse that sometimes meets garbage, the third-party API with moods.

Retries + delay. The frame’s policy is two numbers: Retries (how many times to re-run a failed member before giving up, default 2) and Retry delay (how long to wait between attempts, in milliseconds, default 0 — instant). A member that fails is re-run up to Retries times, pausing Retry delay between each attempt; only then does it fall through to the error port. The retries are visible after a run: the failed step shows ↻ ran N× (with the wait) in the run history and its detail, so you can see it actually retried. Inside the retried node, the $attempt expression reads the current try (1 on the first run, then 2, 3, …) — so a node can back off, vary a jitter, or branch on how many times it’s been tried.

finally — cleanup that always runs, last. A Try has a third output, finally, that follows the try/catch/finally contract: whatever happens, the finally branch runs after the success/error branch has finished — and it runs even if that branch itself fails (e.g. an error leg that ends in Stop and Error). Wire cleanup there (close a handle, release a lock, post a “done” notice). It only runs when something is wired to it. It carries the outcome envelope but is not the run’s result — it’s cleanup, so it never overwrites the output the success/error branch produced.

The error envelope carries the failure as data on the error port: exception.message, exception.code, exception.node, exception.count (how many members failed), exception.errors[] (each with its own attempts), and the retry accounting — exception.attempts (how many times the first failed node ran), exception.maxRetries and exception.maxAttempts (the frame’s budget). So a recovery branch can branch on how hard it tried.

The recovery lane — Error Trigger

The Error Trigger starts a recovery phase for the workflow: when a run fails outside any Try frame, the engine executes the error lane rooted at the Error Trigger, handing it the failure context (failed node, error, the run’s data). The main lane and the recovery lane live in the same document — one workflow, its happy path and its incident response.

Typical recovery lanes: notify Slack/Telegram with the error and run id, write the failure to a queue or database, call a fallback API.

Failure-alert presets — one import away

You don’t have to build the pager lane by hand: the template gallery ships ready-made recovery lanes — Slack alert on failure, email on failure, and the console pager — import one, point the send node at your credential, and every unhandled failure pages you. They compose with autoreplay: retry first, page when the retries are exhausted.

Stop and Error — fail on purpose

Stop and Error fails the run immediately with your message and data. Combine it with If/Check to turn business rule violations into first-class failures that Try frames and the recovery lane see — instead of letting bad data limp downstream.

Per-item error policy

When a node runs once per item, its error policy decides what a poison item does: fail the run, or drop the item and count it (surfaced in the node’s run meta). One bad row out of 100,000 doesn’t have to cost you the other 99,999.

After the fact: retry from a node

Every failed run records its envelopes, so the fix-and-retry loop doesn’t start from the trigger: retry from the failing node re-executes only the downstream subgraph, seeded with the recorded inputs. Fix the config, retry from the node, watch it go green.

On deployed hosts

All of the above deploys as-is: Try frames, the recovery lane, stop-and-error and per-item policies behave identically in the editor and on a host — it is the same engine executing the same document. An approval gate’s timeout policy (approve / reject / error on expiry) also feeds the same machinery — an expired gate can deliberately fail the run into its recovery lane.

Autoreplay — deployed runs that retry themselves

A deployed workflow can carry a replay policy in its document:

"meta": { "autoreplay": { "maxAttempts": 2, "backoffSeconds": 30 } }

When a served run fails — a webhook call, a schedule tick, a bot message — the host re-runs it with the same input, waiting backoffSeconds × 2^(n−1) before each attempt (so 30s, then 60s, …), up to maxAttempts (1–10). Each attempt is an ordinary run in the host’s ledger, tagged autoreplay#n, so the Runs view shows exactly what happened and when. Replays stop early on the first success, and never fire for a paused or stopped workflow.

Autoreplay is deliberately fire-and-forget: nobody hangs through the backoff. On the default async webhook the caller already holds its 202 { runId, status, statusUrl } receipt, and the replays heal the state behind it (“it retried itself at 3am”). Note that the status URL follows the run it names — the first attempt — so it reports that attempt’s failure even after a later replay succeeds; each replay is its own run in the ledger, tagged autoreplay#n. A workflow with an HTTP Response node answers synchronously, so its caller gets the failed reply immediately and the replays run behind it. For request/response APIs where the caller needs the retry, put a Try frame with a retry policy around the flaky section instead — the two compose: Try retries inside a run, autoreplay retries the run.