feat: abort/cancel support in tool-executor and retry
Deploy LangGraph Server to Azure Web App / build-and-deploy (push) Failing after 20s
Deploy LangGraph UI to Azure Static Web Apps / build-and-deploy (push) Failing after 38s

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
gongzhiyong
2026-04-12 14:33:47 +08:00
co-authored by Claude Sonnet 4.6
parent f375220eb6
commit d86da0e48b
2 changed files with 46 additions and 3 deletions
@@ -155,13 +155,29 @@ function hasArtifactForToolCall(
async function withTimeout<T>(
fn: (signal: AbortSignal) => Promise<T>,
timeoutMs: number,
externalSignal?: AbortSignal,
): Promise<T> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const timer = setTimeout(() => controller.abort(new Error("TimeoutError")), timeoutMs);
let onExternalAbort: (() => void) | undefined;
if (externalSignal) {
if (externalSignal.aborted) {
clearTimeout(timer);
controller.abort(new Error("RunCancelled"));
} else {
onExternalAbort = () => controller.abort(new Error("RunCancelled"));
externalSignal.addEventListener("abort", onExternalAbort, { once: true });
}
}
try {
return await fn(controller.signal);
} finally {
clearTimeout(timer);
if (onExternalAbort && externalSignal) {
externalSignal.removeEventListener("abort", onExternalAbort);
}
}
}
@@ -258,6 +274,14 @@ export async function toolExecutorNode(
): Promise<EnterpriseUpdate> {
const ui = typedUi<typeof ComponentMap>(config);
// ── Cancel guard: abort at node entry if run was already cancelled ─────
const runSignal = (config as unknown as { signal?: AbortSignal }).signal;
if (runSignal?.aborted) {
console.log(JSON.stringify({ event: "tool_executor_cancelled", reason: "aborted_at_entry" }));
return { ui: ui.items, timestamp: Date.now() };
}
// ────────────────────────────────────────────────────────────────────────
// Find the last AI message with tool_calls
const lastAiMessage = [...state.messages]
.reverse()
@@ -284,6 +308,14 @@ export async function toolExecutorNode(
// Execute all tool calls in parallel
const executions = toolCalls.map(async (tc) => {
if (runSignal?.aborted) {
return {
role: "tool" as const,
tool_call_id: tc.id ?? "",
content: JSON.stringify({ ok: false, cancelled: true }),
};
}
const name = tc.name;
const args = tc.args;
const id = tc.id ?? "";
@@ -339,6 +371,7 @@ export async function toolExecutorNode(
{ backoffMs: kbCfg.backoffMs, exponential: kbCfg.exponential, onAttempt: () => { kbRetryCount++; } },
),
kbCfg.timeoutMs,
runSignal,
);
} catch (kbError) {
// Push friendly error card, then try fallback to google_search
@@ -365,6 +398,7 @@ export async function toolExecutorNode(
const gData = await withTimeout(
() => googleSearch(parsed.query),
getToolConfig("google_search").timeoutMs,
runSignal,
);
const fallbackResults = gData.results.slice(0, 5).map((r) => ({
title: r.title,
@@ -546,6 +580,7 @@ export async function toolExecutorNode(
const data = await withTimeout(
() => executeWithRetry(() => ticketList(parsed.page ?? 1), tlCfg.maxRetries, { backoffMs: tlCfg.backoffMs, onAttempt: () => { tlRetryCount++; } }),
tlCfg.timeoutMs,
runSignal,
);
const tickets = (data.tickets ?? []).map((t) => ({
id: t.ticketNumber,
@@ -675,6 +710,7 @@ export async function toolExecutorNode(
const t = await withTimeout(
() => executeWithRetry(() => ticketDetail(parsed.ticket_id), tdCfg.maxRetries, { backoffMs: tdCfg.backoffMs, onAttempt: () => { tdRetryCount++; } }),
tdCfg.timeoutMs,
runSignal,
);
const execSummary = `获取工单 ${String(t.ticketNumber ?? parsed.ticket_id)} 详情`;
ui.push(
@@ -802,6 +838,7 @@ export async function toolExecutorNode(
const fallbackData = await withTimeout(
() => executeWithRetry(() => ticketList(1), getToolConfig("ticket_list").maxRetries),
getToolConfig("ticket_list").timeoutMs,
runSignal,
);
const fallbackTickets = (fallbackData.tickets ?? [])
.slice(0, 5)
+8 -2
View File
@@ -5,7 +5,7 @@
export async function executeWithRetry<T>(
fn: () => Promise<T>,
retries = 1,
options?: { backoffMs?: number; exponential?: boolean; onAttempt?: (attempt: number) => void },
options?: { backoffMs?: number; exponential?: boolean; onAttempt?: (attempt: number) => void; abortSignal?: AbortSignal },
): Promise<T> {
const backoffMs = options?.backoffMs ?? 1000;
const exponential = options?.exponential ?? false;
@@ -25,7 +25,13 @@ export async function executeWithRetry<T>(
console.warn(
`[retry] attempt ${attempt}/${maxAttempts} failed, retrying in ${delay}ms`,
);
await new Promise((r) => setTimeout(r, delay));
await new Promise<void>((resolve, reject) => {
const t = setTimeout(resolve, delay);
options?.abortSignal?.addEventListener("abort", () => {
clearTimeout(t);
reject(new Error("RunCancelled"));
}, { once: true });
});
}
}
}