| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179 |
- import { describe, expect, test } from "bun:test"
- import type { NamedError } from "@opencode-ai/util/error"
- import { APICallError } from "ai"
- import { SessionRetry } from "../../src/session/retry"
- import { MessageV2 } from "../../src/session/message-v2"
- function apiError(headers?: Record<string, string>): MessageV2.APIError {
- return new MessageV2.APIError({
- message: "boom",
- isRetryable: true,
- responseHeaders: headers,
- }).toObject() as MessageV2.APIError
- }
- function wrap(message: unknown): ReturnType<NamedError["toObject"]> {
- return { data: { message } } as ReturnType<NamedError["toObject"]>
- }
- describe("session.retry.delay", () => {
- test("caps delay at 30 seconds when headers missing", () => {
- const error = apiError()
- const delays = Array.from({ length: 10 }, (_, index) => SessionRetry.delay(index + 1, error))
- expect(delays).toStrictEqual([2000, 4000, 8000, 16000, 30000, 30000, 30000, 30000, 30000, 30000])
- })
- test("prefers retry-after-ms when shorter than exponential", () => {
- const error = apiError({ "retry-after-ms": "1500" })
- expect(SessionRetry.delay(4, error)).toBe(1500)
- })
- test("uses retry-after seconds when reasonable", () => {
- const error = apiError({ "retry-after": "30" })
- expect(SessionRetry.delay(3, error)).toBe(30000)
- })
- test("accepts http-date retry-after values", () => {
- const date = new Date(Date.now() + 20000).toUTCString()
- const error = apiError({ "retry-after": date })
- const d = SessionRetry.delay(1, error)
- expect(d).toBeGreaterThanOrEqual(19000)
- expect(d).toBeLessThanOrEqual(20000)
- })
- test("ignores invalid retry hints", () => {
- const error = apiError({ "retry-after": "not-a-number" })
- expect(SessionRetry.delay(1, error)).toBe(2000)
- })
- test("ignores malformed date retry hints", () => {
- const error = apiError({ "retry-after": "Invalid Date String" })
- expect(SessionRetry.delay(1, error)).toBe(2000)
- })
- test("ignores past date retry hints", () => {
- const pastDate = new Date(Date.now() - 5000).toUTCString()
- const error = apiError({ "retry-after": pastDate })
- expect(SessionRetry.delay(1, error)).toBe(2000)
- })
- test("uses retry-after values even when exceeding 10 minutes with headers", () => {
- const error = apiError({ "retry-after": "50" })
- expect(SessionRetry.delay(1, error)).toBe(50000)
- const longError = apiError({ "retry-after-ms": "700000" })
- expect(SessionRetry.delay(1, longError)).toBe(700000)
- })
- test("sleep caps delay to max 32-bit signed integer to avoid TimeoutOverflowWarning", async () => {
- const controller = new AbortController()
- const warnings: string[] = []
- const originalWarn = process.emitWarning
- process.emitWarning = (warning: string | Error) => {
- warnings.push(typeof warning === "string" ? warning : warning.message)
- }
- const promise = SessionRetry.sleep(2_560_914_000, controller.signal)
- controller.abort()
- try {
- await promise
- } catch {}
- process.emitWarning = originalWarn
- expect(warnings.some((w) => w.includes("TimeoutOverflowWarning"))).toBe(false)
- })
- })
- describe("session.retry.retryable", () => {
- test("maps too_many_requests json messages", () => {
- const error = wrap(JSON.stringify({ type: "error", error: { type: "too_many_requests" } }))
- expect(SessionRetry.retryable(error)).toBe("Too Many Requests")
- })
- test("maps overloaded provider codes", () => {
- const error = wrap(JSON.stringify({ code: "resource_exhausted" }))
- expect(SessionRetry.retryable(error)).toBe("Provider is overloaded")
- })
- test("handles json messages without code", () => {
- const error = wrap(JSON.stringify({ error: { message: "no_kv_space" } }))
- expect(SessionRetry.retryable(error)).toBe("Provider Server Error")
- })
- test("does not throw on numeric error codes", () => {
- const error = wrap(JSON.stringify({ type: "error", error: { code: 123 } }))
- const result = SessionRetry.retryable(error)
- expect(result).toBeUndefined()
- })
- test("returns undefined for non-json message", () => {
- const error = wrap("not-json")
- expect(SessionRetry.retryable(error)).toBeUndefined()
- })
- })
- describe("session.message-v2.fromError", () => {
- test.concurrent(
- "converts ECONNRESET socket errors to retryable APIError",
- async () => {
- using server = Bun.serve({
- port: 0,
- idleTimeout: 8,
- async fetch(req) {
- return new Response(
- new ReadableStream({
- async pull(controller) {
- controller.enqueue("Hello,")
- await Bun.sleep(10000)
- controller.enqueue(" World!")
- controller.close()
- },
- }),
- { headers: { "Content-Type": "text/plain" } },
- )
- },
- })
- const error = await fetch(new URL("/", server.url.origin))
- .then((res) => res.text())
- .catch((e) => e)
- const result = MessageV2.fromError(error, { providerID: "test" })
- expect(MessageV2.APIError.isInstance(result)).toBe(true)
- expect((result as MessageV2.APIError).data.isRetryable).toBe(true)
- expect((result as MessageV2.APIError).data.message).toBe("Connection reset by server")
- expect((result as MessageV2.APIError).data.metadata?.code).toBe("ECONNRESET")
- expect((result as MessageV2.APIError).data.metadata?.message).toInclude("socket connection")
- },
- 15_000,
- )
- test("ECONNRESET socket error is retryable", () => {
- const error = new MessageV2.APIError({
- message: "Connection reset by server",
- isRetryable: true,
- metadata: { code: "ECONNRESET", message: "The socket connection was closed unexpectedly" },
- }).toObject() as MessageV2.APIError
- const retryable = SessionRetry.retryable(error)
- expect(retryable).toBeDefined()
- expect(retryable).toBe("Connection reset by server")
- })
- test("marks OpenAI 404 status codes as retryable", () => {
- const error = new APICallError({
- message: "boom",
- url: "https://api.openai.com/v1/chat/completions",
- requestBodyValues: {},
- statusCode: 404,
- responseHeaders: { "content-type": "application/json" },
- responseBody: '{"error":"boom"}',
- isRetryable: false,
- })
- const result = MessageV2.fromError(error, { providerID: "openai" }) as MessageV2.APIError
- expect(result.data.isRetryable).toBe(true)
- })
- })
|