mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-14 20:21:39 +03:00
131 lines
5.0 KiB
TypeScript
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" })
|
|
})
|
|
})
|