From 3556c27256156ffe4031c904b27622ddd513a9e9 Mon Sep 17 00:00:00 2001 From: zcy22606 <619730086@qq.com> Date: Tue, 13 Oct 2026 21:39:27 +0800 Subject: [PATCH] Show Claude Code's API retries as a status row while it retries Claude Code retries a failed API request itself (dropped connection, rate limit, overload) and prints system/api_retry for each attempt. The adapter dropped those lines, so a network outage looked like a turn that froze on "working" for minutes. Show one keyed status row naming the cause and the attempt, updated in place, and remove it once the request produces output, the turn ends, the session stops, or the process exits. --- .../harness/providers/claude/claude.ts | 23 +++++++++++ .../providers/claude/claudeLive.test.ts | 41 +++++++++++++++++++ .../providers/claude/claudeProtocol.test.ts | 35 ++++++++++++++++ .../providers/claude/claudeProtocol.ts | 25 +++++++++++ 4 files changed, 124 insertions(+) diff --git a/src/integrations/harness/providers/claude/claude.ts b/src/integrations/harness/providers/claude/claude.ts index e20185e47..71c340964 100644 --- a/src/integrations/harness/providers/claude/claude.ts +++ b/src/integrations/harness/providers/claude/claude.ts @@ -16,6 +16,7 @@ import { writeChild, } from "../../core/child"; import { + apiRetryStatusText, askUserQuestionAllowInput, asRecord, assistantMessageId, @@ -175,6 +176,8 @@ type Live = { pendingAssistantBoundary: boolean; manualCompaction: boolean; compactionConfirmed: boolean; + /** An API retry row is showing. */ + apiRetrying: boolean; }; type Resume = { @@ -203,6 +206,7 @@ const tasksByThread = new Map< >(); /** Task-list block key for TaskCreate/TaskUpdate items. */ const CLAUDE_TASKS_KEY = "claude-tasks"; +const API_RETRY_STATUS_KEY = "claude-api-retry"; const cancelledThreads = new Set(); let resolveClaudeBinaryImpl: () => Promise<{ path: string }> = @@ -357,6 +361,7 @@ export async function stopClaudeSession(sessionId: string): Promise { const live = liveByThread.get(sessionId); liveByThread.delete(sessionId); if (live) { + clearApiRetry(live); live.muteUpdates = true; for (const [, pending] of live.approvals) pending.resolve("deny"); live.approvals.clear(); @@ -512,6 +517,7 @@ async function ensureLive(input: HarnessSessionInput): Promise { pendingAssistantBoundary: false, manualCompaction: false, compactionConfirmed: false, + apiRetrying: false, }; liveRef.current = live; @@ -526,6 +532,7 @@ async function ensureLive(input: HarnessSessionInput): Promise { liveByThread.delete(input.sessionId); const current = liveRef.current; if (!current?.muteUpdates) { + if (current) clearApiRetry(current); (current?.onEvent ?? input.onEvent)({ type: "session.ended", code }); } current?.turnFailed?.(new Error("Claude Code exited")); @@ -698,6 +705,15 @@ function handleLine(sessionId: string, live: Live, line: string): void { return; } + const retry = apiRetryStatusText(rec); + if (retry !== undefined) { + live.apiRetrying = true; + live.onEvent({ type: "status", key: API_RETRY_STATUS_KEY, text: retry }); + return; + } + // Any output means the request went through. + if (type !== "system" && type !== "rate_limit_event") clearApiRetry(live); + if (live.manualCompaction && type !== "system" && type !== "result") { return; } @@ -1700,8 +1716,15 @@ function maybeFinishTurn(live: Live): void { ]); } +function clearApiRetry(live: Live): void { + if (!live.apiRetrying) return; + live.apiRetrying = false; + live.onEvent({ type: "status", key: API_RETRY_STATUS_KEY, text: "" }); +} + function finishActiveTurn(live: Live, extraEvents: HarnessEvent[] = []): void { clearAwaitingResume(live); + clearApiRetry(live); live.turnEndPending = false; live.activeTurn = false; for (const event of extraEvents) live.onEvent(event); diff --git a/src/integrations/harness/providers/claude/claudeLive.test.ts b/src/integrations/harness/providers/claude/claudeLive.test.ts index feedeb99b..3c544801e 100644 --- a/src/integrations/harness/providers/claude/claudeLive.test.ts +++ b/src/integrations/harness/providers/claude/claudeLive.test.ts @@ -347,6 +347,47 @@ describe("claude streamed tool inputs", () => { }); }); +describe("claude API retries", () => { + it("shows one retry row and clears it once the reply streams", async () => { + const { events, turn } = await startTurn("s1"); + for (const attempt of [1, 2]) { + emit({ + type: "system", + subtype: "api_retry", + session_id: "sess_1", + attempt, + max_retries: 10, + error_status: null, + }); + } + emit({ + type: "assistant", + session_id: "sess_1", + message: { content: [{ type: "text", text: "Back online." }] }, + }); + emit({ type: "result", subtype: "success", session_id: "sess_1" }); + await turn; + + expect( + events.filter( + (event) => event.type === "status" && event.key === "claude-api-retry", + ), + ).toEqual([ + { + type: "status", + key: "claude-api-retry", + text: "Connection error · retrying (1/10)…", + }, + { + type: "status", + key: "claude-api-retry", + text: "Connection error · retrying (2/10)…", + }, + { type: "status", key: "claude-api-retry", text: "" }, + ]); + }); +}); + describe("claude task tools", () => { function emitTaskTool( id: string, diff --git a/src/integrations/harness/providers/claude/claudeProtocol.test.ts b/src/integrations/harness/providers/claude/claudeProtocol.test.ts index 3602d92ce..3bd23610b 100644 --- a/src/integrations/harness/providers/claude/claudeProtocol.test.ts +++ b/src/integrations/harness/providers/claude/claudeProtocol.test.ts @@ -4,6 +4,7 @@ import { modelsFromClaudeListModels, } from "./claudeCatalog"; import { + apiRetryStatusText, applyClaudePromptEffortPrefix, applyClaudeTaskTool, askUserQuestionAllowInput, @@ -277,6 +278,40 @@ describe("stream mapping", () => { }); }); +describe("API retries", () => { + it("names the cause and the attempt", () => { + const retry = (extra: Record) => + apiRetryStatusText({ + type: "system", + subtype: "api_retry", + attempt: 7, + max_retries: 10, + ...extra, + }); + expect(retry({ error_status: null })).toBe( + "Connection error · retrying (7/10)…", + ); + expect(retry({ error: "overloaded", error_status: 529 })).toBe( + "API overloaded · retrying (7/10)…", + ); + expect(retry({ error_status: 429 })).toBe( + "Rate limited · retrying (7/10)…", + ); + expect(retry({ error_status: 500 })).toBe( + "API error 500 · retrying (7/10)…", + ); + }); + + it("ignores other system messages and incomplete retries", () => { + expect( + apiRetryStatusText({ type: "system", subtype: "status", attempt: 1 }), + ).toBeUndefined(); + expect( + apiRetryStatusText({ type: "system", subtype: "api_retry", attempt: 1 }), + ).toBeUndefined(); + }); +}); + describe("usage limits", () => { it("reads a refused window and when it resets", () => { expect( diff --git a/src/integrations/harness/providers/claude/claudeProtocol.ts b/src/integrations/harness/providers/claude/claudeProtocol.ts index e744af44a..de0c7d26c 100644 --- a/src/integrations/harness/providers/claude/claudeProtocol.ts +++ b/src/integrations/harness/providers/claude/claudeProtocol.ts @@ -477,6 +477,31 @@ export function statusTextFromSystem( return compact ? "Compacted context" : undefined; } +/** + * Claude Code retries a failed API request itself (dropped connection, rate + * limit, overload) and reports each attempt as `system/api_retry`. Without a + * row for it, minutes of retries read as a turn that froze. + */ +export function apiRetryStatusText( + rec: Record, +): string | undefined { + if (stringField(rec, "type") !== "system") return undefined; + if (stringField(rec, "subtype") !== "api_retry") return undefined; + const { attempt, max_retries: max } = rec; + if (typeof attempt !== "number" || typeof max !== "number") return undefined; + return `${apiRetryReason(rec)} · retrying (${attempt}/${max})…`; +} + +function apiRetryReason(rec: Record): string { + const status = typeof rec.error_status === "number" ? rec.error_status : null; + if (rec.no_response) return "No response from the API"; + if (rec.error === "overloaded" || status === 529) return "API overloaded"; + if (rec.error === "rate_limit" || status === 429) return "Rate limited"; + // No HTTP status at all: the request never got a response. + if (status === null) return "Connection error"; + return `API error ${status}`; +} + export function turnStatusFromResult(rec: Record): { status: "completed" | "failed" | "interrupted" | "cancelled"; error?: string;