deer-flow/frontend/src/core/api/api-client.ts
Jholly 8121dbcb57
fix(frontend): prevent run creation retries (#4725)
* fix(frontend): prevent run creation retries

* test(frontend): cover safe run stream reconnection

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-09-25 21:46:34 +08:00

645 lines
22 KiB
TypeScript

"use client";
import {
Client as LangGraphClient,
RunsClient,
} from "@langchain/langgraph-sdk/client";
import { getLangGraphBaseURL } from "../config";
import { isStaticWebsiteOnly } from "../static-mode";
import {
loadStaticDemoThread,
loadStaticDemoThreads,
staticDemoThreadState,
} from "../threads/static-demo";
import type { AgentThreadState } from "../threads/types";
import { isStateChangingMethod, readCsrfCookie } from "./fetcher";
import { forceChatRunStreamOptions } from "./stream-mode";
/**
* SDK ``onRequest`` hook that mints the ``X-CSRF-Token`` header from the
* live ``csrf_token`` cookie just before each outbound fetch.
*
* Reading the cookie per-request (rather than baking it into the SDK's
* ``defaultHeaders`` at construction) handles login / logout / password
* change cookie rotation transparently. Both the ``/api/langgraph/*`` SDK
* path and the direct REST endpoints in ``fetcher.ts:fetchWithAuth``
* share :func:`readCsrfCookie` and :const:`STATE_CHANGING_METHODS` so
* the contract stays in lockstep.
*/
function injectCsrfHeader(_url: URL, init: RequestInit): RequestInit {
if (!isStateChangingMethod(init.method ?? "GET")) {
return init;
}
const token = readCsrfCookie();
if (!token) return init;
const headers = new Headers(init.headers);
if (!headers.has("X-CSRF-Token")) {
headers.set("X-CSRF-Token", token);
}
return { ...init, headers };
}
// Run statuses that have reached a terminal state where no further streaming
// is possible. Reconnecting (``joinStream``) to such a run either 409s or, once
// the backend's in-memory stream bridge is reaped (``worker.py`` calls
// ``publish_end`` unconditionally, including for interrupted runs, then reaps
// the bridge after 60s), blocks forever on a drained condition variable —
// pinning ``isLoading`` true so the submit button stays a stop button and the
// first message after a reload never sends. The ``joinStream`` wrapper below
// short-circuits these *before* joining.
//
// ``interrupted`` is included because in DeerFlow it is only ever written by
// ``RunManager.cancel()`` (a user-initiated stop); the resumable human-in-the-
// loop path uses ``Command(goto=END)`` (``ClarificationMiddleware``), which
// ends the run as ``success``, not ``interrupted``. So an interrupted run has
// nothing left to stream — its state lives in the checkpoint, fetched
// independently by ``useThreadHistory``, and resuming means a fresh ``submit``.
//
// ``error``/``timeout`` are terminal too, so a reload within the ~60s
// bridge-reap window no longer replays the buffered error event through
// ``onError`` — the transient error toast (``getStreamErrorMessage``) is
// dropped. The persisted error state still loads from the checkpoint via
// ``useThreadHistory``, so only the toast is lost; that is intentional, since
// surfacing a stale error toast on every reload is noise rather than signal.
const TERMINAL_RUN_STATUSES = new Set([
"success",
"error",
"timeout",
"interrupted",
]);
// This is a rejoin budget: the original stream is not counted, so exhausting
// five recovery attempts can consume six streams in total.
const MAX_STREAM_GAP_RECOVERIES = 5;
export type StreamReplayGapData = {
code: "stream_replay_gap";
run_id: string;
requested_event_id: string | null;
earliest_available_event_id: string | null;
latest_available_event_id: string | null;
recovery: "reload_durable_state";
};
type StreamPart = {
id?: string;
event: string;
data: unknown;
};
type ReconnectInputSnapshot = Record<string, unknown> & {
messages: unknown[];
};
function streamOptionSignal(options: unknown): AbortSignal | undefined {
if (typeof AbortSignal === "undefined") {
return undefined;
}
if (options instanceof AbortSignal) {
return options;
}
if (typeof options !== "object" || options === null) {
return undefined;
}
const signal = Reflect.get(options, "signal");
return signal instanceof AbortSignal ? signal : undefined;
}
/**
* Recover the submitted input before replaying an active run. The incremental
* chat stream intentionally omits `values`, so a page reload can otherwise
* receive the run's AI/tool chunks before its human message has reached the
* durable history feed. `runs.get` retains the original graph input in
* `kwargs.input`; merge it into the latest durable values for one synthetic
* snapshot. Any read failure is deliberately ignored so reconnect semantics
* remain unchanged for deployments without run metadata.
*/
async function loadReconnectInputSnapshot(
client: LangGraphClient,
threadId: string,
runId: string,
run?: Awaited<ReturnType<LangGraphClient["runs"]["get"]>>,
durableValues?: unknown,
signal?: AbortSignal,
): Promise<ReconnectInputSnapshot | undefined> {
try {
const resolvedRun =
run ?? (await client.runs.get(threadId, runId, { signal }));
const runKwargs = Reflect.get(resolvedRun, "kwargs");
const input =
typeof runKwargs === "object" && runKwargs !== null
? Reflect.get(runKwargs, "input")
: undefined;
const inputMessages =
typeof input === "object" && input !== null
? Reflect.get(input, "messages")
: undefined;
if (!Array.isArray(inputMessages) || inputMessages.length === 0) {
return undefined;
}
const resolvedDurableValues =
durableValues ??
(await client.threads.getState(threadId, undefined, { signal })).values;
const normalizedDurableValues =
typeof resolvedDurableValues === "object" &&
resolvedDurableValues !== null
? resolvedDurableValues
: {};
const durableMessages = Array.isArray(
Reflect.get(normalizedDurableValues, "messages"),
)
? (Reflect.get(normalizedDurableValues, "messages") as unknown[])
: [];
const seenIds = new Set(
durableMessages.flatMap((message) => {
const id =
typeof message === "object" && message !== null
? Reflect.get(message, "id")
: undefined;
return typeof id === "string" && id.length > 0 ? [id] : [];
}),
);
const messages = [
...durableMessages,
...inputMessages.filter((message) => {
const id =
typeof message === "object" && message !== null
? Reflect.get(message, "id")
: undefined;
if (typeof id !== "string" || id.length === 0) return true;
if (seenIds.has(id)) return false;
seenIds.add(id);
return true;
}),
];
return { ...normalizedDurableValues, messages } as ReconnectInputSnapshot;
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
throw error;
}
return undefined;
}
}
export class StreamReplayGapError extends Error {
constructor(
readonly gap: StreamReplayGapData,
readonly recoveryAttempts: number,
readonly recoveryCause?: unknown,
) {
super(
`Unable to recover SSE history after ${recoveryAttempts} attempts (requested ${gap.requested_event_id ?? "initial stream"}, earliest ${gap.earliest_available_event_id ?? "none"})`,
);
this.name = "StreamReplayGapError";
}
}
function parseStreamReplayGap(data: unknown): StreamReplayGapData {
if (typeof data !== "object" || data === null) {
throw new Error("Invalid stream replay gap payload.");
}
const value = data as Record<string, unknown>;
const requestedEventId = value.requested_event_id;
const earliestAvailableEventId = value.earliest_available_event_id;
const latestAvailableEventId = value.latest_available_event_id;
if (
value.code !== "stream_replay_gap" ||
typeof value.run_id !== "string" ||
(requestedEventId !== null && typeof requestedEventId !== "string") ||
(earliestAvailableEventId !== null &&
typeof earliestAvailableEventId !== "string") ||
(latestAvailableEventId !== null &&
typeof latestAvailableEventId !== "string") ||
value.recovery !== "reload_durable_state"
) {
throw new Error("Invalid stream replay gap payload.");
}
return value as StreamReplayGapData;
}
/**
* Shared matcher for the gateway's 409 conflict responses. The SDK surfaces
* non-2xx responses as ``HTTPError { status, message }`` where ``message`` is
* ``"HTTP 409: {\"detail\":\"...\"}"``, so a 409 may be detected either via the
* numeric ``status`` or a substring of ``message``.
*
* Every passed ``needles`` substring must be present; this AND semantics is what
* lets a caller distinguish sibling conflict branches by phrase (e.g. the
* terminal-state cancel branch from the still-active-on-another-worker branch).
*
* Match strings until the API exposes a structured error code; the source of
* truth is ``_cancel_conflict_detail`` / the store-only response in
* ``backend/app/gateway/routers/thread_runs.py``.
*/
function isRunConflictError(error: unknown, ...needles: string[]): boolean {
const status =
typeof error === "object" && error !== null
? Reflect.get(error, "status")
: undefined;
const message =
typeof error === "string"
? error
: error instanceof Error
? error.message
: typeof error === "object" && error !== null
? String(Reflect.get(error, "message") ?? "")
: "";
return (
(status === 409 || message.includes("HTTP 409")) &&
needles.every((needle) => message.includes(needle))
);
}
// Store-only run cannot be streamed (no in-memory stream bridge on this
// worker): reconnect has nothing to rejoin.
export function isInactiveRunStreamError(error: unknown): boolean {
return isRunConflictError(
error,
"not active on this worker",
"cannot be streamed",
);
}
/**
* Matches the gateway's terminal-state cancel conflict, raised by
* ``_cancel_conflict_detail`` in ``backend/app/gateway/routers/thread_runs.py``
* as ``Run X is not cancellable (status: success|error|timeout)`` when
* ``RunManager.cancel`` refuses a run that already finished.
*
* The sibling ``"not active on this worker and cannot be cancelled"`` branch
* (run still pending/running on another worker in a multi-instance deploy) is
* intentionally NOT matched — that is a real cancel failure on a live run and
* must stay visible. Only the terminal-state branch is a true no-op.
*/
export function isRunNotCancellableError(error: unknown): boolean {
return isRunConflictError(error, "is not cancellable");
}
/**
* Preflight a reconnect and return the run record when it can be read. A
* missing record or failed request returns ``undefined`` so a legitimately
* active reconnect falls back to the original join and the terminal-state
* check remains owned by the caller.
*
* Any error (404 for an evicted record, network blip, auth hiccup, …) falls
* back to the original join so a legitimately active reconnect is never
* silently suppressed.
*/
async function getReconnectRun(
client: LangGraphClient,
threadId: string,
runId: string,
signal?: AbortSignal,
): Promise<Awaited<ReturnType<LangGraphClient["runs"]["get"]>> | undefined> {
try {
return await client.runs.get(threadId, runId, { signal });
} catch {
return undefined;
}
}
export function clearReconnectRun(
threadId: string | null | undefined,
runId: string,
): void {
if (typeof window === "undefined" || !threadId) return;
const key = `lg:stream:${threadId}`;
try {
const storage = window.sessionStorage;
if (storage.getItem(key) === runId) {
storage.removeItem(key);
}
} catch {
// Ignore storage access failures so reconnect cleanup never throws.
}
}
function rememberReconnectRun(
threadId: string | null | undefined,
runId: string,
): void {
if (typeof window === "undefined" || !threadId) return;
try {
window.sessionStorage.setItem(`lg:stream:${threadId}`, runId);
} catch {
// Ignore storage access failures so gap recovery remains usable.
}
}
async function* recoverStreamReplayGaps({
client,
threadId,
expectedRunId,
initialStream,
resume,
signal,
reconnectRun,
}: {
client: LangGraphClient;
threadId: string | null | undefined;
expectedRunId: () => string | undefined;
initialStream: AsyncIterable<StreamPart>;
resume: (runId: string, lastEventId?: string) => AsyncIterable<StreamPart>;
signal?: AbortSignal;
reconnectRun?: Awaited<ReturnType<LangGraphClient["runs"]["get"]>>;
}): AsyncGenerator<StreamPart> {
let stream = initialStream;
let recoveryAttempts = 0;
while (true) {
let gap: StreamReplayGapData | undefined;
for await (const entry of stream) {
if (entry.event === "gap") {
gap = parseStreamReplayGap(entry.data);
break;
}
yield entry;
}
if (!gap) {
return;
}
const runId = expectedRunId() ?? gap.run_id;
if (!threadId || gap.run_id !== runId) {
throw new Error(
"Stream replay gap does not match the active thread run.",
);
}
if (recoveryAttempts >= MAX_STREAM_GAP_RECOVERIES) {
throw new StreamReplayGapError(gap, recoveryAttempts);
}
recoveryAttempts += 1;
// The SDK would otherwise ignore an unknown `gap` event and report a
// normal finish. Surface a custom control event to DeerFlow's hook, reload
// durable values, then resume after the retained tail when it exists
// (or rejoin without a cursor if the buffer is empty).
clearReconnectRun(threadId, runId);
yield {
event: "custom",
data: { type: "stream_replay_gap", ...gap },
};
const durableState = await client.threads
.getState(threadId, undefined, { signal })
.catch((error: unknown) => {
if (error instanceof Error && error.name === "AbortError") {
throw error;
}
throw new StreamReplayGapError(gap, recoveryAttempts, error);
});
if (durableState.values != null) {
// A gap can arrive after the initial hydration frame but before the
// input reaches the checkpoint. Rebuild the snapshot from run metadata
// so this recovery path cannot overwrite the rescued human message.
const recoveredSnapshot = reconnectRun
? await loadReconnectInputSnapshot(
client,
threadId,
runId,
reconnectRun,
durableState.values,
signal,
)
: undefined;
yield {
event: "values",
data: recoveredSnapshot ?? durableState.values,
};
}
rememberReconnectRun(threadId, runId);
stream = resume(runId, gap.latest_available_event_id ?? undefined);
}
}
async function* handleInactiveRunStream({
threadId,
expectedRunId,
stream,
}: {
threadId: string | null | undefined;
expectedRunId: () => string | undefined;
stream: AsyncIterable<StreamPart>;
}): AsyncGenerator<StreamPart> {
try {
yield* stream;
} catch (error) {
const runId = expectedRunId();
if (runId && isInactiveRunStreamError(error)) {
clearReconnectRun(threadId, runId);
return;
}
throw error;
}
}
// Reuse the SDK's retry budget, backoff and HTTP error handling for recovery
// requests that have already been prepared by the run-creation client.
class StreamRecoveryClient extends RunsClient {
fetchWithRetries(...args: Parameters<typeof fetch>): Promise<Response> {
return this.asyncCaller.fetch(...args);
}
}
function createCompatibleClient(isMock?: boolean): LangGraphClient {
if (isStaticWebsiteOnly() && !isMock) {
return createStaticClient();
}
const apiUrl = getLangGraphBaseURL(isMock);
const client = new LangGraphClient({
apiUrl,
onRequest: injectCsrfHeader,
});
// Creating a run is not idempotent. Retrying an ambiguous gateway failure
// can create the same run more than once after the backend accepted the
// original request. The SDK also uses this client's transport for recovery
// GETs, which must retain normal HTTP retries (including transient 5xx).
const streamRecoveryClient = new StreamRecoveryClient({ apiUrl });
const runCreationClient = new RunsClient({
apiUrl,
callerOptions: {
maxRetries: 0,
fetch: (...args: Parameters<typeof fetch>) =>
args[1]?.method === "GET"
? streamRecoveryClient.fetchWithRetries(...args)
: fetch(...args),
},
onRequest: injectCsrfHeader,
});
const originalRunStream = runCreationClient.stream.bind(runCreationClient);
const originalJoinStream = client.runs.joinStream.bind(client.runs);
// Preserve the SDK's lazy AsyncIterable contract. Its StreamManager consumes
// this return value with `for await`, so run creation still starts on first
// iteration rather than when `runs.stream()` is called.
client.runs.stream = async function* (threadId, assistantId, payload) {
const sanitizedPayload = forceChatRunStreamOptions(payload);
const originalOnRunCreated = sanitizedPayload?.onRunCreated;
let runId: string | undefined;
const initialStream = originalRunStream(threadId, assistantId, {
...sanitizedPayload,
onRunCreated(meta) {
runId = meta.run_id;
originalOnRunCreated?.(meta);
},
});
const recoveredStream = recoverStreamReplayGaps({
client,
threadId,
expectedRunId: () => runId,
initialStream,
signal: streamOptionSignal(sanitizedPayload),
reconnectRun: undefined,
resume: (resolvedRunId, lastEventId) => {
// Keep the recovery run id available to the shared inactive-stream
// handler even if the SDK omitted its onRunCreated callback.
runId = resolvedRunId;
return originalJoinStream(threadId, resolvedRunId, {
lastEventId,
signal: sanitizedPayload?.signal,
streamMode: sanitizedPayload?.streamMode,
});
},
});
yield* handleInactiveRunStream({
threadId,
expectedRunId: () => runId,
stream: recoveredStream,
});
} as typeof client.runs.stream;
const originalCancel = client.runs.cancel.bind(client.runs);
client.runs.cancel = (async (threadId, runId, wait, action, options) => {
try {
return await originalCancel(threadId, runId, wait, action, options);
} catch (error) {
if (isRunNotCancellableError(error)) {
// The run already reached a terminal state, so cancelling it is a
// no-op. Swallow the 409 so a stop click during the finish window
// (backend flipped to ``success`` but the SSE stream hasn't drained)
// doesn't surface as an unhandled rejection, and clear the now-stale
// reconnect key. clearReconnectRun only removes the key when it still
// matches this runId, so a newer run's key is never touched.
clearReconnectRun(threadId, runId);
return;
}
throw error;
}
}) as typeof client.runs.cancel;
client.runs.joinStream = async function* (threadId, runId, options) {
// Short-circuit reconnects to runs that have already finished: otherwise a
// reload after the backend's stream bridge is reaped blocks forever on a
// drained condition variable, pinning ``isLoading`` true so the first
// post-reload message is routed to ``stop`` instead of ``submit``.
const reconnectSignal = streamOptionSignal(options);
const reconnectRun = threadId
? await getReconnectRun(client, threadId, runId, reconnectSignal)
: undefined;
if (reconnectRun && TERMINAL_RUN_STATUSES.has(reconnectRun.status)) {
clearReconnectRun(threadId, runId);
return;
}
if (threadId && reconnectRun) {
const reconnectSnapshot = await loadReconnectInputSnapshot(
client,
threadId,
runId,
reconnectRun,
undefined,
reconnectSignal,
);
if (reconnectSnapshot) {
// This is an internal hydration frame. The requested network stream
// remains incremental; the SDK receives the current input before any
// replayed messages-tuple AI/tool chunks and deduplicates it against
// later history or stream copies by message id.
yield { event: "values", data: reconnectSnapshot };
}
}
const sanitizedOptions = forceChatRunStreamOptions(options);
yield* handleInactiveRunStream({
threadId,
expectedRunId: () => runId,
stream: recoverStreamReplayGaps({
client,
threadId,
expectedRunId: () => runId,
initialStream: originalJoinStream(threadId, runId, sanitizedOptions),
signal: reconnectSignal,
reconnectRun,
resume: (resolvedRunId, lastEventId) =>
originalJoinStream(threadId, resolvedRunId, {
...sanitizedOptions,
lastEventId,
}),
}),
});
} as typeof client.runs.joinStream;
return client;
}
function createStaticClient(): LangGraphClient {
const apiUrl =
typeof window === "undefined"
? "http://localhost:3000"
: window.location.origin;
const client = new LangGraphClient({ apiUrl });
client.threads.search = (async (query) => {
return loadStaticDemoThreads(query);
}) as typeof client.threads.search;
client.threads.get = (async (threadId) => {
return loadStaticDemoThread(threadId);
}) as typeof client.threads.get;
client.threads.getState = (async (threadId) => {
return staticDemoThreadState(await loadStaticDemoThread(threadId));
}) as typeof client.threads.getState;
client.threads.getHistory = (async (threadId) => {
return [staticDemoThreadState(await loadStaticDemoThread(threadId))];
}) as typeof client.threads.getHistory;
client.threads.update = (async (threadId) => {
return loadStaticDemoThread(threadId);
}) as typeof client.threads.update;
client.runs.list = (async () => []) as typeof client.runs.list;
client.runs.stream = async function* () {
/* empty */
} as typeof client.runs.stream;
client.runs.joinStream = async function* () {
/* empty */
} as typeof client.runs.joinStream;
return client as LangGraphClient<AgentThreadState>;
}
const _clients = new Map<string, LangGraphClient>();
export function getAPIClient(isMock?: boolean): LangGraphClient {
const cacheKey = isMock ? "mock" : "default";
let client = _clients.get(cacheKey);
if (!client) {
client = createCompatibleClient(isMock);
_clients.set(cacheKey, client);
}
return client;
}