diff --git a/packages/opencode/src/provider/error.ts b/packages/opencode/src/provider/error.ts index 21149a2cf389..a0ab6e9ce94c 100644 --- a/packages/opencode/src/provider/error.ts +++ b/packages/opencode/src/provider/error.ts @@ -107,6 +107,15 @@ export function parseStreamError(input: unknown): ParsedStreamError | undefined const responseBody = JSON.stringify(body) if (body.type !== "error") return + if (body.code === "request_timeout") { + return { + type: "api_error", + message: typeof body.message === "string" ? body.message : "Request timed out.", + isRetryable: true, + responseBody, + } + } + switch (body?.error?.code) { case "context_length_exceeded": return { diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index 30ac879a6a9d..f76f582da791 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -437,4 +437,25 @@ describe("session.message-v2.fromError", () => { message: "An error occurred while processing your request.", }) }) + + test("converts top-level request_timeout stream chunks to retryable APIError", () => { + const result = MessageV2.fromError( + { + message: JSON.stringify({ + type: "error", + sequence_number: 0, + code: "request_timeout", + message: "stream error: stream disconnected before completion: stream closed before response.completed", + }), + }, + { providerID: ProviderV2.ID.make("cliproxyapi") }, + ) + + expect(SessionV1.APIError.isInstance(result)).toBe(true) + if (!SessionV1.APIError.isInstance(result)) throw new Error("expected APIError") + expect(result.data.isRetryable).toBe(true) + expect(SessionRetry.retryable(result, retryProvider)).toEqual({ + message: "stream error: stream disconnected before completion: stream closed before response.completed", + }) + }) })