retry.test.ts 15 KB

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