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());
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));
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.
@durable_step wraps a callable so that calling it with arguments returns a
(StepContext) -> T that context.step can run.
Define a method and reference it with a lambda.
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¶
- Idempotency and retries Retry interactions with side effects.
- Code organization Child contexts, grouping.
- Step operation reference
- Errors and retries