Skip to main content
Concepts

Steps

Define replay-safe workflow steps, handle terminal failures as data, and structure loops with durable, checkpoint-friendly boundaries.

Step-only access to actor APIs

state, vars, db, client(), and connection/event APIs are only valid inside ctx.step(...) callbacks.

Use non-step workflow code for orchestration only: queue waits, sleeps, loops, joins, races, and rollback boundaries. Keep actor-local side effects in steps.

Handling terminal failures as data

Use tryStep when a step failure should produce data instead of failing the whole workflow.

import { setup, workflow } from "@rivet-dev/workflows";
export const paymentActor = workflow({
	state: {
		status: "pending" as "pending" | "manual-review" | "paid",
		reason: null as string | null,
	},
	run: async (ctx) => {
		const charge = await ctx.tryStep({
			name: "charge-card",
			maxRetries: 3,
			run: async (_ctx) => await chargeCard("order-123"),
		});
		await ctx.step("store-charge-result", async (step) => {
			if (!charge.ok) {
				step.state.status = "manual-review";
				step.state.reason = charge.failure.error.message;
				return;
			}
			step.state.status = "paid";
			step.state.reason = null;
		});
	},
	actions: {
		getState: (c) => c.state,
	},
});
async function chargeCard(orderId: string): Promise<string> {
	return `charge-${orderId}`;
}
export const registry = setup({ use: { paymentActor } });

Use try when you want to recover from terminal step, join, or race failures inside a named block.

async function runPaymentFlow(ctx: any) {
  return await ctx.try("payment-flow", async (blockCtx: any) => {
    const auth = await blockCtx.step("authorize", async (blockCtx) =>
      authorizeOrder("order-123"),
    );
    const capture = await blockCtx.step("capture", async (blockCtx) =>
      captureOrder("order-123"),
    );
    return { auth, capture };
  });
}

async function authorizeOrder(orderId: string): Promise<string> {
  return `auth-${orderId}`;
}

async function captureOrder(orderId: string): Promise<string> {
  return `capture-${orderId}`;
}
  • tryStep and try only catch terminal failures. Retry backoff, sleeps, queue waits, eviction, and history divergence still rethrow.
  • Catching a failure does not undo it. state and vars mutations made before the failure remain visible after tryStep or try returns, so use explicit compensating steps when a caught failure needs cleanup.
  • RollbackError is not caught by default. Pass catch: ["rollback"] when you want rollback failures returned as data.

Checkpoint-friendly loop design

Use this when you need reliable replay and resume semantics across crashes and restarts.

import { setup, workflow } from "@rivet-dev/workflows";

type PaymentMessage = {
	id: string;
	amount: number;
};
export const checkpointFriendlyActor = workflow({
	state: {
		appliedCount: 0,
		totalAmount: 0,
		lastPaymentId: null as string | null,
	},
	run: async (ctx) => {
		await ctx.loop("payment-loop", async (loopCtx) => {
			const [message] = await loopCtx.queue.nextBatch("wait-payment", {
				timeout: 30000,
			});
			if (!message) return;
			const payment = message.body as PaymentMessage;
			await loopCtx.rollbackCheckpoint("apply-payment-checkpoint");
			const plan = (await loopCtx.step("build-plan", async (_loopCtx) =>
				buildPaymentPlan(payment),
			)) as {
				paymentId: string;
				amount: number;
			};
			await loopCtx.step("apply-side-effects", async (step) => {
				step.state.appliedCount += 1;
				step.state.totalAmount += plan.amount;
				step.state.lastPaymentId = plan.paymentId;
			});
		});
	},
	actions: {
		getState: (c) => c.state,
	},
});
function buildPaymentPlan(payment: PaymentMessage): {
	paymentId: string;
	amount: number;
} {
	return {
		paymentId: payment.id,
		amount: payment.amount,
	};
}
export const registry = setup({ use: { checkpointFriendlyActor } });