retry.test.ts 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188
  1. import { describe, expect, test } from "bun:test"
  2. import type { NamedError } from "@opencode-ai/util/error"
  3. import { APICallError } from "ai"
  4. import { SessionRetry } from "../../src/session/retry"
  5. import { MessageV2 } from "../../src/session/message-v2"
  6. function apiError(headers?: Record<string, string>): MessageV2.APIError {
  7. return new MessageV2.APIError({
  8. message: "boom",
  9. isRetryable: true,
  10. responseHeaders: headers,
  11. }).toObject() as MessageV2.APIError
  12. }
  13. function wrap(message: unknown): ReturnType<NamedError["toObject"]> {
  14. return { data: { message } } as ReturnType<NamedError["toObject"]>
  15. }
  16. describe("session.retry.delay", () => {
  17. test("caps delay at 30 seconds when headers missing", () => {
  18. const error = apiError()
  19. const delays = Array.from({ length: 10 }, (_, index) => SessionRetry.delay(index + 1, error))
  20. expect(delays).toStrictEqual([2000, 4000, 8000, 16000, 30000, 30000, 30000, 30000, 30000, 30000])
  21. })
  22. test("prefers retry-after-ms when shorter than exponential", () => {
  23. const error = apiError({ "retry-after-ms": "1500" })
  24. expect(SessionRetry.delay(4, error)).toBe(1500)
  25. })
  26. test("uses retry-after seconds when reasonable", () => {
  27. const error = apiError({ "retry-after": "30" })
  28. expect(SessionRetry.delay(3, error)).toBe(30000)
  29. })
  30. test("accepts http-date retry-after values", () => {
  31. const date = new Date(Date.now() + 20000).toUTCString()
  32. const error = apiError({ "retry-after": date })
  33. const d = SessionRetry.delay(1, error)
  34. expect(d).toBeGreaterThanOrEqual(19000)
  35. expect(d).toBeLessThanOrEqual(20000)
  36. })
  37. test("ignores invalid retry hints", () => {
  38. const error = apiError({ "retry-after": "not-a-number" })
  39. expect(SessionRetry.delay(1, error)).toBe(2000)
  40. })
  41. test("ignores malformed date retry hints", () => {
  42. const error = apiError({ "retry-after": "Invalid Date String" })
  43. expect(SessionRetry.delay(1, error)).toBe(2000)
  44. })
  45. test("ignores past date retry hints", () => {
  46. const pastDate = new Date(Date.now() - 5000).toUTCString()
  47. const error = apiError({ "retry-after": pastDate })
  48. expect(SessionRetry.delay(1, error)).toBe(2000)
  49. })
  50. test("uses retry-after values even when exceeding 10 minutes with headers", () => {
  51. const error = apiError({ "retry-after": "50" })
  52. expect(SessionRetry.delay(1, error)).toBe(50000)
  53. const longError = apiError({ "retry-after-ms": "700000" })
  54. expect(SessionRetry.delay(1, longError)).toBe(700000)
  55. })
  56. test("sleep caps delay to max 32-bit signed integer to avoid TimeoutOverflowWarning", async () => {
  57. const controller = new AbortController()
  58. const warnings: string[] = []
  59. const originalWarn = process.emitWarning
  60. process.emitWarning = (warning: string | Error) => {
  61. warnings.push(typeof warning === "string" ? warning : warning.message)
  62. }
  63. const promise = SessionRetry.sleep(2_560_914_000, controller.signal)
  64. controller.abort()
  65. try {
  66. await promise
  67. } catch {}
  68. process.emitWarning = originalWarn
  69. expect(warnings.some((w) => w.includes("TimeoutOverflowWarning"))).toBe(false)
  70. })
  71. })
  72. describe("session.retry.retryable", () => {
  73. test("maps too_many_requests json messages", () => {
  74. const error = wrap(JSON.stringify({ type: "error", error: { type: "too_many_requests" } }))
  75. expect(SessionRetry.retryable(error)).toBe("Too Many Requests")
  76. })
  77. test("maps overloaded provider codes", () => {
  78. const error = wrap(JSON.stringify({ code: "resource_exhausted" }))
  79. expect(SessionRetry.retryable(error)).toBe("Provider is overloaded")
  80. })
  81. test("handles json messages without code", () => {
  82. const error = wrap(JSON.stringify({ error: { message: "no_kv_space" } }))
  83. expect(SessionRetry.retryable(error)).toBe(`{"error":{"message":"no_kv_space"}}`)
  84. })
  85. test("does not throw on numeric error codes", () => {
  86. const error = wrap(JSON.stringify({ type: "error", error: { code: 123 } }))
  87. const result = SessionRetry.retryable(error)
  88. expect(result).toBeUndefined()
  89. })
  90. test("returns undefined for non-json message", () => {
  91. const error = wrap("not-json")
  92. expect(SessionRetry.retryable(error)).toBeUndefined()
  93. })
  94. test("does not retry context overflow errors", () => {
  95. const error = new MessageV2.ContextOverflowError({
  96. message: "Input exceeds context window of this model",
  97. responseBody: '{"error":{"code":"context_length_exceeded"}}',
  98. }).toObject() as ReturnType<NamedError["toObject"]>
  99. expect(SessionRetry.retryable(error)).toBeUndefined()
  100. })
  101. })
  102. describe("session.message-v2.fromError", () => {
  103. test.concurrent(
  104. "converts ECONNRESET socket errors to retryable APIError",
  105. async () => {
  106. using server = Bun.serve({
  107. port: 0,
  108. idleTimeout: 8,
  109. async fetch(req) {
  110. return new Response(
  111. new ReadableStream({
  112. async pull(controller) {
  113. controller.enqueue("Hello,")
  114. await Bun.sleep(10000)
  115. controller.enqueue(" World!")
  116. controller.close()
  117. },
  118. }),
  119. { headers: { "Content-Type": "text/plain" } },
  120. )
  121. },
  122. })
  123. const error = await fetch(new URL("/", server.url.origin))
  124. .then((res) => res.text())
  125. .catch((e) => e)
  126. const result = MessageV2.fromError(error, { providerID: "test" })
  127. expect(MessageV2.APIError.isInstance(result)).toBe(true)
  128. expect((result as MessageV2.APIError).data.isRetryable).toBe(true)
  129. expect((result as MessageV2.APIError).data.message).toBe("Connection reset by server")
  130. expect((result as MessageV2.APIError).data.metadata?.code).toBe("ECONNRESET")
  131. expect((result as MessageV2.APIError).data.metadata?.message).toInclude("socket connection")
  132. },
  133. 15_000,
  134. )
  135. test("ECONNRESET socket error is retryable", () => {
  136. const error = new MessageV2.APIError({
  137. message: "Connection reset by server",
  138. isRetryable: true,
  139. metadata: { code: "ECONNRESET", message: "The socket connection was closed unexpectedly" },
  140. }).toObject() as MessageV2.APIError
  141. const retryable = SessionRetry.retryable(error)
  142. expect(retryable).toBeDefined()
  143. expect(retryable).toBe("Connection reset by server")
  144. })
  145. test("marks OpenAI 404 status codes as retryable", () => {
  146. const error = new APICallError({
  147. message: "boom",
  148. url: "https://api.openai.com/v1/chat/completions",
  149. requestBodyValues: {},
  150. statusCode: 404,
  151. responseHeaders: { "content-type": "application/json" },
  152. responseBody: '{"error":"boom"}',
  153. isRetryable: false,
  154. })
  155. const result = MessageV2.fromError(error, { providerID: "openai" }) as MessageV2.APIError
  156. expect(result.data.isRetryable).toBe(true)
  157. })
  158. })