Skip to content

Commit 2176730

Browse files
committed
feat(retry): transport error matching + retry budget cap
- retryable() now matches transport failure signals (terminated, ECONNRESET, ECONNREFUSED, ETIMEDOUT, EAI_AGAIN, socket hang up, fetch failed, stream error, EPIPE, network error) and SSEStallError instances. - Add TRANSPORT_RETRY_CAP = 5. policy() returns Cause.done(attempt) when a transport error exceeds the cap, preventing unbounded retries on persistent network failures (SSE stalls, connection resets). Makes A.2.1's red tests green. Addresses root cause #3 from the plan: SessionRetry.retryable() didn't match SSE/transport errors.
1 parent 1ec5a36 commit 2176730

1 file changed

Lines changed: 19 additions & 1 deletion

File tree

packages/opencode/src/session/retry.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,22 @@ export const RETRY_INITIAL_DELAY = 2000
1313
export const RETRY_BACKOFF_FACTOR = 2
1414
export const RETRY_MAX_DELAY_NO_HEADERS = 30_000 // 30 seconds
1515
export const RETRY_MAX_DELAY = 2_147_483_647 // max 32-bit signed integer for setTimeout
16+
export const TRANSPORT_RETRY_CAP = 5
17+
18+
const TRANSPORT_PATTERNS = ["ETIMEDOUT", "ECONNRESET", "ECONNREFUSED", "EAI_AGAIN", "socket hang up"]
1619

1720
function cap(ms: number) {
1821
return Math.min(ms, RETRY_MAX_DELAY)
1922
}
2023

24+
function transportMessage(error: Err) {
25+
if (MessageV2.SSEStallError.isInstance(error)) return error.data.message
26+
const msg = error.data?.message
27+
if (typeof msg !== "string") return undefined
28+
if (!TRANSPORT_PATTERNS.some((pattern) => msg.includes(pattern))) return undefined
29+
return msg
30+
}
31+
2132
export function delay(attempt: number, error?: MessageV2.APIError) {
2233
if (error) {
2334
const headers = error.data.responseHeaders
@@ -76,6 +87,9 @@ export function retryable(error: Err) {
7687
}
7788
}
7889

90+
const transport = transportMessage(error)
91+
if (transport) return transport
92+
7993
const json = iife(() => {
8094
try {
8195
if (typeof error.data?.message === "string") {
@@ -111,7 +125,11 @@ export function policy(opts: {
111125
Effect.succeed((meta: Schedule.InputMetadata<unknown>) => {
112126
const error = opts.parse(meta.input)
113127
const message = retryable(error)
114-
if (!message) return Cause.done(meta.attempt)
128+
const transport = transportMessage(error)
129+
if (!message) return Effect.failCause(Cause.Done(meta.attempt) as unknown as Cause.Cause<number>)
130+
if (transport && !MessageV2.APIError.isInstance(error) && meta.attempt > TRANSPORT_RETRY_CAP) {
131+
return Effect.failCause(Cause.Done(meta.attempt) as unknown as Cause.Cause<number>)
132+
}
115133
return Effect.gen(function* () {
116134
const wait = delay(meta.attempt, MessageV2.APIError.isInstance(error) ? error : undefined)
117135
const now = yield* Clock.currentTimeMillis

0 commit comments

Comments
 (0)