---
title: "Final failures"
description: "Introduce a chargeCard step at the front of the workflow that throws FatalError on a declined card, stopping retries cleanly."
canonical_url: "https://vercel.com/academy/workflow-foundations/some-failures-are-final"
md_url: "https://vercel.com/academy/workflow-foundations/some-failures-are-final.md"
docset_id: "vercel-academy"
doc_version: "1.0"
last_updated: "2026-10-20T22:57:37.851Z"
content_type: "lesson"
course: "workflow-foundations"
course_title: "Build Durable Workflows"
prerequisites:  []
---

<agent-instructions>
Vercel Academy — structured learning, not reference docs.
Lessons are sequenced.
Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume.
The lesson shows one path; if the human's project diverges, adapt concepts to their setup.
Preserve the learning goal over literal steps.
Quizzes are pedagogical — engage, don't spoil.
Quiz answers are included for your reference.
</agent-instructions>

# Final failures

# 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

1. Create `workflows/steps/charge-card.ts` that throws `FatalError` when `cardLast4 === "0000"`.
2. Call `chargeCard` as the first step in `processOrder`.
3. Place an order with `0000` as the card last 4. Watch the workflow fail immediately with one attempt.

## Hands-on exercise

**1. Write the step.**

`workflows/steps/charge-card.ts`:

```ts title="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):

```ts title="workflows/process-order.ts" {9,12}
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:

```ts
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.

\*\*Note: Two kinds of failure\*\*

Steps that should retry: throw `Error` (or any subclass that isn't `FatalError`). Steps that should fail immediately: throw `FatalError`. There's no third category. Most steps are the first kind; a few payment, validation, or "not found" cases are the second.

## Commit

```
feat(workflow): add chargeCard step with FatalError on decline
```

## Done-When

- [ ] `workflows/steps/charge-card.ts` exists and throws `FatalError` when `cardLast4 === "0000"`
- [ ] `processOrder` awaits `chargeCard` as 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`:

```ts title="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):

```ts title="workflows/process-order.ts"
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.


---

[Full course index](/academy/llms.txt) · [Sitemap](/academy/sitemap.md)
