- Building Workflows
- Error Handling
Error Handling
Ironflow provides automatic retries for transient failures while allowing you to mark permanent failures that shouldn’t be retried.
Retry Behavior
By default, errors in step functions are retried according to your function’s retry configuration. These are the system defaults — applied when retry is not specified explicitly:
createFunction( { id: "my-function", triggers: [{ event: "my.event" }], retry: { maxAttempts: 3, // Total attempts, including the first (so 2 retries) initialDelayMs: 1000, // Initial delay (1 second) backoffFactor: 2.0, // Exponential backoff multiplier maxDelayMs: 300_000, // Maximum delay (5 minutes) }, }, async ({ event, step }) => { /* ... */ },);Retry delays follow exponential backoff:
- First retry (after attempt 1): 1 second
- Second retry (after attempt 2): 2 seconds
- Attempt 3 is the last —
maxAttemptscounts the first attempt, so 3 means 2 retries
(Derived from initialDelayMs=1000 × backoffFactor=2.0 shown above:
initialDelayMs × backoffFactor^(attempt-1), capped at maxDelayMs, then ±10%
jitter — so the delays land near 1s and 2s, not exactly on them.)
What counts as retryable
The engine retries a failed run only when the SDK reports retryable: true on the
failure. The SDKs classify an unrecognised error differently, and TypeScript
also differs between push and pull:
| SDK / mode | A plain Error / non-Ironflow error thrown inside step.run | …thrown outside any step |
|---|---|---|
| Go (push and pull) | Retryable — Run records ironflow.IsRetryable(err), which defaults unknown errors to true | Retryable |
| TypeScript, push | Not retryable — step.run wraps it in a StepError carrying isRetryable(err), which is false for anything that is not an IronflowError, bar the fetch exception below | Not retryable — serve() classifies with the same isRetryable |
| TypeScript, pull | Not retryable — same StepError wrapping | Retryable — createWorker treats any non-IronflowError as retryable |
So in TypeScript, throw new Error("temporary failure") inside a step is a terminal
failure: the run fails on that attempt and compensations run. Throw an
IronflowError with retryable: true (or one of the built-ins that carry it, such
as TimeoutError) when you want the retry policy to apply.
The one non-IronflowError that isRetryable still classifies retryable is a
TypeError whose message mentions fetch — what an unhandled fetch() network
failure throws. Catch and rethrow anything else you want retried.
Go: use NewError(msg, code, false), not WrapNonRetryable
ironflow.WrapNonRetryable(err) and ironflow.NewNonRetryableError(msg) do not
currently suppress retries. *NonRetryableError embeds *IronflowError, whose
promoted Unwrap() returns the cause, so errors.As walks straight past the
embedded struct and ironflow.IsRetryable falls through to its true default.
Both transports then report retryable: true and the engine retries; compensations
do not run.
Return an *IronflowError directly instead — ironflow.NewError("invalid input", "INVALID_INPUT", false) — which IsRetryable matches and both push and pull read
as non-retryable.
NonRetryableError
Use NonRetryableError to indicate permanent failures that shouldn’t be retried:
import { IronflowError, NonRetryableError } from "@ironflow/node";
await step.run("validate", async () => { if (!isValid(data)) { // Won't retry - permanent failure throw new NonRetryableError("Invalid input"); } // A plain Error is reported as non-retryable by the JS SDK — mark it // retryable explicitly to get the function's retry policy. throw new IronflowError("Temporary failure", { retryable: true });});import "errors"
ironflow.Run(ctx, "validate", func() (any, error) { if !isValid(data) { // NOT WrapNonRetryable — see the caution above. return nil, ironflow.NewError("invalid input", "INVALID_INPUT", false) } return nil, errors.New("temporary failure") // will retry})When to use NonRetryableError:
- Invalid input data that won’t change on retry
- Business logic failures (e.g., insufficient funds)
- Authentication/authorization errors
- Resource not found errors
When NOT to use NonRetryableError:
- Network timeouts
- External service temporary failures
- Rate limiting (should back off and retry)
Error Types Summary
| Error Type | Behavior | Use Case |
|---|---|---|
| Regular Error | Go: retried with backoff. TypeScript: not retried when thrown inside step.run — see What counts as retryable | Transient failures |
NonRetryableError (TS) | Not retried | Permanent failures — invalid input, business-rule violations. In Go use ironflow.NewError(msg, code, false); see the caution above. |
StepError | Thrown by Ironflow when a step’s underlying error propagates out of step.run; carries the wrapped error’s retryable flag | Catch around step calls to inspect step name + attempt count |
StepTimeoutError * | Retryable — subject to function retry policy | A step exceeded its configured timeout |
TimeoutError | Retryable — subject to function retry policy | Exported for classification, but the SDK never throws it today. A push execution exceeding its budget is cut off at the transport, not surfaced as this error. |
ValidationError / SchemaValidationError | Not retried | Event payload failed schema validation |
InvokeError / InvokeTimeoutError * | Not retried | step.invoke child run failed or timed out |
* Thrown by the SDK but not re-exported from @ironflow/node. Import these three from @ironflow/core if you need to instanceof them.
Use the isRetryable(err) helper (isRetryable in TypeScript, exported from @ironflow/node; ironflow.IsRetryable in Go) to test how a caught error will be classified. The two disagree on unrecognised errors — see the table above.
Webhook Signature Verification
All requests from Ironflow are signed for security. The SDK verifies signatures automatically when you provide a signing key:
import { serve } from "@ironflow/node";
export const POST = serve({ functions: [myFunction], signingKey: process.env.IRONFLOW_SIGNING_KEY, // Automatic verification});Manual signature verification is not yet available in the JS SDK. Use the signingKey option in serve() for automatic verification.
handler := ironflow.Serve(ironflow.ServeConfig{ Functions: []ironflow.Function{MyFunction}, SigningKey: os.Getenv("IRONFLOW_SIGNING_KEY"), // Automatic verification})For manual verification:
err := ironflow.VerifySignature( payload, req.Header.Get("X-Ironflow-Signature"), signingKey, ironflow.DefaultSignatureTolerance,)Signature Header
Ironflow includes the signature in the X-Ironflow-Signature header using HMAC-SHA256.
Development Mode
During local development, you can skip verification:
export const POST = serve({ functions: [myFunction], skipVerification: true, // Only for local development!});handler := ironflow.Serve(ironflow.ServeConfig{ Functions: []ironflow.Function{MyFunction}, SkipVerification: true, // Only for local development!})Never disable signature verification in production. This protects your endpoints from unauthorized requests.
Global Error Observation (Client onError)
For client-side operations (emitting events, managing runs, KV store, etc.), you can register a global onError handler to observe all errors without wrapping every call in try/catch:
import { createClient } from "@ironflow/node";
const client = createClient({ onError: async (error, context) => { // Send to your logging/metrics system await logger.error("Ironflow client error", { method: context.method, // e.g. "emit", "kv.bucket.get" endpoint: context.endpoint, // e.g. "/ironflow.v1.IronflowService/Trigger" statusCode: context.statusCode, // HTTP status or undefined for network errors error: error.message, }); },});Key behaviors:
- The handler fires before the error is re-thrown — it never suppresses errors
- Async handlers are fully awaited before the error propagates
- If the handler itself throws, its error is swallowed (logged to stderr)
- Propagates to sub-clients created via
client.kv()andclient.config()
This is useful for centralized logging, metrics collection, and alerting on client errors. See the @ironflow/node reference for the full API.
onError is for observing client errors. For controlling retry behavior inside functions, use NonRetryableError instead.
Handling Failed Runs
When a run fails after exhausting retries, you can:
- Hot Patch: Edit step outputs and resume from a specific step
- Investigate: Use the TUI debugger or dashboard to inspect the failure
- Fix and Retry: Fix the underlying issue and trigger a new event
See Debugging for more details on investigating and recovering from failures.
What’s Next?
- Debugging — Hot patching, TUI debugger, VS Code DAP
- API Reference — REST API endpoints