Skip to content

Step design

A step is the smallest unit of durability. It runs your code, checkpoints the return value, and retrieves that value for every subsequent replay. How you divide work into steps influences how many checkpoints your execution has, how retries behave and how you read the workflow history in logs and errors.

Name steps meaningfully

The step name shows up in checkpoints, execution history, CloudWatch logs, and errors. Names like step1, process-data, or do-stuff make failures hard to triage. Prefer names like validate-order, charge-payment, and notify-customer that describe the encapsulated logic.

Keep names static. Names are part of the deterministic identity of the step. Including a timestamp or a random ID in the name breaks replay because the same step resolves to a different name on subsequent invocations.

// Stable, descriptive name.
await context.step("validate-order", async () => validateOrder(order));

// Dynamic but deterministic: include the item ID from the input.
await context.step(`save-item-${item.id}`, async () => saveItem(item));

// Wrong: non-deterministic name, changes on replay.
await context.step(`run-${Date.now()}`, async () => doThing());
# Stable, descriptive name.
context.step(validate_order(order), name="validate-order")

# Dynamic but deterministic: include the item ID from the input.
context.step(save_item(item), name=f"save-item-{item['id']}")

# Wrong: non-deterministic name.
context.step(do_thing(), name=f"run-{time.time()}")
// Stable, descriptive name.
context.step("validate-order", ValidationResult.class,
    ctx -> validateOrder(order));

// Dynamic but deterministic: include the item ID from the input.
context.step("save-item-" + item.id(), Void.class,
    ctx -> { saveItem(item); return null; });

The name is the second argument, after the context. A name derived from the input is deterministic. A name built from time.Now() breaks replay identity. When you run durablelint, the SDK's static analysis tool, its durablenondeterminism rule reports the time.Now() call.

package main

import (
    "fmt"
    "time"

    "github.com/aws/aws-durable-execution-sdk-go/durable"
)

func doThing() (string, error) { return "done", nil }

// Wrong: the name changes on every invocation, so replay cannot match the step.
func handler(ctx durable.Context, _ any) (string, error) {
    return durable.Step(ctx, fmt.Sprintf("run-%d", time.Now().UnixMilli()), func(durable.StepContext) (string, error) {
        return doThing()
    })
}

func main() {
    durable.Start(handler)
}
package main

import (
    "fmt"

    "github.com/aws/aws-durable-execution-sdk-go/durable"
)

type Item struct {
    ID string `json:"id"`
}

type Event struct {
    Items []Item `json:"items"`
}

func validateOrder() (string, error)     { return "valid", nil }
func saveItem(item Item) (string, error) { return "saved-" + item.ID, nil }

func handler(ctx durable.Context, event Event) ([]string, error) {
    // A stable, descriptive name.
    if _, err := durable.Step(ctx, "validate-order", func(durable.StepContext) (string, error) {
        return validateOrder()
    }); err != nil {
        return nil, err
    }

    // Dynamic but deterministic: the name derives from the input item ID.
    saved := []string{}
    for _, item := range event.Items {
        s, err := durable.Step(ctx, fmt.Sprintf("save-item-%s", item.ID), func(durable.StepContext) (string, error) {
            return saveItem(item)
        })
        if err != nil {
            return nil, err
        }
        saved = append(saved, s)
    }
    return saved, nil
}

func main() {
    durable.Start(handler)
}

Set the name with .name(..), which takes a &str or a String. A name that format! builds from the input is deterministic. A name built from SystemTime::now() changes on the next invocation, and replay then fails the execution with a non-determinism error.

use aws_durable_execution_sdk as durable;
use serde::Deserialize;

#[derive(Clone, Deserialize)]
struct Order {
    id: String,
}

#[derive(Clone, Deserialize)]
struct Item {
    id: String,
}

#[derive(Deserialize)]
struct OrderEvent {
    order: Order,
    item: Item,
}

fn validate_order(order: &Order) -> bool {
    !order.id.is_empty()
}

fn save_item(item: &Item) -> String {
    format!("saved-{}", item.id)
}

async fn handler(event: OrderEvent, ctx: durable::DurableContext) -> Result<(), durable::BoxError> {
    let OrderEvent { order, item } = event;

    // Stable, descriptive name.
    ctx.step(move |_| async move { Ok(validate_order(&order)) })
        .name("validate-order")
        .await?;

    // Dynamic but deterministic: include the item ID from the input.
    let name = format!("save-item-{}", item.id);
    ctx.step(move |_| async move { Ok(save_item(&item)) })
        .name(name)
        .await?;
    Ok(())
}

#[tokio::main]
async fn main() -> Result<(), lambda_runtime::Error> {
    durable::init_default_subscriber()?;
    durable::run(handler).await
}
use std::time::{SystemTime, UNIX_EPOCH};

use aws_durable_execution_sdk as durable;

fn do_thing() -> String {
    "done".to_owned()
}

async fn handler(
    _event: serde_json::Value,
    ctx: durable::DurableContext,
) -> Result<(), durable::BoxError> {
    // Wrong: non-deterministic name, changes on replay.
    let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis();
    ctx.step(|_| async { Ok(do_thing()) })
        .name(format!("run-{now}"))
        .await?;
    Ok(())
}

#[tokio::main]
async fn main() -> Result<(), lambda_runtime::Error> {
    durable::init_default_subscriber()?;
    durable::run(handler).await
}
using Amazon.Lambda.Core;
using Amazon.Lambda.DurableExecution;

public class StepNamesExample
{
    public Task<DurableExecutionInvocationOutput> Handler(
        DurableExecutionInvocationInput input, ILambdaContext context)
        => DurableFunction.WrapAsync<OrderEvent, string>(Workflow, input, context);

    private async Task<string> Workflow(OrderEvent input, IDurableContext ctx)
    {
        // Stable, descriptive name.
        ValidationResult validation = await ctx.StepAsync(
            async (_, _) => ValidateOrder(input),
            name: "validate-order");

        // Dynamic but deterministic: include the item ID from the input.
        await ctx.StepAsync(
            async (_, _) => SaveItem(input.Item),
            name: $"save-item-{input.Item.Id}");

        return validation.OrderId;
    }

    private static ValidationResult ValidateOrder(OrderEvent order) => new(order.Item.Id, Valid: true);
    private static void SaveItem(Item item) { /* persist item */ }
}

public record OrderEvent(Item Item);
public record Item(string Id);
public record ValidationResult(string OrderId, bool Valid);

Warning

A step name with a timestamp or random value resolves to a different name on replay. Keep names static.

Single responsibility

Operations that must succeed or fail together belong in the same step. Split unrelated operations into separate steps so each one has a single logical intent. Keep one external API call per step when that call has side effects.

A step that batches three unrelated side effects reruns all three on retry. If the second call fails, the first runs again.

Related reads against the same resource can batch into one step because they are safe to re-run.

Info

Pure computation rarely benefits from its own step. Deriving a value from data that is already in memory does not need durability, it does not need retries, and each extra step is an unnecessary checkpoint.

// Wrong: one step for three unrelated side effects.
await context.step("process-order", async () => {
  await chargePayment(order);
  await sendConfirmationEmail(order);
  await updateInventory(order);
});

// Right: each side effect gets its own step.
await context.step("charge-payment", async () => chargePayment(order));
await context.step("send-confirmation", async () => sendConfirmationEmail(order));
await context.step("update-inventory", async () => updateInventory(order));
# Right: each side effect gets its own step.
context.step(charge_payment(order), name="charge-payment")
context.step(send_confirmation(order), name="send-confirmation")
context.step(update_inventory(order), name="update-inventory")
context.step("charge-payment", Receipt.class,
    ctx -> chargePayment(order));
context.step("send-confirmation", Void.class,
    ctx -> { sendConfirmationEmail(order); return null; });
context.step("update-inventory", Void.class,
    ctx -> { updateInventory(order); return null; });

Give each side effect its own step so each one checkpoints independently. A single step that performs all three repeats all three when it retries.

package main

import "github.com/aws/aws-durable-execution-sdk-go/durable"

type Order struct {
    ID    string `json:"id"`
    Total int    `json:"total"`
}

func chargePayment(order Order) (string, error)    { return "charged-" + order.ID, nil }
func sendConfirmation(order Order) (string, error) { return "emailed-" + order.ID, nil }
func updateInventory(order Order) (string, error)  { return "updated-" + order.ID, nil }

// Wrong: one step for three unrelated side effects. A retry repeats all three.
func handler(ctx durable.Context, order Order) (string, error) {
    if _, err := durable.Step(ctx, "process-order", func(durable.StepContext) (struct{}, error) {
        if _, err := chargePayment(order); err != nil {
            return struct{}{}, err
        }
        if _, err := sendConfirmation(order); err != nil {
            return struct{}{}, err
        }
        _, err := updateInventory(order)
        return struct{}{}, err
    }); err != nil {
        return "", err
    }
    return "done", nil
}

func main() {
    durable.Start(handler)
}
package main

import "github.com/aws/aws-durable-execution-sdk-go/durable"

type Order struct {
    ID    string `json:"id"`
    Total int    `json:"total"`
}

func chargePayment(order Order) (string, error)    { return "charged-" + order.ID, nil }
func sendConfirmation(order Order) (string, error) { return "emailed-" + order.ID, nil }
func updateInventory(order Order) (string, error)  { return "updated-" + order.ID, nil }

func handler(ctx durable.Context, order Order) (string, error) {
    if _, err := durable.Step(ctx, "charge-payment", func(durable.StepContext) (string, error) {
        return chargePayment(order)
    }); err != nil {
        return "", err
    }
    if _, err := durable.Step(ctx, "send-confirmation", func(durable.StepContext) (string, error) {
        return sendConfirmation(order)
    }); err != nil {
        return "", err
    }
    if _, err := durable.Step(ctx, "update-inventory", func(durable.StepContext) (string, error) {
        return updateInventory(order)
    }); err != nil {
        return "", err
    }
    return "done", nil
}

func main() {
    durable.Start(handler)
}

Give each side effect its own step so each one checkpoints independently. A single step that performs all three repeats all three when it retries.

use aws_durable_execution_sdk as durable;
use serde::Deserialize;

#[derive(Clone, Deserialize)]
struct Order {
    id: String,
}

fn charge_payment(order: &Order) -> String {
    format!("rcpt-{}", order.id)
}

fn send_confirmation_email(order: &Order) -> String {
    format!("email-{}", order.id)
}

fn update_inventory(order: &Order) -> String {
    format!("inventory-{}", order.id)
}

async fn handler(order: Order, ctx: durable::DurableContext) -> Result<(), durable::BoxError> {
    // Wrong: one step for three unrelated side effects.
    ctx.step(move |_| async move {
        charge_payment(&order);
        send_confirmation_email(&order);
        update_inventory(&order);
        Ok(())
    })
    .name("process-order")
    .await?;
    Ok(())
}

#[tokio::main]
async fn main() -> Result<(), lambda_runtime::Error> {
    durable::init_default_subscriber()?;
    durable::run(handler).await
}
use aws_durable_execution_sdk as durable;
use serde::Deserialize;

#[derive(Clone, Deserialize)]
struct Order {
    id: String,
}

fn charge_payment(order: &Order) -> String {
    format!("rcpt-{}", order.id)
}

fn send_confirmation_email(order: &Order) -> String {
    format!("email-{}", order.id)
}

fn update_inventory(order: &Order) -> String {
    format!("inventory-{}", order.id)
}

async fn handler(order: Order, ctx: durable::DurableContext) -> Result<(), durable::BoxError> {
    // Right: each side effect gets its own step.
    let to_charge = order.clone();
    ctx.step(move |_| async move { Ok(charge_payment(&to_charge)) })
        .name("charge-payment")
        .await?;
    let to_confirm = order.clone();
    ctx.step(move |_| async move { Ok(send_confirmation_email(&to_confirm)) })
        .name("send-confirmation")
        .await?;
    ctx.step(move |_| async move { Ok(update_inventory(&order)) })
        .name("update-inventory")
        .await?;
    Ok(())
}

#[tokio::main]
async fn main() -> Result<(), lambda_runtime::Error> {
    durable::init_default_subscriber()?;
    durable::run(handler).await
}
using Amazon.Lambda.Core;
using Amazon.Lambda.DurableExecution;

public class OneThingPerStepExample
{
    public Task<DurableExecutionInvocationOutput> Handler(
        DurableExecutionInvocationInput input, ILambdaContext context)
        => DurableFunction.WrapAsync<Order, Receipt>(Workflow, input, context);

    private async Task<Receipt> Workflow(Order order, IDurableContext ctx)
    {
        // Right: each side effect gets its own step.
        Receipt receipt = await ctx.StepAsync(
            async (_, ct) => await ChargePayment(order, ct),
            name: "charge-payment");
        await ctx.StepAsync(
            async (_, ct) => await SendConfirmationEmail(order, ct),
            name: "send-confirmation");
        await ctx.StepAsync(
            async (_, ct) => await UpdateInventory(order, ct),
            name: "update-inventory");
        return receipt;
    }

    private static Task<Receipt> ChargePayment(Order order, CancellationToken ct)
        => Task.FromResult(new Receipt(order.OrderId, Charged: true));
    private static Task SendConfirmationEmail(Order order, CancellationToken ct) => Task.CompletedTask;
    private static Task UpdateInventory(Order order, CancellationToken ct) => Task.CompletedTask;
}

public record Order(string OrderId);
public record Receipt(string OrderId, bool Charged);

Reuse step logic

Define a reusable step function once and reference it repeatedly from the workflow.

Wrap the core logic in a named function, pass it to context.step.

async function validateOrder(order: Order): Promise<ValidationResult> {
  // ...
}

await context.step("validate-order", () => validateOrder(order));

@durable_step wraps a callable so that calling it with arguments returns a (StepContext) -> T that context.step can run.

@durable_step
def validate_order(ctx: StepContext, order: dict) -> dict:
    return run_validation(order)


context.step(validate_order(order))

Define a method and reference it with a lambda.

private ValidationResult validateOrder(Order order) {
    return new Validator().validate(order);
}

context.step("validate-order", ValidationResult.class,
    ctx -> validateOrder(order));

Define the function once, free of SDK types, and wrap it in a durable.Step closure wherever you reuse it.

package main

import "github.com/aws/aws-durable-execution-sdk-go/durable"

type Order struct {
    ID    string `json:"id"`
    Total int    `json:"total"`
}

type ValidationResult struct {
    Valid bool `json:"valid"`
}

// Define the core logic once, free of SDK types.
func validateOrder(order Order) (ValidationResult, error) {
    return ValidationResult{Valid: order.Total > 0}, nil
}

func handler(ctx durable.Context, order Order) (ValidationResult, error) {
    return durable.Step(ctx, "validate-order", func(durable.StepContext) (ValidationResult, error) {
        return validateOrder(order)
    })
}

func main() {
    durable.Start(handler)
}

Define the function once, free of SDK types, and call it from a step closure wherever you reuse it. The closure must be 'static, so it moves in an owned value, such as a clone of order.

use aws_durable_execution_sdk as durable;
use serde::{Deserialize, Serialize};

#[derive(Clone, Deserialize)]
struct Order {
    id: String,
    total: f64,
}

#[derive(Serialize, Deserialize)]
struct ValidationResult {
    order_id: String,
    valid: bool,
}

fn validate_order(order: &Order) -> ValidationResult {
    ValidationResult {
        order_id: order.id.clone(),
        valid: order.total > 0.0,
    }
}

async fn handler(order: Order, ctx: durable::DurableContext) -> Result<(), durable::BoxError> {
    ctx.step(move |_| async move { Ok(validate_order(&order)) })
        .name("validate-order")
        .await?;
    Ok(())
}

#[tokio::main]
async fn main() -> Result<(), lambda_runtime::Error> {
    durable::init_default_subscriber()?;
    durable::run(handler).await
}

Define a method returning Task<T> and reference it as the step body.

using Amazon.Lambda.Core;
using Amazon.Lambda.DurableExecution;

public class ReusableStepExample
{
    public Task<DurableExecutionInvocationOutput> Handler(
        DurableExecutionInvocationInput input, ILambdaContext context)
        => DurableFunction.WrapAsync<Order, ValidationResult>(Workflow, input, context);

    private async Task<ValidationResult> Workflow(Order order, IDurableContext ctx)
    {
        // Reference the reusable method as the step body.
        return await ctx.StepAsync(
            async (_, ct) => await ValidateOrder(order, ct),
            name: "validate-order");
    }

    // Define a reusable step method once and reference it repeatedly.
    private static Task<ValidationResult> ValidateOrder(Order order, CancellationToken ct)
        => Task.FromResult(new ValidationResult(order.OrderId, Valid: true));
}

public record Order(string OrderId);
public record ValidationResult(string OrderId, bool Valid);

Step nesting

A step receives a StepContext, not the full DurableContext. A step is the atomic unit the SDK checkpoints. You cannot call other durable operations such as step or wait inside another step. If you need to group several durable operations, use runInChildContext as described in Code organization instead.

// Wrong: calling context.step inside a step callback is invalid.
await context.step("outer", async (ctx) => {
  await context.step("inner", async () => work()); // ERROR
});

// Right: group durable operations in a child context.
await context.runInChildContext("order-pipeline", async (child) => {
  await child.step("validate", async () => validate());
  await child.step("charge", async () => charge());
  return "done";
});
# Wrong: calling context.step inside a step callback is invalid.
@durable_step
def outer(ctx: StepContext, context: DurableContext) -> None:
    context.step(work())  # ERROR: ctx is a StepContext, not a DurableContext


# Right: group durable operations in a child context.
def order_pipeline(child: DurableContext) -> str:
    child.step(validate())
    child.step(charge())
    return "done"


context.run_in_child_context(order_pipeline, name="order-pipeline")
// Wrong: calling context.step inside a step callback is invalid.
context.step("outer", Void.class, ctx -> {
    context.step("inner", Void.class, c -> { work(); return null; }); // ERROR
    return null;
});

// Right: group durable operations in a child context.
context.runInChildContext("order-pipeline", String.class, child -> {
    child.step("validate", Void.class, c -> { validate(); return null; });
    child.step("charge", Void.class, c -> { charge(); return null; });
    return "done";
});

A step body receives a durable.StepContext, which cannot start an operation. A body that captures the outer durable.Context and starts an operation on it compiles. At run time that operation fails with durable.ErrWrongContext and records nothing. When you run durablelint, its durablenestedop rule reports the operation. Group operations with durable.RunInChildContext.

package main

import "github.com/aws/aws-durable-execution-sdk-go/durable"

func work() (string, error) { return "worked", nil }

// Wrong: the step body starts an operation on the captured outer context.
// The inner call fails with durable.ErrWrongContext.
func handler(ctx durable.Context, _ any) (string, error) {
    return durable.Step(ctx, "outer", func(durable.StepContext) (string, error) {
        return durable.Step(ctx, "inner", func(durable.StepContext) (string, error) {
            return work()
        })
    })
}

func main() {
    durable.Start(handler)
}
package main

import "github.com/aws/aws-durable-execution-sdk-go/durable"

type Order struct {
    ID string `json:"id"`
}

func validate(order Order) (bool, error) { return order.ID != "", nil }
func charge(order Order) (string, error) { return "charged-" + order.ID, nil }

func handler(ctx durable.Context, order Order) (string, error) {
    return durable.RunInChildContext(ctx, "order-pipeline", func(child durable.Context) (string, error) {
        if _, err := durable.Step(child, "validate", func(durable.StepContext) (bool, error) {
            return validate(order)
        }); err != nil {
            return "", err
        }
        if _, err := durable.Step(child, "charge", func(durable.StepContext) (string, error) {
            return charge(order)
        }); err != nil {
            return "", err
        }
        return "done", nil
    })
}

func main() {
    durable.Start(handler)
}

A step body receives a durable::StepContext, whose only method is attempt(). A body that captures a clone of the outer durable::DurableContext and starts an operation on it compiles. At run time that call fails the execution with a context usage error. Group operations with ctx.run_in_child_context.

use aws_durable_execution_sdk as durable;

fn work() -> String {
    "worked".to_owned()
}

async fn handler(
    _event: serde_json::Value,
    ctx: durable::DurableContext,
) -> Result<(), durable::BoxError> {
    // Wrong: calling ctx.step inside a step body is invalid.
    let outer = ctx.clone();
    ctx.step(move |_| async move {
        outer.step(|_| async { Ok(work()) }).name("inner").await?;
        Ok(())
    })
    .name("outer")
    .await?;
    Ok(())
}

#[tokio::main]
async fn main() -> Result<(), lambda_runtime::Error> {
    durable::init_default_subscriber()?;
    durable::run(handler).await
}
use aws_durable_execution_sdk as durable;

fn validate() -> bool {
    true
}

fn charge() -> String {
    "charged".to_owned()
}

async fn handler(
    _event: serde_json::Value,
    ctx: durable::DurableContext,
) -> Result<(), durable::BoxError> {
    // Right: group durable operations in a child context.
    ctx.run_in_child_context(|child| async move {
        child
            .step(|_| async { Ok(validate()) })
            .name("validate")
            .await?;
        child
            .step(|_| async { Ok(charge()) })
            .name("charge")
            .await?;
        Ok("done".to_owned())
    })
    .name("order-pipeline")
    .await?;
    Ok(())
}

#[tokio::main]
async fn main() -> Result<(), lambda_runtime::Error> {
    durable::init_default_subscriber()?;
    durable::run(handler).await
}
using Amazon.Lambda.Core;
using Amazon.Lambda.DurableExecution;

public class StepBoundaryExample
{
    public Task<DurableExecutionInvocationOutput> Handler(
        DurableExecutionInvocationInput input, ILambdaContext context)
        => DurableFunction.WrapAsync<object, string>(Workflow, input, context);

    private async Task<string> Workflow(object input, IDurableContext ctx)
    {
        // Wrong: you cannot call another durable operation inside a step body.
        // The step body receives an IStepContext, not an IDurableContext, so there
        // is no StepAsync to call. Grouping durable operations belongs in a child
        // context instead.

        // Right: group durable operations in a child context.
        return await ctx.RunInChildContextAsync(
            async (child, ct) =>
            {
                await child.StepAsync(async (_, _) => Validate(), name: "validate");
                await child.StepAsync(async (_, _) => Charge(), name: "charge");
                return "done";
            },
            name: "order-pipeline");
    }

    private static bool Validate() => true;
    private static bool Charge() => true;
}

Handle errors explicitly

Code inside a step runs under the step's retry strategy. An unhandled error triggers the strategy's decision function. A returned value checkpoints the result.

Let errors propagate and use the retry strategy's configuration to decide which error types are retryable. Retry transient failures such as network timeouts, rate limits and 503s with backoff. Fail the step immediately for permanent failures such as invalid input, 404s and authentication errors.

Match the retry strategy to the work. Fast idempotent calls get tight retries, meaning a handful of attempts with only a few seconds of backoff. Long-running calls to third parties get wide retries (many attempts, minutes of backoff). See Retries for presets and configuration options.

List the retryable error classes in the retry strategy configuration.

import { createRetryStrategy } from "@aws/durable-execution-sdk-js";

class TransientApiError extends Error {}
class RateLimitError extends Error {}

// Retry only transient errors. Anything else (bad input, not-found) fails immediately.
const retryStrategy = createRetryStrategy({
  maxAttempts: 5,
  initialDelay: { seconds: 2 },
  retryableErrorTypes: [TransientApiError, RateLimitError],
});

await context.step(
  "call-api",
  async () => externalApi.get(event.id),
  { retryStrategy },
);

List the retryable error classes in the retry strategy configuration.

from aws_durable_execution_sdk_python import durable_step
from aws_durable_execution_sdk_python.config import StepConfig
from aws_durable_execution_sdk_python.retries import (
    RetryStrategyConfig,
    create_retry_strategy,
)
from aws_durable_execution_sdk_python.types import StepContext


class TransientApiError(Exception):
    pass


class RateLimitError(Exception):
    pass


@durable_step
def call_api(ctx: StepContext, record_id: str) -> dict:
    return external_api.get(record_id)


# Retry only transient errors. Anything else (bad input, not-found) fails immediately.
retry_strategy = create_retry_strategy(
    RetryStrategyConfig(
        max_attempts=5,
        retryable_error_types=[TransientApiError, RateLimitError],
    )
)

context.step(
    call_api(event["id"]),
    config=StepConfig(retry_strategy=retry_strategy),
)

Write a RetryStrategy lambda that checks the error type before delegating to a preset for the delay decision.

import java.time.Duration;
import software.amazon.lambda.durable.config.StepConfig;
import software.amazon.lambda.durable.retry.JitterStrategy;
import software.amazon.lambda.durable.retry.RetryDecision;
import software.amazon.lambda.durable.retry.RetryStrategies;
import software.amazon.lambda.durable.retry.RetryStrategy;

static class TransientApiException extends RuntimeException {}
static class RateLimitException extends RuntimeException {}

// Retry only transient errors. Anything else fails immediately.
RetryStrategy retryStrategy = (error, attempt) -> {
    if (!(error instanceof TransientApiException) && !(error instanceof RateLimitException)) {
        return RetryDecision.fail();
    }
    return RetryStrategies.exponentialBackoff(
            5, Duration.ofSeconds(2), Duration.ofMinutes(1), 2.0, JitterStrategy.FULL)
        .makeRetryDecision(error, attempt);
};

context.step(
    "call-api",
    Record.class,
    ctx -> externalApi.get(input.id()),
    StepConfig.builder().retryStrategy(retryStrategy).build());

List the retryable errors in RetryConfig.RetryableErrors as matchers. durable.ErrorAs matches an error type under errors.As, and durable.ErrorIs matches a sentinel value under errors.Is. A step fails on the first attempt whose error no matcher accepts.

package main

import (
    "time"

    "github.com/aws/aws-durable-execution-sdk-go/durable"
)

type Event struct {
    ID string `json:"id"`
}

type TransientAPIError struct{ Message string }

func (e *TransientAPIError) Error() string { return e.Message }

type RateLimitError struct{ Message string }

func (e *RateLimitError) Error() string { return e.Message }

func callAPI(id string) (string, error) { return "result-" + id, nil }

func handler(ctx durable.Context, event Event) (string, error) {
    // Retry only transient errors. Anything else fails the step immediately.
    retry := durable.MustNewRetryStrategy(durable.RetryConfig{
        MaxAttempts:  5,
        InitialDelay: 2 * time.Second,
        RetryableErrors: []durable.ErrorMatcher{
            durable.ErrorAs[*TransientAPIError](),
            durable.ErrorAs[*RateLimitError](),
        },
    })

    return durable.Step(ctx, "call-api", func(durable.StepContext) (string, error) {
        return callAPI(event.ID)
    }, durable.WithRetry(retry))
}

func main() {
    durable.Start(handler)
}

Pass a predicate to retry_if on RetryStrategyConfig::builder() to choose which errors retry. The predicate receives the durable::StepError, whose source() is the error the step body returned, so is::<T>() matches its type. Import std::error::Error to call source(). A step fails on the first attempt whose error the predicate rejects.

use std::error::Error as _;
use std::time::Duration;

use aws_durable_execution_sdk as durable;
use durable::builders::RetryStrategyConfig;
use serde::Deserialize;

#[derive(Debug)]
struct TransientApiError;

impl std::fmt::Display for TransientApiError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str("transient API error")
    }
}

impl std::error::Error for TransientApiError {}

#[derive(Debug)]
struct RateLimitError;

impl std::fmt::Display for RateLimitError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str("rate limited")
    }
}

impl std::error::Error for RateLimitError {}

#[derive(Deserialize)]
struct ApiEvent {
    id: String,
}

fn external_api_get(id: &str) -> String {
    format!("record-{id}")
}

async fn handler(event: ApiEvent, ctx: durable::DurableContext) -> Result<(), durable::BoxError> {
    // Retry only transient errors. Anything else (bad input, not-found) fails immediately.
    let retry_strategy = RetryStrategyConfig::builder()
        .max_attempts(5)
        .initial_delay(Duration::from_secs(2))
        .retry_if(|error| {
            error.source().is_some_and(|source| {
                source.is::<TransientApiError>() || source.is::<RateLimitError>()
            })
        })
        .build();

    let id = event.id;
    ctx.step(move |_| async move { Ok(external_api_get(&id)) })
        .name("call-api")
        .retry_strategy_config(retry_strategy)
        .await?;
    Ok(())
}

#[tokio::main]
async fn main() -> Result<(), lambda_runtime::Error> {
    durable::init_default_subscriber()?;
    durable::run(handler).await
}

List the retryable exception types in the retry strategy configuration.

using Amazon.Lambda.Core;
using Amazon.Lambda.DurableExecution;

public class HandleErrorsInStepExample
{
    public Task<DurableExecutionInvocationOutput> Handler(
        DurableExecutionInvocationInput input, ILambdaContext context)
        => DurableFunction.WrapAsync<ApiEvent, Record>(Workflow, input, context);

    private async Task<Record> Workflow(ApiEvent input, IDurableContext ctx)
    {
        // Retry only transient errors. Anything else (bad input, not-found) fails immediately.
        var config = new StepConfig
        {
            RetryStrategy = RetryStrategy.Exponential(
                maxAttempts: 5,
                initialDelay: TimeSpan.FromSeconds(2),
                maxDelay: TimeSpan.FromMinutes(1),
                retryableExceptions: new[] { typeof(TransientApiException), typeof(RateLimitException) }),
        };

        return await ctx.StepAsync(
            async (_, ct) => await ExternalApi.Get(input.Id, ct),
            name: "call-api",
            config: config);
    }
}

public class TransientApiException : Exception { }
public class RateLimitException : Exception { }

public record ApiEvent(string Id);
public record Record(string Id, string Data);

public static class ExternalApi
{
    public static Task<Record> Get(string id, CancellationToken ct)
        => Task.FromResult(new Record(id, "data"));
}

Warning

Swallowing an exception inside a step hides the failure from the retry strategy and the caller. Let the error propagate and configure the retry strategy to decide.

See also