# Recovery

An unhandled step failure fails the run. Wrap a subtree in `<Catch>` when that subtree has an alternate path, or can be retried, without throwing away the rest of the workflow.

`<Catch>` is an error boundary for steps: when something inside it fails, it renders a fallback instead of failing the run, and its `retry` runs the failed steps again. Every step failure goes one of three ways: it fails the whole run, it renders the closest `<Catch>`'s fallback, or that fallback retries.

```tsx
import { openai } from "june/sdk/openai";
import { Agent, Catch, Step } from "june";

export default function Research() {
  return (
    <Agent model={openai("gpt-5-mini")}>
      <Catch
        fallback={(error) => (
          <Step id="fallback" prompt={`Answer without searching: ${error}`} />
        )}
      >
        <Step id="research" prompt="Search for recent changes" />
      </Catch>
    </Agent>
  );
}
```

If `research` fails, the fallback renders in its place and `fallback` runs. The run settles normally, with `fallback`'s result in `steps`. Only failures inside the boundary are replaced: a step outside this `<Catch>` that fails still fails the run.

## What fails a step

- **A model call that keeps failing.** The AI SDK retries a failed call `maxRetries` times (2 by default) before the step fails. Set `maxRetries` on the `<Agent>`.
- **A `useStep` function that throws or rejects.**

An error thrown by a tool's `execute` doesn't fail the step. The AI SDK sends it to the model as the tool's result and the loop continues; the call shows on the trace's `toolCalls` without an `output`.

A failed step's trace node shows `status: "failed"` and its `error`, `{ name, message }`, before the error moves on to a `<Catch>` or the run.

## What `<Catch>` catches

Any step that fails, and any error thrown while rendering, anywhere below it: inside `<Parallel>`, nested components, or a nested `<Catch>`'s fallback. That includes a [duplicate step id](https://june.dev/docs/workflows/step-identity.md). Failures outside it, or in its own fallback, go to the next `<Catch>` up, and with none, they reject the run.

## Fallback

When a `<Catch>` catches:

1. `fallback(error, retry)` renders in place of all its children. `error` is exactly what was thrown, such as the AI SDK's `APICallError`.
2. Steps still running under it are aborted, unless they're also rendered outside it, and their [approval requests](https://june.dev/docs/workflows/approvals.md) are withdrawn. The run doesn't wait for them.
3. Steps that already finished under it keep their results, in `steps` and in a snapshot, though their nodes leave the trace with the rest of the children.

The fallback is a normal part of the tree. It can render steps, which need ids of their own, and components that call `retry`.

## Retry

`retry()` runs the failed steps again: it clears every step under the boundary that failed, then renders the children again.

- Steps that finished keep their results and don't run again.
- Steps that failed run again, each with a fresh [concurrency slot](https://june.dev/docs/workflows/parallelism.md#concurrency). Steps aborted when the fallback rendered run again too.
- A step can render with a new definition on retry, such as a prompt that says what went wrong.
- A retry on an outer `<Catch>` also re-runs steps that failed under a nested one.

> **A retry repeats side effects.** A failed or aborted step runs again from the start, so whatever it did before it stopped, such as a tool call that wrote a file or sent a message, can happen twice. Make tools that retry safely idempotent, or put work that mustn't repeat behind an [approval](https://june.dev/docs/workflows/approvals.md). Steps that finished aren't repeated; they keep their results. The same holds across a restart, where a [snapshot](https://june.dev/docs/workflows/resume.md) reuses finished steps by their [ids and cache keys](https://june.dev/docs/workflows/step-identity.md) and runs everything else, side effects included, again.

Retry policy is ordinary React. Call `retry` from an effect, render a fallback step, or count attempts in state.

### Retry with a delay

`<Retry>` below tries its children up to three times, waiting longer after each failure, and then renders its fallback, a step on another model:

```tsx
// answer.tsx
import { useEffect, useState, type ReactNode } from "react";
import { openai } from "june/sdk/openai";
import type { LanguageModel } from "ai";
import { Agent, Catch, Step, useStep } from "june";

function sleep(ms: number, signal: AbortSignal) {
  return new Promise<null>((resolve, reject) => {
    const timer = setTimeout(resolve, ms, null);
    signal.addEventListener("abort", () => {
      clearTimeout(timer);
      reject(signal.reason);
    });
  });
}

// The wait is a step, so the run stays open until it's over.
function RetryAfter({
  id,
  ms,
  retry,
}: {
  id: string;
  ms: number;
  retry: () => void;
}) {
  useStep(id, (signal) => sleep(ms, signal), { cacheKey: String(ms) });
  useEffect(() => retry(), [retry]);
  return null;
}

export function Retry({
  id,
  attempts,
  delayMs,
  fallback,
  children,
}: {
  id: string;
  attempts: number;
  delayMs: number;
  fallback: (error: unknown) => ReactNode;
  children: ReactNode;
}) {
  const [attempt, setAttempt] = useState(1);

  return (
    <Catch
      fallback={(error, retry) => {
        if (attempt >= attempts) return fallback(error);
        return (
          <RetryAfter
            id={`${id}-wait-${attempt}`}
            ms={delayMs * 2 ** (attempt - 1)}
            retry={() => {
              setAttempt(attempt + 1);
              retry();
            }}
          />
        );
      }}
    >
      {children}
    </Catch>
  );
}

export default function Answer({
  question,
  model = openai("gpt-5-mini"),
  fallbackModel = openai("gpt-5-nano"),
  delayMs = 1_000,
}: {
  question: string;
  model?: LanguageModel;
  fallbackModel?: LanguageModel;
  delayMs?: number;
}) {
  return (
    <Retry
      id="answer"
      attempts={3}
      delayMs={delayMs}
      fallback={() => (
        <Agent model={fallbackModel}>
          <Step id="fallback-answer" prompt={question} />
        </Agent>
      )}
    >
      <Agent model={model}>
        <Step id="answer" prompt={question} />
      </Agent>
    </Retry>
  );
}
```

- The wait is a [`useStep`](https://june.dev/docs/workflows/use-step.md#waiting). A plain `setTimeout` in an effect wouldn't keep the run open: with no step in flight, the run would settle before the timer fired. The wait holds a concurrency slot while it runs, and its result, `null`, is in `steps` as `answer-wait-1`.
- Each wait has its own id, so the second wait doesn't reuse the first one's result.
- `attempt` lives in `<Retry>`, above the `<Catch>`, because the fallback unmounts on every retry.

## Next

- [Approvals](https://june.dev/docs/workflows/approvals.md): pause a step until a person answers.
- [Snapshots and resume](https://june.dev/docs/workflows/resume.md): reuse finished steps after a crash or restart.
