Some failures are final
Retries are the default. That's mostly what you want. If the kitchen printer jammed once, it might work next time. If the Resend API hiccuped, give it another shot.
But some things will never get better. A card that says "declined" will keep saying "declined." Burning four attempts before giving up isn't graceful. It's just slow.
FatalError is the way you tell the runtime: this one is done. Don't retry. Mark the workflow as failed and move on. We've been using it in our Resend steps since 1.2 without dwelling on it. Today we add the step that exists specifically to fail when it should: chargeCard.
Outcome
Add a chargeCard step at the front of processOrder. If the card's last 4 is 0000, throw a FatalError. The workflow fails immediately with no retry attempts.
Fast Track
- Create
workflows/steps/charge-card.tsthat throwsFatalErrorwhencardLast4 === "0000". - Call
chargeCardas the first step inprocessOrder. - Place an order with
0000as the card last 4. Watch the workflow fail immediately with one attempt.
Hands-on exercise
1. Write the step.
workflows/steps/charge-card.ts:
import { FatalError } from "workflow";
import { updateStatus } from "@/lib/orders-store";
import type { Order } from "@/lib/pizza";
export async function chargeCard(order: Order): Promise<{ chargedAt: string }> {
"use step";
if (order.cardLast4 === "0000") {
throw new FatalError(`Card declined for order ${order.id}`);
}
await new Promise((resolve) => setTimeout(resolve, 200));
updateStatus(order.id, "charged");
return { chargedAt: new Date().toISOString() };
}The shape is familiar. The interesting bit is the conditional FatalError.
In a real implementation, this is where you'd call Stripe and check the response. A card_declined error would throw FatalError. A network blip would throw a regular Error so the runtime retries. The distinction is intent: "this won't recover" versus "this might recover."
Return values matter, too. The function returns { chargedAt }. That gets persisted in the event log and would normally be useful downstream. We're not consuming it, but the runtime is still recording it.
2. Call it first in the workflow.
workflows/process-order.ts (only the first call changes):
import { createHook, sleep } from "workflow";
import type { Order } from "@/lib/pizza";
import { chargeCard } from "./steps/charge-card";
import { sendOrderConfirmation } from "./steps/send-order-confirmation";
// ... other imports unchanged
export async function processOrder(order: Order): Promise<{
status: "delivered" | "escalated";
orderId: string;
}> {
"use workflow";
await chargeCard(order);
await sendOrderConfirmation(order);
// ... rest unchanged
}One import, one await at the top. The rest of the workflow doesn't change.
Try It
Place an order with card last 4 set to 4242. Everything works the way it has all course. The workflow charges the card, confirms the order, and proceeds.
Now place an order with card last 4 set to 0000. Look at the dashboard:
processOrder failed 213ms
└─ chargeCard failed 208ms
error: FatalError: Card declined for order f8c2...
attempts: 1
One attempt. Failed immediately. No retries.
Swap the FatalError for a regular Error and try again:
if (order.cardLast4 === "0000") {
throw new Error(`Card declined for order ${order.id}`);
}Now the same order burns through chargeCard.maxRetries + 1 attempts before giving up:
processOrder failed 1.4s
└─ chargeCard failed
attempts:
1. failed
2. failed
3. failed
4. failed
The customer waited an extra second for the same result. That's the cost of treating a "this won't ever work" as if it might.
Put the FatalError back.
Commit
feat(workflow): add chargeCard step with FatalError on decline
Done-When
workflows/steps/charge-card.tsexists and throwsFatalErrorwhencardLast4 === "0000"processOrderawaitschargeCardas its first step- Placing an order with
cardLast4: "0000"fails the workflow with one attempt - Placing an order with
cardLast4: "4242"continues through the normal flow
Solution
workflows/steps/charge-card.ts:
import { FatalError } from "workflow";
import { updateStatus } from "@/lib/orders-store";
import type { Order } from "@/lib/pizza";
export async function chargeCard(order: Order): Promise<{ chargedAt: string }> {
"use step";
if (order.cardLast4 === "0000") {
throw new FatalError(`Card declined for order ${order.id}`);
}
await new Promise((resolve) => setTimeout(resolve, 200));
updateStatus(order.id, "charged");
return { chargedAt: new Date().toISOString() };
}workflows/process-order.ts (the only change is adding chargeCard as the first call):
import { createHook, sleep } from "workflow";
import type { Order } from "@/lib/pizza";
import { chargeCard } from "./steps/charge-card";
import { sendOrderConfirmation } from "./steps/send-order-confirmation";
import { acknowledgeKitchen } from "./steps/acknowledge-kitchen";
import { dispatchDelivery } from "./steps/dispatch-delivery";
import { markOutForDelivery } from "./steps/mark-out-for-delivery";
import { confirmDelivery } from "./steps/confirm-delivery";
import { sendReviewRequest } from "./steps/send-review-request";
import { sendEscalationEmail } from "./steps/send-escalation-email";
const KITCHEN_TIMEOUT = "20m";
export async function processOrder(order: Order): Promise<{
status: "delivered" | "escalated";
orderId: string;
}> {
"use workflow";
await chargeCard(order);
await sendOrderConfirmation(order);
await acknowledgeKitchen(order);
using kitchenHook = createHook<{ readyAt: string }>({
token: `kitchen:${order.id}`,
});
const kitchenResult = await Promise.race([
kitchenHook.then((payload) => ({ kind: "ready" as const, payload })),
sleep(KITCHEN_TIMEOUT).then(() => ({ kind: "timeout" as const })),
]);
if (kitchenResult.kind === "timeout") {
await sendEscalationEmail(order);
return { status: "escalated", orderId: order.id };
}
await dispatchDelivery(order);
using pickupHook = createHook<{ pickedUpAt: string }>({
token: `driver-pickup:${order.id}`,
});
await pickupHook;
await markOutForDelivery(order);
using deliveryHook = createHook<{ deliveredAt: string }>({
token: `driver-delivered:${order.id}`,
});
await deliveryHook;
await confirmDelivery(order);
await sendReviewRequest(order);
return { status: "delivered", orderId: order.id };
}FatalError and Error look the same in code. They behave very differently. Pick on purpose.
Was this helpful?