feat: say the cause of an api retry in the AIR warning details (#1594)

## Summary

The AIR warning for an SDK `api_retry` message had only a title, such as
`Retrying Claude, attempt 2 of 10.`. The user did not see why Claude
retries or how long the next attempt waits. The warning now carries
`details` with the cause and the wait.

## Changes

- `apiRetryWarning(message)` in `src/session-failure-extension.ts`
builds the title and the details. The title text does not change. The
`api_retry` case of `src/v1/acp-agent.ts` only calls it.
- The cause:
  - `HTTP 529 (overloaded).` when the CLI sends a status;
- `Connection error: no HTTP response.` for a null status with the
`unknown` kind;
- `No HTTP status (overloaded).` for a null status with a kind. The CLI
sends `overloaded` with no status for an SSE `overloaded_error` event
after HTTP 200, so "no HTTP response" would be wrong there.
- The wait: `Next attempt now.` for 0 ms (a `no_response` retry), `Next
attempt in 8s.`, and whole minutes from 120 s (a background rate-limit
wait), e.g. `Next attempt in 30m.`.
- The `no_response` wait stays in the title only.
- The kind label map has the type
`Record<Exclude<SDKAssistantMessageError, "unknown">, string>`, so a new
SDK kind fails typecheck. A missing `error` reads as `unknown`.
- The 401 skip, the actions and the terminal failures do not change. The
subagent retry path sends a `tool_progress` update and no session
failure, so it does not change. v2 has no `api_retry` session failure.

## Tests

- `src/tests/session-failure-extension.test.ts` (new): 0 ms, minutes,
the 119 s / 2 m boundary, a null status with `cloud_credential_error`,
and a missing `error`.
- `src/tests/v1/acp-agent.test.ts`: the warning details on the wire,
revision 2 replaces the details, and the terminal revision has none. The
test doubles use status and kind pairs and delays that the bundled CLI
really sends.
- `npm run build`, `npm run check`: clean.
- `npx vitest run src/tests/session-failure-extension.test.ts
src/tests/v1/acp-agent.test.ts`: 766 passed, 9 skipped.
- `npm run test:run`: 17 tests in `create-session-options.test.ts`,
`providers.test.ts` and `acp-v2.test.ts` fail on `main` without this
change too. They read the model list of the local environment.
This commit is contained in:
nikita-ashihmin
2026-11-11 14:19:41 +04:00
committed by GitHub
parent 51f514d73c
commit 4f690e129c
4 changed files with 293 additions and 17 deletions

View File

@@ -1,6 +1,7 @@
import type { ClientCapabilities, SessionNotification } from "@agentclientprotocol/sdk/unstable";
import {
getSessionMessages,
type SDKAPIRetryMessage,
type SDKAssistantMessageError,
USAGE_LIMIT_ERROR_PREFIXES,
} from "@anthropic-ai/claude-agent-sdk";
@@ -446,6 +447,84 @@ export class SessionFailureController {
}
}
/** Formats a wait in whole seconds, at least 1s: `8000` → `"8s"`. */
function retrySeconds(ms: number): string {
return `${Math.max(1, Math.round(ms / 1000))}s`;
}
/** The wait before the next attempt: `0` → `"now"`, `8000` → `"in 8s"`, and
* from 120 s whole minutes rounded up: `1_800_000` → `"in 30m"`. The CLI sends
* `0` for a `no_response` retry and minutes for a background rate-limit wait. */
function nextAttemptDelay(ms: number): string {
const seconds = Math.round(ms / 1000);
if (seconds <= 0) return "now";
if (seconds < 120) return `in ${seconds}s`;
return `in ${Math.ceil(ms / 60_000)}m`;
}
/** Plain-English names of the SDK error kinds. `unknown` has none: it adds
* nothing to the HTTP status or the connection error. */
const RETRY_ERROR_KIND_LABELS: Record<Exclude<SDKAssistantMessageError, "unknown">, string> = {
authentication_failed: "authentication failed",
oauth_org_not_allowed: "organization not allowed",
account_on_hold: "account on hold",
verification_required: "verification required",
billing_error: "billing error",
rate_limit: "rate limited",
overloaded: "overloaded",
invalid_request: "invalid request",
model_not_found: "model not found",
server_error: "server error",
max_output_tokens: "output token limit",
cloud_credential_error: "cloud credential error",
};
/** The `api_retry` message fields the warning reads. `error` is optional
* because an older CLI can omit it; the warning then treats it as `unknown`. */
type ApiRetryWarningInput = Pick<
SDKAPIRetryMessage,
"attempt" | "max_retries" | "error_status" | "retry_delay_ms" | "no_response"
> & { error?: SDKAssistantMessageError };
/** The cause of a retry. A null `error_status` with a kind is not always a
* lost connection: the CLI maps an SSE `overloaded_error` event after HTTP 200
* to `overloaded` with no status. */
function apiRetryCause(message: ApiRetryWarningInput): string {
const kind = message.error ?? "unknown";
const label =
kind === "unknown"
? undefined
: (RETRY_ERROR_KIND_LABELS[kind] ?? String(kind).replaceAll("_", " "));
if (message.error_status !== null) {
return `HTTP ${message.error_status}${label ? ` (${label})` : ""}.`;
}
return label ? `No HTTP status (${label}).` : "Connection error: no HTTP response.";
}
/** The title and the `details` of an `api_retry` warning. The title says the
* attempt and, for a `no_response` retry, how long the CLI waited and will
* wait. The details say the cause and the wait before the next attempt, e.g.
* "HTTP 529 (overloaded). Next attempt in 8s." The details do not repeat the
* `no_response` wait. */
export function apiRetryWarning(message: ApiRetryWarningInput): {
title: string;
details: string;
} {
// `no_response` (SDK 0.3.261+): the API sent no response headers
// within the first-byte window, so this retry waits longer for
// them. Say so — "attempt 1 of 1" alone reads like a final
// failure, and the wait is what the user is about to sit through.
const noResponse = message.no_response
? ` No response after ${retrySeconds(message.no_response.waited_ms)}; waiting up to ${retrySeconds(message.no_response.retry_wait_ms)}.`
: "";
const title =
message.error_status === null
? `Reconnecting to Claude, attempt ${message.attempt} of ${message.max_retries}.${noResponse}`
: `Retrying Claude, attempt ${message.attempt} of ${message.max_retries}.${noResponse}`;
const delay = nextAttemptDelay(message.retry_delay_ms);
return { title, details: `${apiRetryCause(message)} Next attempt ${delay}.` };
}
export function providerFailureCategory(
errorKind?: SDKAssistantMessageError,
isUsageLimit = false,

View File

@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { apiRetryWarning } from "../session-failure-extension.js";
describe("apiRetryWarning", () => {
// Each input is one the bundled CLI sends: the CLI derives `error` from the
// status (`IIt`), and an error with no HTTP response has a null status.
it.each([
{
name: "a no_response retry, which the CLI sends with a 0 ms delay",
message: {
error_status: null,
error: "unknown" as const,
retry_delay_ms: 0,
no_response: { waited_ms: 180_000, retry_wait_ms: 600_000 },
},
details: "Connection error: no HTTP response. Next attempt now.",
},
{
name: "a background rate-limit wait in minutes, rounded up",
message: { error_status: 429, error: "rate_limit" as const, retry_delay_ms: 1_740_500 },
details: "HTTP 429 (rate limited). Next attempt in 30m.",
},
{
name: "a wait just under 120 s in seconds",
message: { error_status: 529, error: "overloaded" as const, retry_delay_ms: 119_000 },
details: "HTTP 529 (overloaded). Next attempt in 119s.",
},
{
name: "a wait of 120 s in minutes",
message: { error_status: 529, error: "overloaded" as const, retry_delay_ms: 120_000 },
details: "HTTP 529 (overloaded). Next attempt in 2m.",
},
{
name: "a credential error, which has no HTTP status",
message: {
error_status: null,
error: "cloud_credential_error" as const,
retry_delay_ms: 1_100,
},
details: "No HTTP status (cloud credential error). Next attempt in 1s.",
},
{
name: "a message with no error kind as unknown",
message: { error_status: 503, error: undefined, retry_delay_ms: 600 },
details: "HTTP 503. Next attempt in 1s.",
},
])("describes $name", ({ message, details }) => {
expect(apiRetryWarning({ attempt: 1, max_retries: 10, ...message }).details).toBe(details);
});
});

View File

@@ -82,6 +82,7 @@ import {
PermissionUpdate,
query,
SDKAssistantMessage,
type SDKAssistantMessageError,
type Options,
type SDKControlGetUsageResponse,
} from "@anthropic-ai/claude-agent-sdk";
@@ -11900,7 +11901,7 @@ describe("stop reason propagation", () => {
subtype: "api_retry",
attempt: 1,
max_retries: 5,
retry_delay_ms: 100,
retry_delay_ms: 600,
error_status: 429,
error: "rate_limit",
uuid: randomUUID(),
@@ -11930,6 +11931,7 @@ describe("stop reason propagation", () => {
severity: "warning",
revision: 1,
title: "Retrying Claude, attempt 1 of 5.",
details: "HTTP 429 (rate limited). Next attempt in 1s.",
actions: [],
}),
);
@@ -11943,6 +11945,144 @@ describe("stop reason propagation", () => {
actions: ["retry"],
}),
);
expect(terminal).not.toHaveProperty("details");
});
it("keeps the api retries of one turn and its failure in one row before the echo", async () => {
const updates: SessionNotification[] = [];
const agent = new ClaudeAcpAgent(
{
sessionUpdate: async (update: SessionNotification) => updates.push(update),
} as unknown as AcpClient,
{ log: () => {}, error: () => {} },
);
await initializeClient(agent, airSessionFailureCapabilities);
const retry = (attempt: number) => ({
type: "system" as const,
subtype: "api_retry" as const,
attempt,
max_retries: 10,
retry_delay_ms: 1_000,
error_status: 503,
error: "server_error" as const,
uuid: randomUUID(),
session_id: "test-session",
});
const input = new Pushable<any>();
async function* cli() {
const iter = input[Symbol.asyncIterator]();
for (const turn of [1, 2]) {
const { value: userMessage } = await iter.next();
const echo = {
type: "user",
message: userMessage.message,
parent_tool_use_id: null,
uuid: userMessage.uuid,
session_id: "test-session",
isReplay: true,
};
// The CLI holds the echo back until the first assistant frame or
// stream event of the turn. A request that gets a 503 has neither, so
// its retries come before the echo.
if (turn === 1) {
yield retry(1);
yield retry(2);
yield echo;
yield createAssistantError("server_error", "API Error: 503");
yield createResultMessage({
subtype: "success",
stop_reason: "end_turn",
is_error: true,
result: "API Error: 503",
});
} else {
yield retry(1);
yield echo;
yield createResultMessage({
subtype: "success",
stop_reason: "end_turn",
is_error: false,
result: "ok",
});
}
}
}
agent.sessions["test-session"] = mockSessionState({ query: wrapQuery(cli()), input });
const first = await agent.prompt({
sessionId: "test-session",
prompt: [{ type: "text", text: "first" }],
});
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "second" }] });
const warnings = updates
.map((update) => (update.update._meta as any)?.jetbrains?.air?.sessionFailure)
.filter((failure) => failure?.severity === "warning");
expect(warnings.map((warning) => warning.revision)).toEqual([1, 2, 1]);
expect(warnings[1].id).toBe(warnings[0].id);
expect(warnings[0].id).toMatch(/:error$/);
expect(sessionFailureFromResponse(first)).toEqual(
expect.objectContaining({ id: warnings[0].id, revision: 3, severity: "error" }),
);
expect(warnings[2].id).not.toBe(warnings[0].id);
});
it("replaces the cause of an api retry warning on the next retry", async () => {
const updates: SessionNotification[] = [];
const agent = new ClaudeAcpAgent(
{
sessionUpdate: async (update: SessionNotification) => updates.push(update),
} as unknown as AcpClient,
{ log: () => {}, error: () => {} },
);
await initializeClient(agent, airSessionFailureCapabilities);
const retry = (
attempt: number,
error_status: number | null,
error: SDKAssistantMessageError,
retry_delay_ms: number,
) => ({
type: "system" as const,
subtype: "api_retry" as const,
attempt,
max_retries: 10,
retry_delay_ms,
error_status,
error,
uuid: randomUUID(),
session_id: "test-session",
});
injectSession(agent, [
retry(1, 529, "overloaded", 8_000),
// An SSE `overloaded_error` event after HTTP 200: the CLI reports
// `overloaded` with no status.
retry(2, null, "overloaded", 1_100),
createResultMessage({
subtype: "success",
stop_reason: "end_turn",
is_error: false,
result: "ok",
}),
]);
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] });
const warnings = updates
.map((update) => (update.update._meta as any)?.jetbrains?.air?.sessionFailure)
.filter(Boolean);
expect(warnings).toEqual([
expect.objectContaining({
revision: 1,
title: "Retrying Claude, attempt 1 of 10.",
details: "HTTP 529 (overloaded). Next attempt in 8s.",
}),
expect.objectContaining({
id: warnings[0].id,
revision: 2,
title: "Reconnecting to Claude, attempt 2 of 10.",
details: "No HTTP status (overloaded). Next attempt in 1s.",
}),
]);
});
it("says how long a no-response retry waited and will wait", async () => {
@@ -12006,7 +12146,7 @@ describe("stop reason propagation", () => {
subtype: "api_retry",
attempt: 2,
max_retries: 5,
retry_delay_ms: 100,
retry_delay_ms: 1_100,
error_status: null,
error: "unknown",
uuid: randomUUID(),
@@ -12588,7 +12728,7 @@ describe("stop reason propagation", () => {
subtype: "api_retry",
attempt: 1,
max_retries: 10,
retry_delay_ms: 100,
retry_delay_ms: 600,
error_status: 401,
error: "authentication_failed",
uuid: randomUUID(),

View File

@@ -229,6 +229,7 @@ import { decodeClaudePermissionResponse } from "../permissions/response.js";
import { SettingsManager } from "../settings.js";
import {
activeUsageLimitMessage,
apiRetryWarning,
airSessionFailureCapabilityMeta,
assistantMessageText,
type ClaudeFailureKind,
@@ -3605,12 +3606,17 @@ export class ClaudeAcpAgent {
kind: ClaudeFailureKind,
options: {
turnScoped?: boolean;
/** The turn the failure belongs to, when it is not `activeTurn`. */
turnId?: string;
title?: string;
details?: string;
severity?: "warning" | "error";
} = {},
) => {
const turnId = options.turnScoped === false ? undefined : session.activeTurn?.promptUuid;
const turnId =
options.turnScoped === false
? undefined
: (options.turnId ?? session.activeTurn?.promptUuid);
await sessionFailures.publish(kind, {
turnId,
sessionScoped: options.turnScoped === false,
@@ -5589,19 +5595,20 @@ export class ClaudeAcpAgent {
// here would outlive the `auth_required` rejection with no
// action to clear it (issue #1072).
if (kind === "auth_required") break;
// `no_response` (SDK 0.3.261+): the API sent no response headers
// within the first-byte window, so this retry waits longer for
// them. Say so — "attempt 1 of 1" alone reads like a final
// failure, and the wait is what the user is about to sit through.
const seconds = (ms: number) => `${Math.max(1, Math.round(ms / 1000))}s`;
const noResponse = message.no_response
? ` No response after ${seconds(message.no_response.waited_ms)}; waiting up to ${seconds(message.no_response.retry_wait_ms)}.`
: "";
const title =
message.error_status === null
? `Reconnecting to Claude, attempt ${message.attempt} of ${message.max_retries}.${noResponse}`
: `Retrying Claude, attempt ${message.attempt} of ${message.max_retries}.${noResponse}`;
await publishSessionFailure(kind, { title, severity: "warning" });
// The CLI holds the echo of a prompt back until the first
// assistant frame or stream event of the turn, so the retries
// of a request that gets no response come before the echo
// activates the turn. Give them the queue head, the prompt
// being sent: all retries and the terminal failure of one turn
// then share its `${turnId}:error` row.
const retryTurn =
session.activeTurn ??
(asyncTasks.autonomousTurnOpen ? undefined : firstUnsettledQueuedTurn());
await publishSessionFailure(kind, {
turnId: retryTurn?.promptUuid,
...apiRetryWarning(message),
severity: "warning",
});
break;
}
case "model_refusal_fallback": {