fix(runtime-fallback): honor retryable signal

This commit is contained in:
YeonGyu-Kim
2026-05-25 00:03:29 +09:00
parent de7f1d887d
commit 222adeb606
3 changed files with 176 additions and 0 deletions
@@ -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<Record<string, unknown>> = []
const hook = createRuntimeFallbackHook(
unsafeTestValue<RuntimeFallbackPluginInput>({
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<string, unknown>)
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: "<title>mengmota.com | 524: A timeout occurred</title>",
},
},
},
},
})
//#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" })
})
})
@@ -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: "<title>mengmota.com | 524: A timeout occurred</title>",
},
}
//#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 = [
@@ -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<string, unknown>
const paths = [
errorObj,
errorObj.data,
errorObj.error,
(errorObj.data as Record<string, unknown> | undefined)?.error,
errorObj.cause,
]
for (const obj of paths) {
if (obj && typeof obj === "object") {
const retryable = (obj as Record<string, unknown>).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))
}