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])
|
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", () => {
|
test("classifies localized quota exhaustion messages as quota_exceeded", () => {
|
||||||
//#given
|
//#given
|
||||||
const errors = [
|
const errors = [
|
||||||
|
|||||||
@@ -97,6 +97,28 @@ export function extractErrorName(error: unknown): string | undefined {
|
|||||||
return 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 {
|
function isLocalizedQuotaExhaustionMessage(message: string): boolean {
|
||||||
return (
|
return (
|
||||||
(/预扣费额度失败/i.test(message) && /用户剩余额度/i.test(message)) ||
|
(/预扣费额度失败/i.test(message) && /用户剩余额度/i.test(message)) ||
|
||||||
@@ -199,5 +221,9 @@ export function isRetryableError(error: unknown, retryOnErrors: number[]): boole
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (extractRetryableSignal(error) === true) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
return RETRYABLE_ERROR_PATTERNS.some((pattern) => pattern.test(message))
|
return RETRYABLE_ERROR_PATTERNS.some((pattern) => pattern.test(message))
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user