mirror of
https://gitea.toothfairyai.com/ToothFairyAI/tf_code.git
synced 2026-03-30 22:03:58 +00:00
- Rename packages/opencode → packages/tfcode (directory only) - Rename bin/opencode → bin/tfcode (CLI binary) - Rename .opencode → .tfcode (config directory) - Update package.json name and bin field - Update config directory path references (.tfcode) - Keep internal code references as 'opencode' for easy upstream sync - Keep @opencode-ai/* workspace package names This minimal branding approach allows clean merges from upstream opencode repository while providing tfcode branding for users.
193 lines
6.8 KiB
TypeScript
193 lines
6.8 KiB
TypeScript
import { describe, expect, test } from "bun:test"
|
|
import type { NamedError } from "@opencode-ai/util/error"
|
|
import { APICallError } from "ai"
|
|
import { setTimeout as sleep } from "node:timers/promises"
|
|
import { SessionRetry } from "../../src/session/retry"
|
|
import { MessageV2 } from "../../src/session/message-v2"
|
|
import { ProviderID } from "../../src/provider/schema"
|
|
|
|
const providerID = ProviderID.make("test")
|
|
|
|
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(`{"error":{"message":"no_kv_space"}}`)
|
|
})
|
|
|
|
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()
|
|
})
|
|
|
|
test("does not retry context overflow errors", () => {
|
|
const error = new MessageV2.ContextOverflowError({
|
|
message: "Input exceeds context window of this model",
|
|
responseBody: '{"error":{"code":"context_length_exceeded"}}',
|
|
}).toObject() as ReturnType<NamedError["toObject"]>
|
|
|
|
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 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 })
|
|
|
|
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: ProviderID.make("openai") }) as MessageV2.APIError
|
|
expect(result.data.isRetryable).toBe(true)
|
|
})
|
|
})
|