retry.test.ts 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273
  1. import { describe, expect, test } from "bun:test"
  2. import type { NamedError } from "@opencode-ai/util/error"
  3. import { APICallError } from "ai"
  4. import { setTimeout as sleep } from "node:timers/promises"
  5. import { Effect, Schedule } from "effect"
  6. import { SessionRetry } from "../../src/session/retry"
  7. import { MessageV2 } from "../../src/session/message-v2"
  8. import { ProviderID } from "../../src/provider/schema"
  9. import { AppRuntime } from "../../src/effect/app-runtime"
  10. import { SessionID } from "../../src/session/schema"
  11. import { SessionStatus } from "../../src/session/status"
  12. import { Instance } from "../../src/project/instance"
  13. import { tmpdir } from "../fixture/fixture"
  14. const providerID = ProviderID.make("test")
  15. function apiError(headers?: Record<string, string>): MessageV2.APIError {
  16. return new MessageV2.APIError({
  17. message: "boom",
  18. isRetryable: true,
  19. responseHeaders: headers,
  20. }).toObject() as MessageV2.APIError
  21. }
  22. function wrap(message: unknown): ReturnType<NamedError["toObject"]> {
  23. return { data: { message } } as ReturnType<NamedError["toObject"]>
  24. }
  25. describe("session.retry.delay", () => {
  26. test("caps delay at 30 seconds when headers missing", () => {
  27. const error = apiError()
  28. const delays = Array.from({ length: 10 }, (_, index) => SessionRetry.delay(index + 1, error))
  29. expect(delays).toStrictEqual([2000, 4000, 8000, 16000, 30000, 30000, 30000, 30000, 30000, 30000])
  30. })
  31. test("prefers retry-after-ms when shorter than exponential", () => {
  32. const error = apiError({ "retry-after-ms": "1500" })
  33. expect(SessionRetry.delay(4, error)).toBe(1500)
  34. })
  35. test("uses retry-after seconds when reasonable", () => {
  36. const error = apiError({ "retry-after": "30" })
  37. expect(SessionRetry.delay(3, error)).toBe(30000)
  38. })
  39. test("accepts http-date retry-after values", () => {
  40. const date = new Date(Date.now() + 20000).toUTCString()
  41. const error = apiError({ "retry-after": date })
  42. const d = SessionRetry.delay(1, error)
  43. expect(d).toBeGreaterThanOrEqual(19000)
  44. expect(d).toBeLessThanOrEqual(20000)
  45. })
  46. test("ignores invalid retry hints", () => {
  47. const error = apiError({ "retry-after": "not-a-number" })
  48. expect(SessionRetry.delay(1, error)).toBe(2000)
  49. })
  50. test("ignores malformed date retry hints", () => {
  51. const error = apiError({ "retry-after": "Invalid Date String" })
  52. expect(SessionRetry.delay(1, error)).toBe(2000)
  53. })
  54. test("ignores past date retry hints", () => {
  55. const pastDate = new Date(Date.now() - 5000).toUTCString()
  56. const error = apiError({ "retry-after": pastDate })
  57. expect(SessionRetry.delay(1, error)).toBe(2000)
  58. })
  59. test("uses retry-after values even when exceeding 10 minutes with headers", () => {
  60. const error = apiError({ "retry-after": "50" })
  61. expect(SessionRetry.delay(1, error)).toBe(50000)
  62. const longError = apiError({ "retry-after-ms": "700000" })
  63. expect(SessionRetry.delay(1, longError)).toBe(700000)
  64. })
  65. test("caps oversized header delays to the runtime timer limit", () => {
  66. const error = apiError({ "retry-after-ms": "999999999999" })
  67. expect(SessionRetry.delay(1, error)).toBe(SessionRetry.RETRY_MAX_DELAY)
  68. })
  69. test("policy updates retry status and increments attempts", async () => {
  70. await using tmp = await tmpdir()
  71. await Instance.provide({
  72. directory: tmp.path,
  73. fn: async () => {
  74. const sessionID = SessionID.make("session-retry-test")
  75. const error = apiError({ "retry-after-ms": "0" })
  76. await Effect.runPromise(
  77. Effect.gen(function* () {
  78. const step = yield* Schedule.toStepWithMetadata(
  79. SessionRetry.policy({
  80. parse: (err) => err as MessageV2.APIError,
  81. set: (info) =>
  82. Effect.promise(() =>
  83. AppRuntime.runPromise(
  84. SessionStatus.Service.use((svc) =>
  85. svc.set(sessionID, {
  86. type: "retry",
  87. attempt: info.attempt,
  88. message: info.message,
  89. next: info.next,
  90. }),
  91. ),
  92. ),
  93. ),
  94. }),
  95. )
  96. yield* step(error)
  97. yield* step(error)
  98. }),
  99. )
  100. expect(await AppRuntime.runPromise(SessionStatus.Service.use((svc) => svc.get(sessionID)))).toMatchObject({
  101. type: "retry",
  102. attempt: 2,
  103. message: "boom",
  104. })
  105. },
  106. })
  107. })
  108. })
  109. describe("session.retry.retryable", () => {
  110. test("maps too_many_requests json messages", () => {
  111. const error = wrap(JSON.stringify({ type: "error", error: { type: "too_many_requests" } }))
  112. expect(SessionRetry.retryable(error)).toBe("Too Many Requests")
  113. })
  114. test("maps overloaded provider codes", () => {
  115. const error = wrap(JSON.stringify({ code: "resource_exhausted" }))
  116. expect(SessionRetry.retryable(error)).toBe("Provider is overloaded")
  117. })
  118. test("does not retry unknown json messages", () => {
  119. const error = wrap(JSON.stringify({ error: { message: "no_kv_space" } }))
  120. expect(SessionRetry.retryable(error)).toBeUndefined()
  121. })
  122. test("does not throw on numeric error codes", () => {
  123. const error = wrap(JSON.stringify({ type: "error", error: { code: 123 } }))
  124. const result = SessionRetry.retryable(error)
  125. expect(result).toBeUndefined()
  126. })
  127. test("returns undefined for non-json message", () => {
  128. const error = wrap("not-json")
  129. expect(SessionRetry.retryable(error)).toBeUndefined()
  130. })
  131. test("retries plain text rate limit errors from Alibaba", () => {
  132. const msg =
  133. "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."
  134. const error = wrap(msg)
  135. expect(SessionRetry.retryable(error)).toBe(msg)
  136. })
  137. test("retries plain text rate limit errors", () => {
  138. const msg = "Rate limit exceeded, please try again later"
  139. const error = wrap(msg)
  140. expect(SessionRetry.retryable(error)).toBe(msg)
  141. })
  142. test("retries too many requests in plain text", () => {
  143. const msg = "Too many requests, please slow down"
  144. const error = wrap(msg)
  145. expect(SessionRetry.retryable(error)).toBe(msg)
  146. })
  147. test("does not retry context overflow errors", () => {
  148. const error = new MessageV2.ContextOverflowError({
  149. message: "Input exceeds context window of this model",
  150. responseBody: '{"error":{"code":"context_length_exceeded"}}',
  151. }).toObject() as ReturnType<NamedError["toObject"]>
  152. expect(SessionRetry.retryable(error)).toBeUndefined()
  153. })
  154. test("retries ZlibError decompression failures", () => {
  155. const error = new MessageV2.APIError({
  156. message: "Response decompression failed",
  157. isRetryable: true,
  158. metadata: { code: "ZlibError" },
  159. }).toObject() as MessageV2.APIError
  160. const retryable = SessionRetry.retryable(error)
  161. expect(retryable).toBeDefined()
  162. expect(retryable).toBe("Response decompression failed")
  163. })
  164. })
  165. describe("session.message-v2.fromError", () => {
  166. test.concurrent(
  167. "converts ECONNRESET socket errors to retryable APIError",
  168. async () => {
  169. using server = Bun.serve({
  170. port: 0,
  171. idleTimeout: 8,
  172. async fetch(req) {
  173. return new Response(
  174. new ReadableStream({
  175. async pull(controller) {
  176. controller.enqueue("Hello,")
  177. await sleep(10000)
  178. controller.enqueue(" World!")
  179. controller.close()
  180. },
  181. }),
  182. { headers: { "Content-Type": "text/plain" } },
  183. )
  184. },
  185. })
  186. const error = await fetch(new URL("/", server.url.origin))
  187. .then((res) => res.text())
  188. .catch((e) => e)
  189. const result = MessageV2.fromError(error, { providerID })
  190. expect(MessageV2.APIError.isInstance(result)).toBe(true)
  191. expect((result as MessageV2.APIError).data.isRetryable).toBe(true)
  192. expect((result as MessageV2.APIError).data.message).toBe("Connection reset by server")
  193. expect((result as MessageV2.APIError).data.metadata?.code).toBe("ECONNRESET")
  194. expect((result as MessageV2.APIError).data.metadata?.message).toInclude("socket connection")
  195. },
  196. 15_000,
  197. )
  198. test("ECONNRESET socket error is retryable", () => {
  199. const error = new MessageV2.APIError({
  200. message: "Connection reset by server",
  201. isRetryable: true,
  202. metadata: { code: "ECONNRESET", message: "The socket connection was closed unexpectedly" },
  203. }).toObject() as MessageV2.APIError
  204. const retryable = SessionRetry.retryable(error)
  205. expect(retryable).toBeDefined()
  206. expect(retryable).toBe("Connection reset by server")
  207. })
  208. // kilocode_change start
  209. test("ECONNREFUSED socket error is retryable", () => {
  210. const result = MessageV2.fromError(
  211. {
  212. code: "ECONNREFUSED",
  213. syscall: "connect",
  214. message: "connect ECONNREFUSED 127.0.0.1:3000",
  215. },
  216. { providerID: ProviderID.make("test") },
  217. ) as MessageV2.APIError
  218. expect(result.data.isRetryable).toBe(true)
  219. expect(result.data.message).toBe("Connection refused")
  220. expect(result.data.metadata?.code).toBe("ECONNREFUSED")
  221. })
  222. // kilocode_change end
  223. test("marks OpenAI 404 status codes as retryable", () => {
  224. const error = new APICallError({
  225. message: "boom",
  226. url: "https://api.openai.com/v1/chat/completions",
  227. requestBodyValues: {},
  228. statusCode: 404,
  229. responseHeaders: { "content-type": "application/json" },
  230. responseBody: '{"error":"boom"}',
  231. isRetryable: false,
  232. })
  233. const result = MessageV2.fromError(error, { providerID: ProviderID.make("openai") }) as MessageV2.APIError
  234. expect(result.data.isRetryable).toBe(true)
  235. })
  236. })