Skip to content

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