Durable recovery

Last updated Oct 22, 2026 Think always wraps chat turns in recoverable fibers. If the Durable Object is evicted mid-stream, Think reconstructs any buffered chunks. It persists partial output and schedules a continuation or retry.

Note

Durable recovery works without configuration. Most apps never need to configure it. Use this page for provider-specific recovery, a stall watchdog, or terminal behavior.

WebSocket turns, sub-agent chat() turns, durable submitMessages() executions, automatic continuations, saveMessages(), and continueLastTurn() are wrapped in runFiber.

Bounded recovery

A stream-stall watchdog abort (chatStreamStallTimeoutMs) uses the same bounded recovery path. The SDK preserves the settled partial and schedules a continuation. A transient hang recovers automatically. A persistently hanging provider exhausts the budget through the same path as a deployment or eviction. The SDK calls onExhausted, emits chat:recovery:exhausted, and shows the configured terminalMessage.

Configure bounded recovery by setting chatRecovery to an object:

export class MyAgent extends Think {
    chatRecovery = {
        maxAttempts: 6,
        stableTimeoutMs: 10_000,
        terminalMessage: "The assistant was interrupted and could not recover.",
        async onExhausted(ctx) {
            console.warn("Chat recovery exhausted", ctx.incidentId);
        },
    };

getModel() {
        /* ... */
    }
}
export class MyAgent extends Think<Env> {
    override chatRecovery = {
        maxAttempts: 6,
        stableTimeoutMs: 10_000,
        terminalMessage: "The assistant was interrupted and could not recover.",
        async onExhausted(ctx) {
            console.warn("Chat recovery exhausted", ctx.incidentId);
        },
    };

getModel() {
        /* ... */
    }
}

The same recovery events are available through agents/observability on the chat channel; transcript repairs are emitted on the transcript channel. Refer to Diagnostics channels.

onChatRecovery

Override onChatRecovery when you need provider-specific recovery, such as retrieving a stored OpenAI Responses result instead of issuing a new model call:

export class MyAgent extends Think {
    chatRecovery = {
        maxAttempts: 10,
        terminalMessage: "The assistant was interrupted. Please try again.",
    };

async onChatRecovery(ctx) {
        console.log("Recovering chat turn", ctx.incidentId, ctx.attempt);
        return {}; // persist partial output and continue/retry when possible
    }
}
import type {
    ChatRecoveryContext,
    ChatRecoveryOptions,
} from "@cloudflare/think";

export class MyAgent extends Think<Env> {
    override chatRecovery = {
        maxAttempts: 10,
        terminalMessage: "The assistant was interrupted. Please try again.",
    };

override async onChatRecovery(
        ctx: ChatRecoveryContext,
    ): Promise<ChatRecoveryOptions> {
        console.log("Recovering chat turn", ctx.incidentId, ctx.attempt);
        return {}; // persist partial output and continue/retry when possible
    }
}

ChatRecoveryContext

Field Type Description
incidentId string Stable ID for this recovery incident
attempt number Current attempt number for this incident, starting at 1
maxAttempts number Configured attempt cap before terminal exhaustion
recoveryKind "retry" \| "continue" Whether recovery will retry an unanswered user turn or continue a partial assistant turn
streamId string The stream ID of the interrupted turn
requestId string The request ID of the interrupted turn
partialText string Text generated before the interruption
partialParts MessagePart[] Parts accumulated before the interruption
recoveryData unknown \| null Data from this.stash() during the turn
messages UIMessage[] Current conversation history
lastBody Record<string, unknown>? Body from the interrupted turn
lastClientTools ClientToolSchema[]? Client tools from the interrupted turn
createdAt number Epoch milliseconds when the turn started

ChatRecoveryOptions

Field Type Description
persist boolean? Whether to persist the partial assistant message
continue boolean? Whether to auto-continue with a new turn via continueLastTurn()

With persist: true, the partial message is saved. With continue: true, Think calls continueLastTurn() after the agent reaches a stable state.

For pre-stream interruptions, where ctx.streamId === "" and ctx.partialText === "" but the latest persisted message is still the unanswered user message, Think retries that turn automatically unless continue is false.

onChatRecovery(ctx: ChatRecoveryContext): ChatRecoveryOptions {
    if (!ctx.streamId && !ctx.partialText) {
        console.log("Recovering a pre-stream interruption");
    }
    return {};
}

Use ctx.createdAt to skip stale recoveries. For example, if the interrupted turn is older than a few minutes, return { continue: false } so the partial response is preserved without starting an old continuation.

Durable bookkeeping remains active when automatic continuation is not appropriate. Return { continue: false } to prevent another model call. For cancellation, side-effect, and cost controls, refer to Control automatic continuation.

Recovery budgets and limits

Assign a chatRecovery object to tune recovery limits and terminal behavior. A progressing turn survives repeated interruptions while it stays within the maxRecoveryWork limit and fewer than maxInterruptedAttempts of its attempts die. The following options control when recovery stops:

export class MyAgent extends Think {
    chatRecovery = {
        maxAttempts: 10,
        noProgressTimeoutMs: 5 * 60 * 1000,
        maxRecoveryWork: 10_000,
        maxInterruptedAttempts: 5,
        terminalMessage: "The assistant was interrupted and could not recover.",
        // Consulted from the second recovery attempt onward. Return false to stop.
        // Called as `config.shouldKeepRecovering(ctx)`, so it is NOT bound to the
        // agent instance — track real token/cost spend in your own store keyed by
        // `ctx.recoveryRootRequestId`.
        async shouldKeepRecovering(ctx) {
            return (await getSpendForTurn(ctx.recoveryRootRequestId)) < MAX_SPEND;
        },
        async onExhausted(ctx) {
            console.warn("Recovery exhausted", ctx.incidentId, ctx.reason);
        },
    };
}
export class MyAgent extends Think<Env> {
    override chatRecovery = {
        maxAttempts: 10,
        noProgressTimeoutMs: 5 * 60 * 1000,
        maxRecoveryWork: 10_000,
        maxInterruptedAttempts: 5,
        terminalMessage: "The assistant was interrupted and could not recover.",
        // Consulted from the second recovery attempt onward. Return false to stop.
        // Called as `config.shouldKeepRecovering(ctx)`, so it is NOT bound to the
        // agent instance — track real token/cost spend in your own store keyed by
        // `ctx.recoveryRootRequestId`.
        async shouldKeepRecovering(ctx) {
            return (await getSpendForTurn(ctx.recoveryRootRequestId)) < MAX_SPEND;
        },
        async onExhausted(ctx) {
            console.warn("Recovery exhausted", ctx.incidentId, ctx.reason);
        },
    };
}
Field Default Description
maxAttempts 10 Attempt cap. Resets on forward progress, so it catches a tight no-progress alarm loop, not a healthy long turn.
stableTimeoutMs 10_000 How long an attempt waits for the isolate to reach stable state before rescheduling.
noProgressTimeoutMs 300_000 (5 min) Primary stuck-turn bound: max time without forward progress before sealing. Resets on every progress-bearing attempt.
maxRecoveryWork 10,000 Runaway-loop guard: maximum produced content/tool units before a still-progressing turn is sealed. Set a higher value or Infinity for a long agentic turn.
maxInterruptedAttempts 5 How many of one turn's attempts may be interrupted before recovery gives up (interrupted_attempts_exceeded). An attempt is interrupted when its isolate dies before it finishes, for example in a Durable Object memory-limit reset or a deploy. The original turn counts, and progress never resets the count. Replaces maxOomRetries, which is still read as maxInterruptedAttempts: maxOomRetries + 1.
shouldKeepRecovering — Caller policy consulted from the second attempt onward. Return false to stop recovery. The hook point for a token/cost budget (ctx.work is a coarse segment count, not tokens).
terminalMessage generic message Message shown to the user when recovery is given up on.
onExhausted — Called once when recovery is given up on. Inspect ctx.reason.

ctx.reason on the exhausted hook is one of: no_progress_timeout (stuck), max_attempts_exceeded (no-progress alarm loop), work_budget_exceeded (runaway), interrupted_attempts_exceeded (the turn's attempts kept dying, for example in memory-limit resets), out_of_memory (sealed by the alarm memory-limit circuit breaker), recovery_aborted (your shouldKeepRecovering returned false), or stable_timeout (extreme churn). Refer to Stream recovery for the full shared reference. Think and @cloudflare/ai-chat use the same recovery configuration.

Repairing interrupted tool calls

When a turn is interrupted mid-flight, the transcript can contain a tool call with no settled result. Before the next provider call, Think repairs each such call so the model does not silently re-run it and the provider does not reject the transcript with AI_MissingToolResultsError. The default flips the interrupted call to an errored tool result, so the record survives and conversion still has a tool result for it.