diff --git a/src/hooks/runtime-fallback/ai-sdk-retryable-session-error.test.ts b/src/hooks/runtime-fallback/ai-sdk-retryable-session-error.test.ts new file mode 100644 index 000000000..46155c15d --- /dev/null +++ b/src/hooks/runtime-fallback/ai-sdk-retryable-session-error.test.ts @@ -0,0 +1,95 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" +import type { OhMyOpenCodeConfig, RuntimeFallbackConfig } from "../../config" +import { SessionCategoryRegistry } from "../../shared/session-category-registry" +import { releaseAllPromptAsyncReservationsForTesting } from "../shared/prompt-async-gate" +import { createRuntimeFallbackHook } from "./hook" +import type { RuntimeFallbackPluginInput } from "./types" + +describe("runtime-fallback AI SDK retryable session errors", () => { + afterEach(() => { + SessionCategoryRegistry.clear() + releaseAllPromptAsyncReservationsForTesting() + }) + + function createRuntimeFallbackConfig(): RuntimeFallbackConfig { + return { + enabled: true, + retry_on_errors: [429, 500, 502, 503, 504], + max_fallback_attempts: 3, + cooldown_seconds: 60, + notify_on_fallback: false, + } + } + + function createPluginConfig(): OhMyOpenCodeConfig { + return { + git_master: { + commit_footer: true, + include_co_authored_by: true, + git_env_prefix: "GIT_MASTER=1", + }, + categories: { + test: { + fallback_models: ["openai/gpt-5.4"], + }, + }, + } + } + + test("dispatches fallback for nested AI SDK retryable Cloudflare timeout errors", async () => { + //#given + const promptCalls: Array> = [] + const hook = createRuntimeFallbackHook( + unsafeTestValue({ + client: { + tui: { showToast: async () => ({}) }, + session: { + messages: async () => ({ + data: [{ info: { role: "user" }, parts: [{ type: "text", text: "continue" }] }], + }), + promptAsync: async (args: unknown) => { + promptCalls.push(args as Record) + return {} + }, + abort: async () => ({}), + }, + }, + directory: "/test/dir", + }), + { config: createRuntimeFallbackConfig(), pluginConfig: createPluginConfig() }, + ) + const sessionID = "test-session-ai-sdk-cloudflare-timeout" + SessionCategoryRegistry.register(sessionID, "test") + + await hook.event({ + event: { + type: "session.created", + properties: { info: { id: sessionID, model: "openai/gpt-5.5-fast" } }, + }, + }) + + //#when + await hook.event({ + event: { + type: "session.error", + properties: { + sessionID, + error: { + error: { + name: "AI_APICallError", + statusCode: 524, + isRetryable: true, + responseBody: "mengmota.com | 524: A timeout occurred", + }, + }, + }, + }, + }) + + //#then + expect(promptCalls).toHaveLength(1) + const promptBody = promptCalls[0]?.body as { model?: { providerID?: string; modelID?: string } } | undefined + expect(promptBody?.model).toEqual({ providerID: "openai", modelID: "gpt-5.4" }) + }) +}) diff --git a/src/hooks/runtime-fallback/error-classifier.test.ts b/src/hooks/runtime-fallback/error-classifier.test.ts index a7f43210a..fb311990e 100644 --- a/src/hooks/runtime-fallback/error-classifier.test.ts +++ b/src/hooks/runtime-fallback/error-classifier.test.ts @@ -74,6 +74,61 @@ describe("runtime-fallback error classifier", () => { expect(retryable).toEqual([true, true, true]) }) + test("treats nested AI SDK retryable Cloudflare timeout errors as retryable", () => { + //#given + const error = { + error: { + name: "AI_APICallError", + statusCode: 524, + isRetryable: true, + responseBody: "mengmota.com | 524: A timeout occurred", + }, + } + + //#when + const retryable = isRetryableError(error, [429, 500, 502, 503, 504]) + + //#then + expect(retryable).toBe(true) + }) + + test("treats retryable AI SDK errors without configured status codes as retryable", () => { + //#given + const error = { + data: { + error: { + name: "AI_APICallError", + isRetryable: true, + message: "connection reset before response body arrived", + }, + }, + } + + //#when + const retryable = isRetryableError(error, [429, 503, 529]) + + //#then + expect(retryable).toBe(true) + }) + + test("ignores malformed retryable flags on otherwise non-retryable errors", () => { + //#given + const error = { + error: { + name: "AI_APICallError", + statusCode: 400, + isRetryable: "true", + message: "Invalid request payload", + }, + } + + //#when + const retryable = isRetryableError(error, [429, 503, 529]) + + //#then + expect(retryable).toBe(false) + }) + test("classifies localized quota exhaustion messages as quota_exceeded", () => { //#given const errors = [ diff --git a/src/hooks/runtime-fallback/error-classifier.ts b/src/hooks/runtime-fallback/error-classifier.ts index 6ba155101..76c970e81 100644 --- a/src/hooks/runtime-fallback/error-classifier.ts +++ b/src/hooks/runtime-fallback/error-classifier.ts @@ -97,6 +97,28 @@ export function extractErrorName(error: unknown): string | undefined { return undefined } +export function extractRetryableSignal(error: unknown): boolean | undefined { + if (!error || typeof error !== "object") return undefined + + const errorObj = error as Record + const paths = [ + errorObj, + errorObj.data, + errorObj.error, + (errorObj.data as Record | undefined)?.error, + errorObj.cause, + ] + + for (const obj of paths) { + if (obj && typeof obj === "object") { + const retryable = (obj as Record).isRetryable + if (typeof retryable === "boolean") return retryable + } + } + + return undefined +} + function isLocalizedQuotaExhaustionMessage(message: string): boolean { return ( (/预扣费额度失败/i.test(message) && /用户剩余额度/i.test(message)) || @@ -199,5 +221,9 @@ export function isRetryableError(error: unknown, retryOnErrors: number[]): boole return true } + if (extractRetryableSignal(error) === true) { + return true + } + return RETRYABLE_ERROR_PATTERNS.some((pattern) => pattern.test(message)) }