retry.test.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430
  1. import { describe, expect, test } from "bun:test"
  2. import type { NamedError } from "@opencode-ai/core/util/error"
  3. import { APICallError } from "ai"
  4. import { setTimeout as sleep } from "node:timers/promises"
  5. import { Effect, Layer, Schedule, Schema } from "effect"
  6. import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
  7. import { SessionRetry } from "../../src/session/retry"
  8. import { MessageV2 } from "../../src/session/message-v2"
  9. import { ProviderID } from "../../src/provider/schema"
  10. import { ProviderError } from "../../src/provider/error"
  11. import { SessionID } from "../../src/session/schema"
  12. import { SessionStatus } from "../../src/session/status"
  13. import { provideTmpdirInstance } from "../fixture/fixture"
  14. import { testEffect } from "../lib/effect"
  15. const providerID = ProviderID.make("test")
  16. const retryProvider = "test"
  17. const it = testEffect(Layer.mergeAll(SessionStatus.defaultLayer, CrossSpawnSpawner.defaultLayer))
  18. function apiError(headers?: Record<string, string>): MessageV2.APIError {
  19. return Schema.decodeUnknownSync(MessageV2.APIError.Schema)(
  20. new MessageV2.APIError({
  21. message: "boom",
  22. isRetryable: true,
  23. responseHeaders: headers,
  24. }).toObject(),
  25. )
  26. }
  27. function wrap(message: unknown): ReturnType<NamedError["toObject"]> {
  28. return { name: "", data: { message } }
  29. }
  30. describe("session.retry.delay", () => {
  31. test("caps delay at 30 seconds when headers missing", () => {
  32. const error = apiError()
  33. const delays = Array.from({ length: 10 }, (_, index) => SessionRetry.delay(index + 1, error))
  34. expect(delays).toStrictEqual([2000, 4000, 8000, 16000, 30000, 30000, 30000, 30000, 30000, 30000])
  35. })
  36. test("prefers retry-after-ms when shorter than exponential", () => {
  37. const error = apiError({ "retry-after-ms": "1500" })
  38. expect(SessionRetry.delay(4, error)).toBe(1500)
  39. })
  40. test("uses retry-after seconds when reasonable", () => {
  41. const error = apiError({ "retry-after": "30" })
  42. expect(SessionRetry.delay(3, error)).toBe(30000)
  43. })
  44. test("accepts http-date retry-after values", () => {
  45. const date = new Date(Date.now() + 20000).toUTCString()
  46. const error = apiError({ "retry-after": date })
  47. const d = SessionRetry.delay(1, error)
  48. expect(d).toBeGreaterThanOrEqual(19000)
  49. expect(d).toBeLessThanOrEqual(20000)
  50. })
  51. test("ignores invalid retry hints", () => {
  52. const error = apiError({ "retry-after": "not-a-number" })
  53. expect(SessionRetry.delay(1, error)).toBe(2000)
  54. })
  55. test("ignores malformed date retry hints", () => {
  56. const error = apiError({ "retry-after": "Invalid Date String" })
  57. expect(SessionRetry.delay(1, error)).toBe(2000)
  58. })
  59. test("ignores past date retry hints", () => {
  60. const pastDate = new Date(Date.now() - 5000).toUTCString()
  61. const error = apiError({ "retry-after": pastDate })
  62. expect(SessionRetry.delay(1, error)).toBe(2000)
  63. })
  64. test("uses retry-after values even when exceeding 10 minutes with headers", () => {
  65. const error = apiError({ "retry-after": "50" })
  66. expect(SessionRetry.delay(1, error)).toBe(50000)
  67. const longError = apiError({ "retry-after-ms": "700000" })
  68. expect(SessionRetry.delay(1, longError)).toBe(700000)
  69. })
  70. test("caps oversized header delays to the runtime timer limit", () => {
  71. const error = apiError({ "retry-after-ms": "999999999999" })
  72. expect(SessionRetry.delay(1, error)).toBe(SessionRetry.RETRY_MAX_DELAY)
  73. })
  74. it.live("policy updates retry status and increments attempts", () =>
  75. provideTmpdirInstance(() =>
  76. Effect.gen(function* () {
  77. const sessionID = SessionID.make("session-retry-test")
  78. const error = apiError({ "retry-after-ms": "0" })
  79. const status = yield* SessionStatus.Service
  80. const step = yield* Schedule.toStepWithMetadata(
  81. SessionRetry.policy({
  82. provider: "test",
  83. parse: Schema.decodeUnknownSync(MessageV2.APIError.Schema),
  84. set: (info) =>
  85. status.set(sessionID, {
  86. type: "retry",
  87. attempt: info.attempt,
  88. message: info.message,
  89. next: info.next,
  90. }),
  91. }),
  92. )
  93. yield* step(error)
  94. yield* step(error)
  95. expect(yield* status.get(sessionID)).toMatchObject({
  96. type: "retry",
  97. attempt: 2,
  98. message: "boom",
  99. })
  100. }),
  101. ),
  102. )
  103. })
  104. describe("session.retry.retryable", () => {
  105. test("maps too_many_requests json messages", () => {
  106. const error = wrap(JSON.stringify({ type: "error", error: { type: "too_many_requests" } }))
  107. expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: "Too Many Requests" })
  108. })
  109. test("maps overloaded provider codes", () => {
  110. const error = wrap(JSON.stringify({ code: "resource_exhausted" }))
  111. expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: "Provider is overloaded" })
  112. })
  113. test("does not retry unknown json messages", () => {
  114. const error = wrap(JSON.stringify({ error: { message: "no_kv_space" } }))
  115. expect(SessionRetry.retryable(error, retryProvider)).toBeUndefined()
  116. })
  117. test("does not throw on numeric error codes", () => {
  118. const error = wrap(JSON.stringify({ type: "error", error: { code: 123 } }))
  119. const result = SessionRetry.retryable(error, retryProvider)
  120. expect(result).toBeUndefined()
  121. })
  122. test("returns undefined for non-json message", () => {
  123. const error = wrap("not-json")
  124. expect(SessionRetry.retryable(error, retryProvider)).toBeUndefined()
  125. })
  126. test("retries plain text rate limit errors from Alibaba", () => {
  127. const msg =
  128. "Upstream error from Alibaba: Request rate increased too quickly. To ensure system stability, please adjust your client logic to scale requests more smoothly over time."
  129. const error = wrap(msg)
  130. expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: msg })
  131. })
  132. test("retries plain text rate limit errors", () => {
  133. const msg = "Rate limit exceeded, please try again later"
  134. const error = wrap(msg)
  135. expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: msg })
  136. })
  137. test("retries too many requests in plain text", () => {
  138. const msg = "Too many requests, please slow down"
  139. const error = wrap(msg)
  140. expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: msg })
  141. })
  142. test("retries transport timeout errors", () => {
  143. const request = MessageV2.fromError(new ProviderError.HeaderTimeoutError(10000), { providerID })
  144. expect(MessageV2.APIError.isInstance(request)).toBe(true)
  145. expect(SessionRetry.retryable(request, retryProvider)).toEqual({
  146. message: "Provider response headers timed out after 10000ms",
  147. })
  148. })
  149. test("does not retry context overflow errors", () => {
  150. const error = new MessageV2.ContextOverflowError({
  151. message: "Input exceeds context window of this model",
  152. responseBody: '{"error":{"code":"context_length_exceeded"}}',
  153. }).toObject()
  154. expect(SessionRetry.retryable(error, retryProvider)).toBeUndefined()
  155. })
  156. test("retries 500 errors even when isRetryable is false", () => {
  157. const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)(
  158. new MessageV2.APIError({
  159. message: "Internal server error",
  160. isRetryable: false,
  161. statusCode: 500,
  162. responseBody: '{"type":"api_error","message":"Internal server error"}',
  163. }).toObject(),
  164. )
  165. expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: "Internal server error" })
  166. })
  167. test("retries 502 bad gateway errors", () => {
  168. const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)(
  169. new MessageV2.APIError({
  170. message: "Bad gateway",
  171. isRetryable: false,
  172. statusCode: 502,
  173. }).toObject(),
  174. )
  175. expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: "Bad gateway" })
  176. })
  177. test("retries 503 service unavailable errors", () => {
  178. const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)(
  179. new MessageV2.APIError({
  180. message: "Service unavailable",
  181. isRetryable: false,
  182. statusCode: 503,
  183. }).toObject(),
  184. )
  185. expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: "Service unavailable" })
  186. })
  187. test("does not retry 4xx errors when isRetryable is false", () => {
  188. const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)(
  189. new MessageV2.APIError({
  190. message: "Bad request",
  191. isRetryable: false,
  192. statusCode: 400,
  193. }).toObject(),
  194. )
  195. expect(SessionRetry.retryable(error, retryProvider)).toBeUndefined()
  196. })
  197. test("retries ZlibError decompression failures", () => {
  198. const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)(
  199. new MessageV2.APIError({
  200. message: "Response decompression failed",
  201. isRetryable: true,
  202. metadata: { code: "ZlibError" },
  203. }).toObject(),
  204. )
  205. const retryable = SessionRetry.retryable(error, retryProvider)
  206. expect(retryable).toBeDefined()
  207. expect(retryable).toEqual({ message: "Response decompression failed" })
  208. })
  209. test("maps free limits to Go upsell action", () => {
  210. const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)(
  211. new MessageV2.APIError({
  212. message: "Free usage exceeded",
  213. isRetryable: true,
  214. statusCode: 429,
  215. responseBody: JSON.stringify({
  216. type: "error",
  217. error: { type: "FreeUsageLimitError", message: "Free usage exceeded" },
  218. }),
  219. }).toObject(),
  220. )
  221. expect(SessionRetry.retryable(error, "opencode")).toEqual({
  222. message: SessionRetry.GO_UPSELL_MESSAGE,
  223. action: {
  224. reason: "free_tier_limit",
  225. provider: "opencode",
  226. title: "Free limit reached",
  227. message: "Subscribe to OpenCode Go for reliable access to the best open-source models, starting at $5/month.",
  228. label: "subscribe",
  229. link: SessionRetry.GO_UPSELL_URL,
  230. },
  231. })
  232. })
  233. test("maps Go subscription limits to workspace PAYG upsell", () => {
  234. const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)(
  235. new MessageV2.APIError({
  236. message: "Subscription quota exceeded. You can continue using free models.",
  237. isRetryable: true,
  238. statusCode: 429,
  239. responseHeaders: {
  240. "retry-after": "19380",
  241. },
  242. responseBody: JSON.stringify({
  243. type: "error",
  244. error: {
  245. type: "GoUsageLimitError",
  246. message: "Subscription quota exceeded. You can continue using free models.",
  247. },
  248. metadata: {
  249. workspace: "wrk_01K6XGM22R6FM8JVABE9XDQXGH",
  250. limitName: "5 hour",
  251. },
  252. }),
  253. }).toObject(),
  254. )
  255. expect(SessionRetry.retryable(error, "opencode-go")).toEqual({
  256. message:
  257. "5 hour usage limit reached. It will reset in 5 hours 23 minutes. To continue using this model now, enable usage from your available balance - https://opencode.ai/workspace/wrk_01K6XGM22R6FM8JVABE9XDQXGH/go",
  258. action: {
  259. reason: "account_rate_limit",
  260. provider: "opencode-go",
  261. title: "Go limit reached",
  262. message:
  263. "5 hour usage limit reached. It will reset in 5 hours 23 minutes. To continue using this model now, enable usage from your available balance",
  264. label: "open settings",
  265. link: "https://opencode.ai/workspace/wrk_01K6XGM22R6FM8JVABE9XDQXGH/go",
  266. },
  267. })
  268. })
  269. test("maps Go subscription limits without limit metadata", () => {
  270. const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)(
  271. new MessageV2.APIError({
  272. message: "Subscription quota exceeded. You can continue using free models.",
  273. isRetryable: true,
  274. statusCode: 429,
  275. responseHeaders: {
  276. "retry-after": "900",
  277. },
  278. responseBody: JSON.stringify({
  279. type: "error",
  280. error: {
  281. type: "GoUsageLimitError",
  282. message: "Subscription quota exceeded. You can continue using free models.",
  283. },
  284. metadata: {
  285. workspace: "wrk_01K6XGM22R6FM8JVABE9XDQXGH",
  286. },
  287. }),
  288. }).toObject(),
  289. )
  290. expect(SessionRetry.retryable(error, "opencode-go")?.action?.message).toBe(
  291. "Usage limit reached. It will reset in 15 minutes. To continue using this model now, enable usage from your available balance",
  292. )
  293. })
  294. })
  295. describe("session.message-v2.fromError", () => {
  296. test.concurrent(
  297. "converts ECONNRESET socket errors to retryable APIError",
  298. async () => {
  299. using server = Bun.serve({
  300. port: 0,
  301. idleTimeout: 8,
  302. async fetch(_req) {
  303. return new Response(
  304. new ReadableStream({
  305. async pull(controller) {
  306. controller.enqueue("Hello,")
  307. await sleep(10000)
  308. controller.enqueue(" World!")
  309. controller.close()
  310. },
  311. }),
  312. { headers: { "Content-Type": "text/plain" } },
  313. )
  314. },
  315. })
  316. const error = await fetch(new URL("/", server.url.origin))
  317. .then((res) => res.text())
  318. .catch((e) => e)
  319. const result = MessageV2.fromError(error, { providerID })
  320. expect(MessageV2.APIError.isInstance(result)).toBe(true)
  321. if (!MessageV2.APIError.isInstance(result)) throw new Error("expected APIError")
  322. expect(result.data.isRetryable).toBe(true)
  323. expect(result.data.message).toBe("Connection reset by server")
  324. expect(result.data.metadata?.code).toBe("ECONNRESET")
  325. expect(result.data.metadata?.message).toInclude("socket connection")
  326. },
  327. 15_000,
  328. )
  329. test("ECONNRESET socket error is retryable", () => {
  330. const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)(
  331. new MessageV2.APIError({
  332. message: "Connection reset by server",
  333. isRetryable: true,
  334. metadata: { code: "ECONNRESET", message: "The socket connection was closed unexpectedly" },
  335. }).toObject(),
  336. )
  337. const retryable = SessionRetry.retryable(error, retryProvider)
  338. expect(retryable).toBeDefined()
  339. expect(retryable).toEqual({ message: "Connection reset by server" })
  340. })
  341. test("marks OpenAI 404 status codes as retryable", () => {
  342. const error = new APICallError({
  343. message: "boom",
  344. url: "https://api.openai.com/v1/chat/completions",
  345. requestBodyValues: {},
  346. statusCode: 404,
  347. responseHeaders: { "content-type": "application/json" },
  348. responseBody: '{"error":"boom"}',
  349. isRetryable: false,
  350. })
  351. const result = MessageV2.fromError(error, { providerID: ProviderID.make("openai") })
  352. if (!MessageV2.APIError.isInstance(result)) throw new Error("expected APIError")
  353. expect(result.data.isRetryable).toBe(true)
  354. })
  355. test("converts OpenAI server_error stream chunks to retryable APIError", () => {
  356. const result = MessageV2.fromError(
  357. {
  358. message: JSON.stringify({
  359. type: "error",
  360. sequence_number: 2,
  361. error: {
  362. type: "server_error",
  363. code: "server_error",
  364. message: "An error occurred while processing your request.",
  365. param: null,
  366. },
  367. }),
  368. },
  369. { providerID: ProviderID.make("openai") },
  370. )
  371. expect(MessageV2.APIError.isInstance(result)).toBe(true)
  372. if (!MessageV2.APIError.isInstance(result)) throw new Error("expected APIError")
  373. expect(result.data.isRetryable).toBe(true)
  374. expect(SessionRetry.retryable(result, retryProvider)).toEqual({
  375. message: "An error occurred while processing your request.",
  376. })
  377. })
  378. })