retry.test.ts 5.1 KB

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