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.
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
maxRetriestimes (2 by default) before the step fails. SetmaxRetrieson the<Agent>. - A
useStepfunction 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. 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:
fallback(error, retry)renders in place of all its children.erroris exactly what was thrown, such as the AI SDK'sAPICallError.- Steps still running under it are aborted, unless they're also rendered outside it, and their approval requests are withdrawn. The run doesn't wait for them.
- Steps that already finished under it keep their results, in
stepsand 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. 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. Steps that finished aren't repeated; they keep their results. The same holds across a restart, where a snapshot reuses finished steps by their ids and cache keys 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:
// 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. A plainsetTimeoutin 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 instepsasanswer-wait-1. - Each wait has its own id, so the second wait doesn't reuse the first one's result.
attemptlives in<Retry>, above the<Catch>, because the fallback unmounts on every retry.
Next
- Approvals: pause a step until a person answers.
- Snapshots and resume: reuse finished steps after a crash or restart.