Files
opencode/packages/core/test/session-runner-retry.test.ts

131 lines
5.0 KiB
TypeScript

import { describe, expect, test } from "bun:test"
import { AIError, ProviderInternalError, TransportError } from "@opencode/ai/effect"
import { ModelRetry } from "@opencode/core/model-retry"
import { Agent } from "@opencode/schema/agent"
import { Model } from "@opencode/schema/model"
import { Provider } from "@opencode/core/provider"
import { SessionSchema } from "@opencode/core/session/schema"
import { SessionRunnerRetry } from "@opencode/core/session/runner/retry"
import { toSessionError } from "@opencode/core/session/to-session-error"
import { Clock, Duration, Effect } from "effect"
import { TestClock } from "effect/testing"
import { it } from "./lib/effect"
const timeout = new AIError({
reason: new TransportError({ message: "Timed out", transport: "http", operation: "request", code: "Timeout" }),
})
const internal = new AIError({ reason: new ProviderInternalError({ message: "internal" }) })
const dnsFailure = new AIError({
reason: new TransportError({
message: "getaddrinfo ENOTFOUND opencode.ai",
transport: "http",
operation: "request",
code: "ENOTFOUND",
url: "https://opencode.ai/zen/v1/responses",
}),
})
const refused = new AIError({
reason: new TransportError({
message: "Unable to connect",
transport: "http",
operation: "request",
code: "ECONNREFUSED",
}),
})
const input = (cause: AIError) => ({
cause,
error: toSessionError(cause),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ id: Model.ID.make("model"), providerID: Provider.ID.make("provider") }),
hook: () => Effect.void,
retry: SessionRunnerRetry.isRetryable(cause),
})
// Decisions for `count` consecutive failures of the same cause within one step.
const decisions = (cause: AIError, count: number) =>
Effect.gen(function* () {
const policy = yield* SessionRunnerRetry.policy(SessionSchema.ID.make("ses_retry"))
const results: boolean[] = []
for (let i = 0; i < count; i++) results.push((yield* policy(input(cause))).retry)
return results
})
describe("SessionRunnerRetry.policy", () => {
it.effect("stops retrying transport timeouts after three attempts", () =>
Effect.gen(function* () {
expect(yield* decisions(timeout, 4)).toEqual([true, true, true, false])
}),
)
it.effect("keeps the general allowance for other transient failures", () =>
Effect.gen(function* () {
expect(yield* decisions(internal, 11)).toEqual([...Array(10).fill(true), false])
}),
)
it.effect("keeps retrying an unreachable host for an hour with gaps of at most 30 seconds", () =>
Effect.gen(function* () {
const policy = yield* SessionRunnerRetry.policy(SessionSchema.ID.make("ses_retry"))
const started = yield* Clock.currentTimeMillis
const delays: number[] = []
while (true) {
const decision = yield* policy(input(dnsFailure))
if (!decision.retry) break
delays.push(decision.delay)
yield* TestClock.adjust(Duration.millis(decision.delay))
}
const waited = (yield* Clock.currentTimeMillis) - started
expect(delays.length).toBeGreaterThan(100)
expect(Math.max(...delays)).toBeLessThanOrEqual(30_000)
expect(waited).toBeGreaterThanOrEqual(Duration.toMillis(ModelRetry.UNREACHABLE_WINDOW))
expect(waited).toBeLessThanOrEqual(Duration.toMillis(ModelRetry.UNREACHABLE_WINDOW) + 30_000)
}),
)
it.effect("keeps a refused connection on the general allowance", () =>
Effect.gen(function* () {
expect(ModelRetry.isUnreachable(refused)).toBe(false)
expect(yield* decisions(refused, 11)).toEqual([...Array(10).fill(true), false])
}),
)
it.effect("doesn't spend the general allowance on an unreachable host", () =>
Effect.gen(function* () {
const policy = yield* SessionRunnerRetry.policy(SessionSchema.ID.make("ses_retry"))
for (let i = 0; i < 20; i++) expect((yield* policy(input(dnsFailure))).retry).toBe(true)
const results: boolean[] = []
for (let i = 0; i < 11; i++) results.push((yield* policy(input(internal))).retry)
expect(results).toEqual([...Array(10).fill(true), false])
}),
)
})
describe("ModelRetry.retryOneShot", () => {
it.effect("doesn't replay a call whose response was cut off by the network going down", () =>
Effect.gen(function* () {
let calls = 0
const cutOff = new AIError({
reason: new TransportError({
message: "Network is down",
transport: "http",
operation: "read",
code: "ENETDOWN",
}),
})
yield* ModelRetry.retryOneShot(Effect.suspend(() => (calls++, Effect.fail(cutOff)))).pipe(Effect.flip)
expect(calls).toBe(1)
}),
)
})
describe("toSessionError", () => {
test("names the network when the provider's host can't be reached", () => {
expect(toSessionError(dnsFailure)).toEqual({
type: "provider.transport",
message: "Can't reach opencode.ai. Check the network connection. (getaddrinfo ENOTFOUND opencode.ai)",
})
expect(toSessionError(refused)).toEqual({ type: "provider.transport", message: "Unable to connect" })
})
})