Skip to main content
Steps support durable retries and timeouts. Retry state persists across workflow restarts — if a step is mid-retry when the server goes down, it resumes from the correct attempt when the workflow recovers.

RetryConfig

Pass a retry object to Workflow.step():
yield* Workflow.step({
  name: "External API call",
  execute: callExternalAPI(),
  retry: { maxAttempts: 3, delay: "5 seconds" },
});
maxAttempts
number
required
Number of retry attempts, not counting the initial execution. maxAttempts: 3 means up to 4 total executions.
delay
string | number | BackoffStrategy | (attempt: number) => number
Delay between retry attempts. Accepts a human-readable string, milliseconds, a Backoff strategy, or a custom function. When omitted, defaults to exponential backoff starting at 1 second (capped at 60 seconds).
jitter
boolean
Add random variance to delays to avoid synchronized retries across many clients (the “thundering herd” problem). Defaults to true.
maxDuration
string | number
Total time budget across all attempts. If elapsed time exceeds this value, the step fails with RetryExhaustedError even if maxAttempts has not been reached.
isRetryable
(error: E | WorkflowTimeoutError) => boolean
Predicate called on each failure. Return false to stop retrying for specific error types. All errors are retryable by default.

Backoff strategies

Import the Backoff namespace for built-in strategies:
import { Backoff } from "@durable-effect/workflow";

Exponential

Delay grows as base × factor^attempt. This is the most common strategy for external APIs.
yield* Workflow.step({
  name: "API call",
  execute: callAPI(),
  retry: {
    maxAttempts: 5,
    delay: Backoff.exponential({
      base: "1 second",   // Starting delay
      factor: 2,          // Multiplier per attempt (default: 2)
      max: "30 seconds",  // Cap on any single delay
    }),
  },
});
// Delay sequence: 1s → 2s → 4s → 8s → 16s (capped at 30s)

Linear

Delay grows as initial + (attempt × increment).
yield* Workflow.step({
  name: "API call",
  execute: callAPI(),
  retry: {
    maxAttempts: 5,
    delay: Backoff.linear({
      initial: "1 second",
      increment: "2 seconds",
      max: "10 seconds",
    }),
  },
});
// Delay sequence: 1s → 3s → 5s → 7s → 9s (capped at 10s)

Constant

Fixed delay between every retry.
yield* Workflow.step({
  name: "API call",
  execute: callAPI(),
  retry: {
    maxAttempts: 3,
    delay: Backoff.constant("5 seconds"),
  },
});

Custom function

For fully custom delay logic, pass a function that receives the current attempt number (1-indexed) and returns milliseconds:
yield* Workflow.step({
  name: "Custom backoff",
  execute: operation(),
  retry: {
    maxAttempts: 5,
    delay: (attempt) => 1000 * Math.pow(2, attempt),
  },
});

Presets

Use built-in presets for common scenarios:
PresetBehaviorUse case
Backoff.presets.standard()1s → 2s → 4s → 8s → 16s (max 30s)General external APIs
Backoff.presets.aggressive()100ms → 200ms → 400ms → 800ms (max 5s)Internal services, low latency
Backoff.presets.patient()5s → 10s → 20s → 40s (max 2min)Rate-limited APIs
Backoff.presets.simple()1s constantPolling
yield* Workflow.step({
  name: "Call rate-limited API",
  execute: callAPI(),
  retry: {
    maxAttempts: 10,
    delay: Backoff.presets.patient(),
  },
});

Jitter

Jitter adds a random offset to each computed delay. When many workflow instances fail at the same time and all retry with identical delays, they can overwhelm a downstream service simultaneously. Jitter staggers the retries. To disable jitter when precise timing matters:
yield* Workflow.step({
  name: "Precise timing",
  execute: operation(),
  retry: {
    maxAttempts: 3,
    delay: "5 seconds",
    jitter: false,
  },
});

Max duration

maxDuration sets a wall-clock budget for the entire retry sequence. Combine it with a high maxAttempts when you want best-effort retries within a fixed window:
yield* Workflow.step({
  name: "Time-bounded operation",
  execute: operation(),
  retry: {
    maxAttempts: 100,
    delay: Backoff.exponential({ base: "1 second" }),
    maxDuration: "5 minutes",  // Give up after 5 minutes total
  },
});

Selective retry with isRetryable

Use isRetryable to retry only specific error types. This is useful when some failures are permanent (validation errors, not-found errors) and others are transient (network errors, rate limits).
import { Effect, Data } from "effect";

class ValidationError extends Data.TaggedError("ValidationError")<{
  readonly message: string;
}> {}

class NetworkError extends Data.TaggedError("NetworkError")<{
  readonly message: string;
}> {}

yield* Workflow.step({
  name: "Process payment",
  execute: processPayment(paymentId),
  retry: {
    maxAttempts: 5,
    delay: Backoff.presets.standard(),
    isRetryable: (error) => error instanceof NetworkError,
  },
});
ValidationError will fail the step immediately. NetworkError will trigger up to 5 retries.

Timeouts

Set a per-attempt deadline with timeout:
yield* Workflow.step({
  name: "External API",
  execute: callExternalAPI(),
  timeout: "30 seconds",
});

Timeout with retry

When both timeout and retry are configured, the timeout applies to each attempt individually — not to the total time:
yield* Workflow.step({
  name: "API call",
  execute: callAPI(),
  timeout: "30 seconds",  // Each attempt gets 30 seconds
  retry: { maxAttempts: 3 },
});
// Maximum total: 3 attempts × 30 seconds = 90 seconds (plus retry delays)
A timed-out attempt is treated as a failure and triggers a retry if the step has retry configured and isRetryable returns true for WorkflowTimeoutError.

Duration format

Both timeout and delay accept the same formats:
// Human-readable strings
timeout: "30 seconds"
timeout: "5 minutes"
timeout: "2 hours"

// Milliseconds
timeout: 5000

Build docs developers (and LLMs) love