Skip to content

Commit 1b8f62d

Browse files
committed
test(retry): failing tests for transport retries + budget cap (RED for A.2.2)
- Asserts SessionRetry.retryable matches transport substrings: terminated, ECONNRESET, socket hang up, fetch failed, SSE read timed out, stream error - Asserts SessionRetry.retryable matches SSEStallError instances - Budget cap test using Schedule.toStep + Effect.exit + it.live to avoid TestClock deadlock; drives policy() until it returns Cause.done(attempt) at TRANSPORT_RETRY_CAP + 1 Part of A.2.1. Red until A.2.2 exports TRANSPORT_RETRY_CAP and extends retryable() with transport matchers.
1 parent 2d6ed17 commit 1b8f62d

1 file changed

Lines changed: 73 additions & 1 deletion

File tree

packages/opencode/test/session/retry.test.ts

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,17 @@ import { describe, expect, test } from "bun:test"
22
import type { NamedError } from "@opencode-ai/shared/util/error"
33
import { APICallError } from "ai"
44
import { setTimeout as sleep } from "node:timers/promises"
5-
import { Effect, Schedule } from "effect"
5+
import { Effect, Exit, Layer, Schedule } from "effect"
66
import { SessionRetry } from "../../src/session/retry"
77
import { MessageV2 } from "../../src/session/message-v2"
8+
import { SSEStallError } from "../../src/provider/provider"
89
import { ProviderID } from "../../src/provider/schema"
910
import { AppRuntime } from "../../src/effect/app-runtime"
1011
import { SessionID } from "../../src/session/schema"
1112
import { SessionStatus } from "../../src/session/status"
1213
import { Instance } from "../../src/project/instance"
1314
import { tmpdir } from "../fixture/fixture"
15+
import { testEffect } from "../lib/effect"
1416

1517
const providerID = ProviderID.make("test")
1618

@@ -232,6 +234,76 @@ describe("session.retry.retryable", () => {
232234
})
233235
})
234236

237+
describe("SessionRetry.retryable — SSE stall round-trip", () => {
238+
test("retries SSEStallError after MessageV2.fromError round-trip", () => {
239+
const err = new SSEStallError("SSE read timed out")
240+
const obj = MessageV2.fromError(err, { providerID })
241+
expect(MessageV2.SSEStallError.isInstance(obj)).toBe(true)
242+
expect(SessionRetry.retryable(obj)).toBe("SSE read timed out")
243+
})
244+
})
245+
246+
describe("SessionRetry.retryable — narrow transport substrings", () => {
247+
test("retries ETIMEDOUT", () => {
248+
expect(SessionRetry.retryable(wrap("connect ETIMEDOUT 140.82.114.6:443"))).toContain("ETIMEDOUT")
249+
})
250+
251+
test("retries ECONNRESET", () => {
252+
expect(SessionRetry.retryable(wrap("read ECONNRESET"))).toContain("ECONNRESET")
253+
})
254+
255+
test("retries ECONNREFUSED", () => {
256+
expect(SessionRetry.retryable(wrap("connect ECONNREFUSED 127.0.0.1"))).toContain("ECONNREFUSED")
257+
})
258+
259+
test("retries EAI_AGAIN", () => {
260+
expect(SessionRetry.retryable(wrap("getaddrinfo EAI_AGAIN api.example.com"))).toContain("EAI_AGAIN")
261+
})
262+
263+
test("retries socket hang up", () => {
264+
expect(SessionRetry.retryable(wrap("socket hang up"))).toContain("socket hang up")
265+
})
266+
267+
test("does NOT retry EPIPE (often user-initiated abort)", () => {
268+
expect(SessionRetry.retryable(wrap("write EPIPE"))).toBeUndefined()
269+
})
270+
271+
test("does NOT retry the phrase 'network error' broadly", () => {
272+
expect(SessionRetry.retryable(wrap("upstream returned a network error"))).toBeUndefined()
273+
})
274+
275+
test("does NOT retry arbitrary agent errors", () => {
276+
expect(SessionRetry.retryable(wrap("agent not found: explore"))).toBeUndefined()
277+
})
278+
})
279+
280+
describe("SessionRetry.policy — transport retry budget", () => {
281+
const it = testEffect(Layer.empty)
282+
283+
it.live(
284+
"stops after exactly 6 total attempts on transport error (TRANSPORT_RETRY_CAP=5 + initial)",
285+
() =>
286+
Effect.gen(function* () {
287+
let setCalls = 0
288+
const step = yield* Schedule.toStep(
289+
SessionRetry.policy({
290+
parse: (err) => err as ReturnType<NamedError["toObject"]>,
291+
set: (_info) =>
292+
Effect.sync(() => {
293+
setCalls++
294+
}),
295+
}),
296+
)
297+
const now = 0
298+
for (let i = 0; i < 10; i++) {
299+
const exit = yield* Effect.exit(step(now, wrap("connect ETIMEDOUT 1.2.3.4")))
300+
if (!Exit.isSuccess(exit)) break
301+
}
302+
expect(setCalls).toBe(5)
303+
}),
304+
)
305+
})
306+
235307
describe("session.message-v2.fromError", () => {
236308
test.concurrent(
237309
"converts ECONNRESET socket errors to retryable APIError",

0 commit comments

Comments
 (0)