{{node_id.field}}. ModuleX wraps
most node types in a retry-and-events layer that retries transient failures with exponential
backoff, streams every attempt to the run’s event stream, and stops the run with a typed
error when retries are exhausted. This page covers the retry contract, every error event,
the credit impact of a failed run, and how to debug one.
For how a run streams in general, see Running workflows.
For documented gaps you should not rely on, see Known limitations.
How a node failure surfaces
Every retry-wrapped node emits the same three event types to the run’s stream, observed over SSE run streaming onGET /workflows/listen/{run_id}. These are
the raw wire shapes the executor publishes — note the field is node (the node id), not
node_id.
node_started
{type, node, name, timestamp}.node_retry (zero or more)
{type, node, name, attempt, max_attempts, error_type, error_message, next_retry_in, timestamp}.node_error (terminal for the node)
{type, node, name, error_type, error_message, reason, attempt, max_attempts, recoverable, timestamp}.error event,
and sets the run status to failed (see Run-level failure).
llm, tool, agent,
conditional, transformer, guardrails, knowledge — are wrapped, though the pure
in-memory ones (transformer, guardrails, conditional) rarely raise a retryable error
because they make no external calls.Retry configuration
Each node carries an optionalretry_config on its NodeDefinition. If you omit it, the
engine applies a built-in default identical to the values below. Set it per node in the detail
panel, or directly on the node JSON when authoring through the API.
1 means no retry and 3 means 2 retries. Range
1-10. Out-of-range values are rejected at validation time.0.1-60.0.1.0-5.0. A factor of 1.0 makes the delay constant.max_attempts.How the backoff delay is computed
The delay before the retry following attemptn is:
initial_interval=1.0, backoff_factor=2.0, max_attempts=3), a node
that keeps failing on a retryable error produces this sequence:
next_retry_in field on each node_retry event carries this computed delay in seconds.
How an error is matched to the retry list
An error is retried only if both are true: it is not the final attempt, and the error matchesretry_on_error_types. Matching is by exact exception class name, plus a substring
fallback on the error message for the three default types:
TimeoutErroralso matches any error whose message containstimeout.ConnectionErroralso matches any error whose message containsconnect.HTTPErroralso matches any error whose message containshttp.
retry_on_error_types are matched by exact class name only. A
billing denial raised mid-run is not in the default
list, so it fails fast without retrying — retrying an exhausted credit allowance would not
help.
A worked example: a tool node with custom retries
Thistool node calls an integration action and retries up to four times, starting at half a
second and tripling the delay each time, on connection and rate-limit failures.
backoff_factor: 3.0 and initial_interval: 0.5, the waits between attempts are
0.5s, 1.5s, then 4.5s. After the fourth failed attempt the node emits node_error and
the run fails.
Error events on the wire
These are the exact payloads published by the engine, in publish order. They carrynode
(the node id) and a flat structure — they are the live wire shapes, not the typed event
models. See SSE run streaming for framing (data: <json>\n\n,
no event: line; switch on the JSON type).
node, not node_id).max_attempts (from retry_config, or 3 by default).TimeoutError or ValueError.node_retry only: the computed backoff delay in seconds before the next attempt.node_error only: a stable machine token taken from the exception’s code attribute
when present, for example credit_exhausted for a mid-run budget stop. It is null for
errors that define no code, in which case match on error_type and error_message instead.node_error only: always false. A node_error is terminal for the node — it re-raises
and the run fails. There is no in-engine “continue past a failed node” path.Run-level failure
When a node exhausts its retries (or raises a non-retryable error), it re-raises. The executor catches the exception, publishes a single run-levelerror event, and sets the run
status to failed. The error event is flat with just a message:
error is a terminal event: the listen stream closes after it.
The durable run record reflects this too: the in-memory run status is failed, and the run’s
status is also failed, with error_message populated. (Note the status
naming split: a successful run streams a done event but its durable status is succeeded.)
You can read the durable record back via GET /workflow-runs/{run_pk}.
A run can also end without a node_error in two other ways:
- Timeout — a run that exceeds the workflow execution timeout publishes an
errorevent carryingerror_type: "timeout"and sets statusfailed. - Cancellation — a
POST /workflows/cancel/{run_id}request sets a flag the executor checks between nodes; the run publishes acancelledevent and sets statuscancelled. This is graceful — the current node finishes first.
Credit impact of a failed run
Understanding what a failed run costs is the most common error-handling question.POST /workflows/run call, before the
background task starts. That charge is not refunded if the workflow later fails. A run
that fails at the third node still costs its run credit. Resuming an interrupted run reuses the
same run_id reservation, so a resume does not add a second run credit.- Retries do not add run credits. The single run credit covers the whole logical run, including every retry of every node.
- LLM and agent calls meter tokens per call. Each attempt of an LLM node or agent node that actually reaches the model records token usage in credits. A node that fails after its first model call and then retries will meter the tokens of each attempt that reached the model. Token metering failures never fail the run — usage logging is best-effort.
- Managed knowledge retrieval is gated. A knowledge node
using managed
modulexdbreserves a retrieval credit before embedding and records the cost on success; on error it releases the reservation. The managed-retrieval gate is best-effort inside a run — a billing hiccup releases the reservation rather than crashing the workflow. - BYOK is not credited. Bring-your-own-key model and knowledge usage is billed directly by your provider, not in ModuleX credits.
Billing denials mid-run
The billing gate is live on the run surface. If your org’s credit allowance is exhausted (and no wallet overage is available), the admission gate onPOST /workflows/run rejects the run before it starts with a flat DenialEnvelope
(402 / 403 / 429) — no run record is created and no credit is charged.
If a budget limit is hit mid-run (for example by an LLM node’s token usage), the failing
node surfaces a node_error whose reason is the stable denial token (such as
credit_exhausted), so a client can branch on it deterministically rather than scanning the
free-text message. See Usage gating & limits and the full envelope
reference on the Errors page.
Debugging a failed node
Find the last node_error before the run error
GET /workflows/listen/{run_id} and read the events in order. The last
node_error frame names the node (node), the exception (error_type), the message
(error_message), and — for coded failures — the reason token. The subsequent run-level
error frame only restates the message.Inspect the checkpoint state
GET /workflows/state/{thread_id} to see the state as
it stood when the run stopped. Every node writes its result to state under its own id, so
you can confirm which upstream nodes produced values and which reference a failing node
expected. An unresolved {{node_id.field}} reference is left intact as a literal string in
state rather than raising — a tell-tale sign of a typo in a reference.Reproduce with a tighter retry budget
retry_config.max_attempts to 1 temporarily to fail fast and surface the
underlying error immediately without waiting through backoff delays. Restore the retry
budget once you have identified the cause.Check the durable run record
GET /workflow-runs/{run_pk} returns the persisted run with status: "failed",
error_message, started_at/completed_at, and the input_snapshot — useful when you
are debugging after the SSE stream has closed.Common causes
Model timeout or rate limit (llm / agent nodes)
Model timeout or rate limit (llm / agent nodes)
TimeoutError, HTTPError), so they retry automatically with backoff. If the
provider stays unavailable across all attempts, the node emits node_error. Raise
max_attempts or initial_interval for flaky providers, or switch the
LLM provider.Unreachable endpoint (tool nodes / http_request function)
Unreachable endpoint (tool nodes / http_request function)
http_request
function returns a result with success: false instead
of raising — see the next item.Soft failures from function nodes (no retry, no run failure)
Soft failures from function nodes (no retry, no run failure)
{error, success: false, ...} under the node id, so the run continues
and the next node can branch on it. Because nothing is raised, the retry layer is never
triggered. If you need a failing HTTP call to retry, use a tool node, or branch on the
function result with a conditional node.Unresolved node references
Unresolved node references
{{node_id.field}} that points at a missing node, a wrong field, or an
out-of-range array index resolves to None (for nested paths) or is left intact as the
literal string (for whole-template strings) rather than raising. The symptom is wrong or
empty input downstream, not an error event. Verify references on
Variables & references and inspect state at
the checkpoint.Guardrails blocking content
Guardrails blocking content
on_failure: block stops the
flow when validation fails. This is expected behavior, not a node exception — the node
returns a result with valid: false and blocked. Use on_failure: route with a
failure_route to send blocked content down a recovery branch instead of stopping.Credit allowance exhausted
Credit allowance exhausted
DenialEnvelope, or a
mid-run node emits node_error with reason: "credit_exhausted". Top up your
wallet or upgrade your plan; see
Usage gating & limits.Malformed workflow schema (run never starts)
Malformed workflow schema (run never starts)
workflow_schema does not surface as a node error — it fails the create/update
or run call itself. Because the request body is an untyped dict, a schema validation failure
surfaces as a 500 (not the usual 422), with a descriptive detail. Validate your graph
before running; the __start__ and __end__ nodes are virtual and must not appear in the
nodes array.Related
Running workflows
Known limitations
Variables & references
{{node_id.field}} reference system and how unresolved references behave.