fix(runtime-fallback): honor retryable signal
This commit is contained in:
@@ -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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user