diff --git a/docs/non-compaction-retry-policy.md b/docs/non-compaction-retry-policy.md index 6e97d5b5d..d795865d2 100644 --- a/docs/non-compaction-retry-policy.md +++ b/docs/non-compaction-retry-policy.md @@ -41,6 +41,7 @@ Current retryable inputs are regex/string-classified: - rate limit / usage limit / too many requests - HTTP-like server classes: 429, 500, 502, 503, 504 - service unavailable / server/internal error +- provider-suggested retry wording, including OpenAI `retry your request` failures - network/connection/socket failures, refused/closed connections, upstream connect/reset-before-headers, socket hang up, timeout/timed out, fetch failed, terminated, retry delay wording, and unexpected socket close messages This is string-pattern classification, not typed provider error codes. diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 8aec02120..ccb06eb3b 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -6148,10 +6148,11 @@ export class AgentSession { #isTransientTransportErrorMessage(errorMessage: string): boolean { // Match: overloaded_error, provider returned error, rate limit, 429, 500, 502, 503, 504, - // service unavailable, network/connection/socket errors, fetch failed, terminated, retry delay exceeded + // service unavailable, provider-suggested retry, network/connection/socket errors, fetch failed, + // terminated, retry delay exceeded return ( isUnexpectedSocketCloseMessage(errorMessage) || - /overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|network.?error|connection.?error|connection.?refused|other side closed|fetch failed|upstream.?connect|reset before headers|socket hang up|timed? out|timeout|terminated|retry delay|stream stall|no error details in response/i.test( + /overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|retry your request|network.?error|connection.?error|connection.?refused|other side closed|fetch failed|upstream.?connect|reset before headers|socket hang up|timed? out|timeout|terminated|retry delay|stream stall|no error details in response/i.test( errorMessage, ) ); diff --git a/packages/coding-agent/test/agent-session-retry-fallback.test.ts b/packages/coding-agent/test/agent-session-retry-fallback.test.ts index dc0cb1e83..025cc8ecf 100644 --- a/packages/coding-agent/test/agent-session-retry-fallback.test.ts +++ b/packages/coding-agent/test/agent-session-retry-fallback.test.ts @@ -346,6 +346,92 @@ describe("AgentSession retry fallback", () => { expect(lastAssistant.content).toContainEqual({ type: "text", text: "Recovered after OpenAI timeout" }); }); + it("auto-retries OpenAI processing-request transient errors", async () => { + const model = getBundledModel("openai", "gpt-4o-mini"); + if (!model) { + throw new Error("Expected bundled OpenAI test model to exist"); + } + + const processingError = + "An error occurred while processing your request. You can retry your request, or contact us through our help center at help.openai.com if the error persists. Please include the request ID 4a4c6b73-a07c-4de0-aaaf-82560f9f626a in your message."; + const requestedModels: string[] = []; + let attemptCount = 0; + + const agent = new Agent({ + getApiKey: provider => `${provider}-test-key`, + initialState: { + model, + systemPrompt: ["Test"], + tools: [], + messages: [], + }, + streamFn: requestedModel => { + requestedModels.push(`${requestedModel.provider}/${requestedModel.id}`); + const stream = new MockAssistantStream(); + queueMicrotask(() => { + attemptCount += 1; + if (attemptCount === 1) { + const message = createAssistantMessage(requestedModel, { + stopReason: "error", + errorMessage: processingError, + }); + stream.push({ type: "start", partial: message }); + stream.push({ type: "error", reason: "error", error: message }); + return; + } + if (attemptCount === 2) { + const message = createAssistantMessage(requestedModel, { + text: "Recovered after OpenAI processing error", + stopReason: "stop", + }); + stream.push({ + type: "start", + partial: createAssistantMessage(requestedModel, { text: "", stopReason: "stop" }), + }); + stream.push({ type: "done", reason: "stop", message }); + return; + } + throw new Error(`Unexpected retry attempt in processing-error test: ${attemptCount}`); + }); + return stream; + }, + }); + + const settings = Settings.isolated({ + "compaction.enabled": false, + "retry.baseDelayMs": 5, + "retry.maxRetries": 1, + }); + settings.setModelRole("default", `${model.provider}/${model.id}`); + + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings, + modelRegistry, + }); + const { retryStartEvents, retryEndEvents } = trackRetryEvents(session); + + await session.prompt("Retry OpenAI processing-request error"); + await session.waitForIdle(); + + expect(requestedModels).toEqual([`${model.provider}/${model.id}`, `${model.provider}/${model.id}`]); + expect(retryStartEvents).toHaveLength(1); + expect(retryStartEvents[0]).toMatchObject({ + attempt: 1, + maxAttempts: 1, + errorMessage: processingError, + }); + expect(retryEndEvents).toHaveLength(1); + expect(retryEndEvents[0]).toMatchObject({ success: true, attempt: 1 }); + const lastAssistant = getLastAssistantMessage(session); + expect(lastAssistant.stopReason).toBe("stop"); + expect(lastAssistant.content).toContainEqual({ + type: "text", + text: "Recovered after OpenAI processing error", + }); + }); + it("auto-retries Anthropic stream-envelope failures before message_start", async () => { const model = getBundledModel("anthropic", "claude-sonnet-4-5"); if (!model) {