Skip to content

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:

  1. First retry (after attempt 1): 1 second
  2. Second retry (after attempt 2): 2 seconds
  3. Attempt 3 is the last — maxAttempts counts 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 / modeA 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 trueRetryable
TypeScript, pushNot 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 belowNot retryable — serve() classifies with the same isRetryable
TypeScript, pullNot retryable — same StepError wrappingRetryable — 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 });
});

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 TypeBehaviorUse Case
Regular ErrorGo: retried with backoff. TypeScript: not retried when thrown inside step.run — see What counts as retryableTransient failures
NonRetryableError (TS)Not retriedPermanent failures — invalid input, business-rule violations. In Go use ironflow.NewError(msg, code, false); see the caution above.
StepErrorThrown by Ironflow when a step’s underlying error propagates out of step.run; carries the wrapped error’s retryable flagCatch around step calls to inspect step name + attempt count
StepTimeoutError *Retryable — subject to function retry policyA step exceeded its configured timeout
TimeoutErrorRetryable — subject to function retry policyExported 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 / SchemaValidationErrorNot retriedEvent payload failed schema validation
InvokeError / InvokeTimeoutError *Not retriedstep.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.

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!
});

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() and client.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:

  1. Hot Patch: Edit step outputs and resume from a specific step
  2. Investigate: Use the TUI debugger or dashboard to inspect the failure
  3. 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?