diff --git a/AGENTS.md b/AGENTS.md index d96c59f3..e8bdff27 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,6 +27,10 @@ read README.md, then ask which module(s) to work on. Based on the answer, read t ## GitHub Issues When reading issues: - Always read all comments on the issue +- Use this command to get everything in one call: + ```bash + gh issue view --json title,body,comments,labels,state + ``` When creating issues: - Add `pkg:*` labels to indicate which package(s) the issue affects diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 61cdfec3..8b71c329 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixed - Fixed OpenCode provider's `/v1` endpoint to use `system` role instead of `developer` role, fixing `400 Incorrect role information` error for models using `openai-completions` API ([#755](https://github.com/badlogic/pi-mono/pull/755) by [@melihmucuk](https://github.com/melihmucuk)) +- Added retry logic to OpenAI Codex provider for transient errors (429, 5xx, connection failures). Uses exponential backoff with up to 3 retries. ([#733](https://github.com/badlogic/pi-mono/issues/733)) ## [0.46.0] - 2026-01-15 diff --git a/packages/ai/src/providers/openai-codex-responses.ts b/packages/ai/src/providers/openai-codex-responses.ts index b05a3b81..bc481b66 100644 --- a/packages/ai/src/providers/openai-codex-responses.ts +++ b/packages/ai/src/providers/openai-codex-responses.ts @@ -30,6 +30,8 @@ import { transformMessages } from "./transform-messages.js"; const CODEX_URL = "https://chatgpt.com/backend-api/codex/responses"; const JWT_CLAIM_PATH = "https://api.openai.com/auth" as const; +const MAX_RETRIES = 3; +const BASE_DELAY_MS = 1000; // ============================================================================ // Types @@ -58,6 +60,31 @@ interface RequestBody { [key: string]: unknown; } +// ============================================================================ +// Retry Helpers +// ============================================================================ + +function isRetryableError(status: number, errorText: string): boolean { + if (status === 429 || status === 500 || status === 502 || status === 503 || status === 504) { + return true; + } + return /rate.?limit|overloaded|service.?unavailable|upstream.?connect|connection.?refused/i.test(errorText); +} + +function sleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new Error("Request was aborted")); + return; + } + const timeout = setTimeout(resolve, ms); + signal?.addEventListener("abort", () => { + clearTimeout(timeout); + reject(new Error("Request was aborted")); + }); + }); +} + // ============================================================================ // Main Stream Function // ============================================================================ @@ -97,17 +124,62 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses" const accountId = extractAccountId(apiKey); const body = buildRequestBody(model, context, options); const headers = buildHeaders(model.headers, accountId, apiKey, options?.sessionId); + const bodyJson = JSON.stringify(body); - const response = await fetch(CODEX_URL, { - method: "POST", - headers, - body: JSON.stringify(body), - signal: options?.signal, - }); + // Fetch with retry logic for rate limits and transient errors + let response: Response | undefined; + let lastError: Error | undefined; - if (!response.ok) { - const info = await parseErrorResponse(response); - throw new Error(info.friendlyMessage || info.message); + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + if (options?.signal?.aborted) { + throw new Error("Request was aborted"); + } + + try { + response = await fetch(CODEX_URL, { + method: "POST", + headers, + body: bodyJson, + signal: options?.signal, + }); + + if (response.ok) { + break; + } + + const errorText = await response.text(); + if (attempt < MAX_RETRIES && isRetryableError(response.status, errorText)) { + const delayMs = BASE_DELAY_MS * 2 ** attempt; + await sleep(delayMs, options?.signal); + continue; + } + + // Parse error for friendly message on final attempt or non-retryable error + const fakeResponse = new Response(errorText, { + status: response.status, + statusText: response.statusText, + }); + const info = await parseErrorResponse(fakeResponse); + throw new Error(info.friendlyMessage || info.message); + } catch (error) { + if (error instanceof Error) { + if (error.name === "AbortError" || error.message === "Request was aborted") { + throw new Error("Request was aborted"); + } + } + lastError = error instanceof Error ? error : new Error(String(error)); + // Network errors are retryable + if (attempt < MAX_RETRIES && !lastError.message.includes("usage limit")) { + const delayMs = BASE_DELAY_MS * 2 ** attempt; + await sleep(delayMs, options?.signal); + continue; + } + throw lastError; + } + } + + if (!response?.ok) { + throw lastError ?? new Error("Failed after retries"); } if (!response.body) { diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index e56363b0..59a0f050 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -14,6 +14,7 @@ ### Fixed - Session tree now preserves branch connectors and indentation when filters hide intermediate entries so descendants attach to the nearest visible ancestor and sibling branches align. Fixed in both TUI and HTML export ([#739](https://github.com/badlogic/pi-mono/pull/739) by [@w-winter](https://github.com/w-winter)) +- Added `upstream connect`, `connection refused`, and `reset before headers` patterns to auto-retry error detection ([#733](https://github.com/badlogic/pi-mono/issues/733)) ## [0.46.0] - 2026-01-15 diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 18fadeb1..61f9eba9 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -1584,8 +1584,8 @@ export class AgentSession { if (isContextOverflow(message, contextWindow)) return false; const err = message.errorMessage; - // Match: overloaded_error, rate limit, 429, 500, 502, 503, 504, service unavailable, connection error, other side closed, fetch failed - return /overloaded|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server error|internal error|connection.?error|other side closed|fetch failed/i.test( + // Match: overloaded_error, rate limit, 429, 500, 502, 503, 504, service unavailable, connection errors, fetch failed + return /overloaded|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server error|internal error|connection.?error|connection.?refused|other side closed|fetch failed|upstream.?connect|reset before headers/i.test( err, ); }