test: use testEffect for retry policy (#25050)

This commit is contained in:
Kit Langton
2026-04-30 12:55:33 -04:00
committed by GitHub
parent e4ac936eb9
commit ec3ab4a00c
+85 -78
View File
@@ -2,28 +2,31 @@ import { describe, expect, test } from "bun:test"
import type { NamedError } from "@opencode-ai/core/util/error" import type { NamedError } from "@opencode-ai/core/util/error"
import { APICallError } from "ai" import { APICallError } from "ai"
import { setTimeout as sleep } from "node:timers/promises" import { setTimeout as sleep } from "node:timers/promises"
import { Effect, Schedule } from "effect" import { Effect, Layer, Schedule } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { SessionRetry } from "../../src/session/retry" import { SessionRetry } from "../../src/session/retry"
import { MessageV2 } from "../../src/session/message-v2" import { MessageV2 } from "../../src/session/message-v2"
import { ProviderID } from "../../src/provider/schema" import { ProviderID } from "../../src/provider/schema"
import { AppRuntime } from "../../src/effect/app-runtime"
import { SessionID } from "../../src/session/schema" import { SessionID } from "../../src/session/schema"
import { SessionStatus } from "../../src/session/status" import { SessionStatus } from "../../src/session/status"
import { Instance } from "../../src/project/instance" import { provideTmpdirInstance } from "../fixture/fixture"
import { tmpdir } from "../fixture/fixture" import { testEffect } from "../lib/effect"
const providerID = ProviderID.make("test") const providerID = ProviderID.make("test")
const it = testEffect(Layer.mergeAll(SessionStatus.defaultLayer, CrossSpawnSpawner.defaultLayer))
function apiError(headers?: Record<string, string>): MessageV2.APIError { function apiError(headers?: Record<string, string>): MessageV2.APIError {
return new MessageV2.APIError({ return MessageV2.APIError.Schema.parse(
message: "boom", new MessageV2.APIError({
isRetryable: true, message: "boom",
responseHeaders: headers, isRetryable: true,
}).toObject() as MessageV2.APIError responseHeaders: headers,
}).toObject(),
)
} }
function wrap(message: unknown): ReturnType<NamedError["toObject"]> { function wrap(message: unknown): ReturnType<NamedError["toObject"]> {
return { data: { message } } as ReturnType<NamedError["toObject"]> return { name: "", data: { message } }
} }
describe("session.retry.delay", () => { describe("session.retry.delay", () => {
@@ -80,47 +83,36 @@ describe("session.retry.delay", () => {
expect(SessionRetry.delay(1, error)).toBe(SessionRetry.RETRY_MAX_DELAY) expect(SessionRetry.delay(1, error)).toBe(SessionRetry.RETRY_MAX_DELAY)
}) })
test("policy updates retry status and increments attempts", async () => { it.live("policy updates retry status and increments attempts", () =>
await using tmp = await tmpdir() provideTmpdirInstance(() =>
await Instance.provide({ Effect.gen(function* () {
directory: tmp.path,
fn: async () => {
const sessionID = SessionID.make("session-retry-test") const sessionID = SessionID.make("session-retry-test")
const error = apiError({ "retry-after-ms": "0" }) const error = apiError({ "retry-after-ms": "0" })
const status = yield* SessionStatus.Service
await Effect.runPromise( const step = yield* Schedule.toStepWithMetadata(
Effect.gen(function* () { SessionRetry.policy({
const step = yield* Schedule.toStepWithMetadata( parse: (err) => MessageV2.APIError.Schema.parse(err),
SessionRetry.policy({ set: (info) =>
parse: (err) => err as MessageV2.APIError, status.set(sessionID, {
set: (info) => type: "retry",
Effect.promise(() => attempt: info.attempt,
AppRuntime.runPromise( message: info.message,
SessionStatus.Service.use((svc) => next: info.next,
svc.set(sessionID, {
type: "retry",
attempt: info.attempt,
message: info.message,
next: info.next,
}),
),
),
),
}), }),
)
yield* step(error)
yield* step(error)
}), }),
) )
yield* step(error)
yield* step(error)
expect(await AppRuntime.runPromise(SessionStatus.Service.use((svc) => svc.get(sessionID)))).toMatchObject({ expect(yield* status.get(sessionID)).toMatchObject({
type: "retry", type: "retry",
attempt: 2, attempt: 2,
message: "boom", message: "boom",
}) })
}, }),
}) ),
}) )
}) })
describe("session.retry.retryable", () => { describe("session.retry.retryable", () => {
@@ -173,58 +165,68 @@ describe("session.retry.retryable", () => {
const error = new MessageV2.ContextOverflowError({ const error = new MessageV2.ContextOverflowError({
message: "Input exceeds context window of this model", message: "Input exceeds context window of this model",
responseBody: '{"error":{"code":"context_length_exceeded"}}', responseBody: '{"error":{"code":"context_length_exceeded"}}',
}).toObject() as ReturnType<NamedError["toObject"]> }).toObject()
expect(SessionRetry.retryable(error)).toBeUndefined() expect(SessionRetry.retryable(error)).toBeUndefined()
}) })
test("retries 500 errors even when isRetryable is false", () => { test("retries 500 errors even when isRetryable is false", () => {
const error = new MessageV2.APIError({ const error = MessageV2.APIError.Schema.parse(
message: "Internal server error", new MessageV2.APIError({
isRetryable: false, message: "Internal server error",
statusCode: 500, isRetryable: false,
responseBody: '{"type":"api_error","message":"Internal server error"}', statusCode: 500,
}).toObject() as MessageV2.APIError responseBody: '{"type":"api_error","message":"Internal server error"}',
}).toObject(),
)
expect(SessionRetry.retryable(error)).toBe("Internal server error") expect(SessionRetry.retryable(error)).toBe("Internal server error")
}) })
test("retries 502 bad gateway errors", () => { test("retries 502 bad gateway errors", () => {
const error = new MessageV2.APIError({ const error = MessageV2.APIError.Schema.parse(
message: "Bad gateway", new MessageV2.APIError({
isRetryable: false, message: "Bad gateway",
statusCode: 502, isRetryable: false,
}).toObject() as MessageV2.APIError statusCode: 502,
}).toObject(),
)
expect(SessionRetry.retryable(error)).toBe("Bad gateway") expect(SessionRetry.retryable(error)).toBe("Bad gateway")
}) })
test("retries 503 service unavailable errors", () => { test("retries 503 service unavailable errors", () => {
const error = new MessageV2.APIError({ const error = MessageV2.APIError.Schema.parse(
message: "Service unavailable", new MessageV2.APIError({
isRetryable: false, message: "Service unavailable",
statusCode: 503, isRetryable: false,
}).toObject() as MessageV2.APIError statusCode: 503,
}).toObject(),
)
expect(SessionRetry.retryable(error)).toBe("Service unavailable") expect(SessionRetry.retryable(error)).toBe("Service unavailable")
}) })
test("does not retry 4xx errors when isRetryable is false", () => { test("does not retry 4xx errors when isRetryable is false", () => {
const error = new MessageV2.APIError({ const error = MessageV2.APIError.Schema.parse(
message: "Bad request", new MessageV2.APIError({
isRetryable: false, message: "Bad request",
statusCode: 400, isRetryable: false,
}).toObject() as MessageV2.APIError statusCode: 400,
}).toObject(),
)
expect(SessionRetry.retryable(error)).toBeUndefined() expect(SessionRetry.retryable(error)).toBeUndefined()
}) })
test("retries ZlibError decompression failures", () => { test("retries ZlibError decompression failures", () => {
const error = new MessageV2.APIError({ const error = MessageV2.APIError.Schema.parse(
message: "Response decompression failed", new MessageV2.APIError({
isRetryable: true, message: "Response decompression failed",
metadata: { code: "ZlibError" }, isRetryable: true,
}).toObject() as MessageV2.APIError metadata: { code: "ZlibError" },
}).toObject(),
)
const retryable = SessionRetry.retryable(error) const retryable = SessionRetry.retryable(error)
expect(retryable).toBeDefined() expect(retryable).toBeDefined()
@@ -261,20 +263,23 @@ describe("session.message-v2.fromError", () => {
const result = MessageV2.fromError(error, { providerID }) const result = MessageV2.fromError(error, { providerID })
expect(MessageV2.APIError.isInstance(result)).toBe(true) expect(MessageV2.APIError.isInstance(result)).toBe(true)
expect((result as MessageV2.APIError).data.isRetryable).toBe(true) if (!MessageV2.APIError.isInstance(result)) throw new Error("expected APIError")
expect((result as MessageV2.APIError).data.message).toBe("Connection reset by server") expect(result.data.isRetryable).toBe(true)
expect((result as MessageV2.APIError).data.metadata?.code).toBe("ECONNRESET") expect(result.data.message).toBe("Connection reset by server")
expect((result as MessageV2.APIError).data.metadata?.message).toInclude("socket connection") expect(result.data.metadata?.code).toBe("ECONNRESET")
expect(result.data.metadata?.message).toInclude("socket connection")
}, },
15_000, 15_000,
) )
test("ECONNRESET socket error is retryable", () => { test("ECONNRESET socket error is retryable", () => {
const error = new MessageV2.APIError({ const error = MessageV2.APIError.Schema.parse(
message: "Connection reset by server", new MessageV2.APIError({
isRetryable: true, message: "Connection reset by server",
metadata: { code: "ECONNRESET", message: "The socket connection was closed unexpectedly" }, isRetryable: true,
}).toObject() as MessageV2.APIError metadata: { code: "ECONNRESET", message: "The socket connection was closed unexpectedly" },
}).toObject(),
)
const retryable = SessionRetry.retryable(error) const retryable = SessionRetry.retryable(error)
expect(retryable).toBeDefined() expect(retryable).toBeDefined()
@@ -291,7 +296,8 @@ describe("session.message-v2.fromError", () => {
responseBody: '{"error":"boom"}', responseBody: '{"error":"boom"}',
isRetryable: false, isRetryable: false,
}) })
const result = MessageV2.fromError(error, { providerID: ProviderID.make("openai") }) as MessageV2.APIError const result = MessageV2.fromError(error, { providerID: ProviderID.make("openai") })
if (!MessageV2.APIError.isInstance(result)) throw new Error("expected APIError")
expect(result.data.isRetryable).toBe(true) expect(result.data.isRetryable).toBe(true)
}) })
@@ -313,7 +319,8 @@ describe("session.message-v2.fromError", () => {
) )
expect(MessageV2.APIError.isInstance(result)).toBe(true) expect(MessageV2.APIError.isInstance(result)).toBe(true)
expect((result as MessageV2.APIError).data.isRetryable).toBe(true) if (!MessageV2.APIError.isInstance(result)) throw new Error("expected APIError")
expect(result.data.isRetryable).toBe(true)
expect(SessionRetry.retryable(result)).toBe("An error occurred while processing your request.") expect(SessionRetry.retryable(result)).toBe("An error occurred while processing your request.")
}) })
}) })