From ddf2619760bf596d0fc20dd31addee776a327321 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sun, 9 Aug 2026 23:38:40 -0500 Subject: [PATCH] fix(core): retry AI SDK transport failures --- packages/ai/src/provider-error.ts | 2 +- packages/core/src/aisdk.ts | 26 +++++++++++++++++++------- packages/core/test/aisdk.test.ts | 16 ++++++++++++++++ 3 files changed, 36 insertions(+), 8 deletions(-) diff --git a/packages/ai/src/provider-error.ts b/packages/ai/src/provider-error.ts index 3429000712..f86aeb66f3 100644 --- a/packages/ai/src/provider-error.ts +++ b/packages/ai/src/provider-error.ts @@ -86,7 +86,7 @@ export interface ProviderFailure { // Keep HTTP failures and provider-reported stream failures on one typed path so // session retry policy never needs provider-specific string matching. -export function classifyProviderFailure(input: ProviderFailure): AIError["reason"] { +export function classifyProviderFailure(input: ProviderFailure) { const body = input.http?.body ?? "" const codes = [input.code, ...providerCodes(body), ...providerCodes(input.message)] .filter((code): code is string => code !== undefined) diff --git a/packages/core/src/aisdk.ts b/packages/core/src/aisdk.ts index 5b6227e89e..b61c3cb1bb 100644 --- a/packages/core/src/aisdk.ts +++ b/packages/core/src/aisdk.ts @@ -23,6 +23,7 @@ import { LanguageModel, ProviderID, ProviderMetadata, + TransportReason, ToolResultValue, UnknownProviderReason, type ContentPart, @@ -725,13 +726,7 @@ function llmError(method: string, error: unknown) { error instanceof AIError ? new InvalidProviderOutputReason({ message: error.message }) : APICallError.isInstance(error) - ? RequestExecutor.classifyHttpFailure({ - message: unknownErrorMessage(error), - url: error.url, - status: error.statusCode, - responseHeaders: error.responseHeaders, - responseBody: error.responseBody, - }) + ? apiCallErrorReason(error) : new UnknownProviderReason({ message: unknownErrorMessage(error) }) return new AIError({ module: "AISDK", @@ -740,6 +735,23 @@ function llmError(method: string, error: unknown) { }) } +function apiCallErrorReason(error: APICallError) { + const reason = RequestExecutor.classifyHttpFailure({ + message: unknownErrorMessage(error), + url: error.url, + status: error.statusCode, + responseHeaders: error.responseHeaders, + responseBody: error.responseBody, + }) + if (error.statusCode !== undefined || !error.isRetryable) return reason + return new TransportReason({ + message: reason.message, + kind: error.name, + url: error.url, + http: reason.http, + }) +} + const ProviderErrorBody = Schema.Struct({ message: Schema.optionalKey(Schema.String), code: Schema.optionalKey(Schema.String), diff --git a/packages/core/test/aisdk.test.ts b/packages/core/test/aisdk.test.ts index f13e08a6a2..7685bfb601 100644 --- a/packages/core/test/aisdk.test.ts +++ b/packages/core/test/aisdk.test.ts @@ -1,6 +1,7 @@ import { APICallError } from "@ai-sdk/provider" import type { LanguageModelV3, LanguageModelV3StreamPart } from "@ai-sdk/provider" import { AISDK } from "@opencode-ai/core/aisdk" +import { SessionRunnerRetry } from "@opencode-ai/core/session/runner/retry" import { toSessionError } from "@opencode-ai/core/session/to-session-error" import { Model } from "@opencode-ai/core/model" import { Provider } from "@opencode-ai/core/provider" @@ -428,6 +429,20 @@ it.effect("classifies retryable AI SDK failures with retry-after details", () => }), ) +it.effect("retries status-less AI SDK transport failures", () => + Effect.gen(function* () { + const error = yield* streamFailure( + apiCallError({ + message: "Cannot connect to API: connection refused", + isRetryable: true, + }), + ) + expect(error.reason).toMatchObject({ _tag: "Transport", kind: "AI_APICallError" }) + expect(SessionRunnerRetry.isRetryable(error)).toBeTrue() + expect(toSessionError(error).http?.request.url).toBe("https://api.example.com/chat") + }), +) + it.effect("prefers a structured provider message over the code fallback", () => Effect.gen(function* () { const error = yield* streamFailure( @@ -445,6 +460,7 @@ it.effect("falls back to the status alone for malformed response bodies", () => const error = yield* streamFailure( apiCallError({ statusCode: 502, + isRetryable: false, responseBody: "Bad Gateway", }), )