Skip to content

Commit c38f0f4

Browse files
committed
fix(session): bound transient transport failures
1 parent 0a601cf commit c38f0f4

7 files changed

Lines changed: 294 additions & 20 deletions

File tree

packages/core/src/v1/config/provider.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,9 +108,14 @@ export const Info = Schema.Struct({
108108
description:
109109
"Timeout in milliseconds to wait for response headers. Provider integrations may set defaults. Set to false to disable timeout.",
110110
}),
111-
chunkTimeout: Schema.optional(PositiveInt).annotate({
111+
chunkTimeout: Schema.optional(
112+
Schema.Union([PositiveInt, Schema.Literal(false)]).annotate({
113+
description:
114+
"Timeout in milliseconds between streamed SSE chunks for this provider. If no chunk arrives within this window, the request is aborted. Default is 120000 (2 minutes). Set to false to disable.",
115+
}),
116+
).annotate({
112117
description:
113-
"Timeout in milliseconds between streamed SSE chunks for this provider. If no chunk arrives within this window, the request is aborted.",
118+
"Timeout in milliseconds between streamed SSE chunks for this provider. If no chunk arrives within this window, the request is aborted. Default is 120000 (2 minutes). Set to false to disable.",
114119
}),
115120
}),
116121
[Schema.Record(Schema.String, Schema.Any)],

packages/opencode/src/provider/provider.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,16 @@ import { ProviderError } from "./error"
3434

3535
const OPENAI_HEADER_TIMEOUT_DEFAULT = 300_000
3636

37+
// Default between-chunk timeout for streamed SSE responses. Without this,
38+
// a TCP socket that goes silent (wifi hiccup, NAT eviction, server crash
39+
// with no FIN/RST) parks the AI SDK's fullStream forever — Linux TCP
40+
// keepalive defaults to ~2 hours of idle before probing. 120s is short
41+
// enough to surface a freeze quickly, long enough that even slow
42+
// reasoning models emit *something* (token, ping, thinking chunk) in
43+
// between. Per-provider `chunkTimeout` overrides this; set to `false` to
44+
// disable entirely for providers with legitimately silent long pauses.
45+
const DEFAULT_CHUNK_TIMEOUT_MS = 120_000
46+
3747
function wrapSSE(res: Response, ms: number, ctl: AbortController) {
3848
if (typeof ms !== "number" || ms <= 0) return res
3949
if (!res.body) return res
@@ -1730,15 +1740,23 @@ const layer = Layer.effect(
17301740
if (existing) return existing
17311741

17321742
const customFetch = options["fetch"]
1733-
const chunkTimeout = options["chunkTimeout"]
1743+
const configuredChunkTimeout = options["chunkTimeout"]
17341744
const headerTimeout = options["headerTimeout"]
17351745
delete options["chunkTimeout"]
17361746
delete options["headerTimeout"]
1747+
// Default to DEFAULT_CHUNK_TIMEOUT_MS unless the provider config
1748+
// explicitly sets a value (number) or disables it (false).
1749+
const chunkTimeout =
1750+
configuredChunkTimeout === false
1751+
? 0
1752+
: typeof configuredChunkTimeout === "number"
1753+
? configuredChunkTimeout
1754+
: DEFAULT_CHUNK_TIMEOUT_MS
17371755

17381756
options["fetch"] = async (input: any, init?: BunFetchRequestInit) => {
17391757
const fetchFn = customFetch ?? fetch
17401758
const opts = init ?? {}
1741-
const chunkAbortCtl = typeof chunkTimeout === "number" && chunkTimeout > 0 ? new AbortController() : undefined
1759+
const chunkAbortCtl = chunkTimeout > 0 ? new AbortController() : undefined
17421760
const headerTimeoutMs = headerTimeout === false ? undefined : headerTimeout
17431761
const headerTimeoutCtl = typeof headerTimeoutMs === "number" ? timeoutController(headerTimeoutMs) : undefined
17441762
const signals: AbortSignal[] = []

packages/opencode/src/session/message-v2.ts

Lines changed: 85 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -600,17 +600,78 @@ export function latest(msgs: WithParts[]) {
600600
return { user, assistant, finished, tasks }
601601
}
602602

603+
// Transport failures that can recover without changing the request. Keep
604+
// permanent endpoint failures such as ENOTFOUND and ECONNREFUSED out of this
605+
// list so a bad provider configuration surfaces immediately.
606+
const TRANSIENT_SYS_CODES = new Set([
607+
"ECONNRESET",
608+
"ETIMEDOUT",
609+
"EAI_AGAIN",
610+
"EHOSTUNREACH",
611+
"ENETUNREACH",
612+
"EPIPE",
613+
"UND_ERR_CONNECT_TIMEOUT",
614+
"UND_ERR_HEADERS_TIMEOUT",
615+
"UND_ERR_BODY_TIMEOUT",
616+
"UND_ERR_SOCKET",
617+
])
618+
const PERMANENT_SYS_CODES = new Set(["ENOTFOUND", "ECONNREFUSED"])
619+
const CAUSE_MAX_DEPTH = 8
620+
621+
export function isPermanentTransportCode(code: unknown) {
622+
return typeof code === "string" && PERMANENT_SYS_CODES.has(code)
623+
}
624+
625+
// Exact messages emitted for a prematurely closed transport. Generic text
626+
// such as "network error" or "fetch failed" cannot distinguish a transient
627+
// disconnect from a permanent DNS, TLS, authentication, or endpoint failure.
628+
const TRANSIENT_MESSAGES = new Set(["socket hang up", "sse read timed out", "other side closed", "terminated"])
629+
630+
function classifyTransportError(error: unknown) {
631+
const seen = new Set<Error>()
632+
let current = error
633+
let transient: SystemError | undefined
634+
for (let depth = 0; depth < CAUSE_MAX_DEPTH; depth++) {
635+
if (!(current instanceof Error) || seen.has(current)) break
636+
seen.add(current)
637+
const code = "code" in current && typeof current.code === "string" ? current.code : undefined
638+
if (isPermanentTransportCode(code)) return { type: "permanent" as const, error: current as SystemError }
639+
if (!transient && code && TRANSIENT_SYS_CODES.has(code)) transient = current as SystemError
640+
current = current.cause
641+
}
642+
if (transient) return { type: "transient" as const, error: transient }
643+
return undefined
644+
}
645+
603646
export function fromError(
604647
e: unknown,
605648
ctx: { providerID: ProviderV2.ID; aborted?: boolean },
606649
): NonNullable<Assistant["error"]> {
650+
const transport = classifyTransportError(e)
651+
const transient = transport?.type === "transient" ? transport.error : undefined
652+
const permanent = transport?.type === "permanent" ? transport.error : undefined
653+
const sysCode = transient?.code
607654
switch (true) {
608655
case e instanceof DOMException && e.name === "AbortError":
609-
return new AbortedError(
610-
{ message: e.message },
611-
{
612-
cause: e,
613-
},
656+
// A *bare* AbortError only ever originates from `controller.abort()`
657+
// with no reason — i.e. a user/parent cancel (see provider.ts fetch
658+
// wrapper + processor onInterrupt). Every transport timeout we control
659+
// aborts with a *specific* reason and therefore surfaces as its own
660+
// identifiable error instead: AbortSignal.timeout() -> "TimeoutError"
661+
// DOMException (next case), the header-timeout -> HeaderTimeoutError,
662+
// and the SSE chunk-timeout -> ResponseStreamError ("SSE read timed
663+
// out"). Those are all classified retryable on their own below, so we
664+
// do NOT need to second-guess a bare AbortError here. Classifying by
665+
// error identity (rather than the externally-set `ctx.aborted` flag,
666+
// which races the abort propagating through the failure channel) keeps
667+
// a genuine cancel from being retried.
668+
return new AbortedError({ message: e.message }, { cause: e }).toObject()
669+
case e instanceof DOMException && e.name === "TimeoutError":
670+
// Modern fetch surfaces AbortSignal.timeout() as a DOMException with
671+
// name "TimeoutError" rather than "AbortError". Always retryable.
672+
return new APIError(
673+
{ message: e.message || "Request timed out", isRetryable: true, metadata: { code: "TimeoutError" } },
674+
{ cause: e },
614675
).toObject()
615676
case OutputLengthError.isInstance(e):
616677
return e
@@ -622,15 +683,15 @@ export function fromError(
622683
},
623684
{ cause: e },
624685
).toObject()
625-
case (e as SystemError)?.code === "ECONNRESET":
686+
case typeof sysCode === "string" && TRANSIENT_SYS_CODES.has(sysCode):
626687
return new APIError(
627688
{
628-
message: "Connection reset by server",
689+
message: sysCode === "ECONNRESET" ? "Connection reset by server" : `Network error (${sysCode})`,
629690
isRetryable: true,
630691
metadata: {
631-
code: (e as SystemError).code ?? "",
632-
syscall: (e as SystemError).syscall ?? "",
633-
message: (e as SystemError).message ?? "",
692+
code: sysCode ?? "",
693+
syscall: transient?.syscall ?? "",
694+
message: transient?.message ?? "",
634695
},
635696
},
636697
{ cause: e },
@@ -692,13 +753,25 @@ export function fromError(
692753
{
693754
message: parsed.message,
694755
statusCode: parsed.statusCode,
695-
isRetryable: parsed.isRetryable,
756+
isRetryable: permanent ? false : parsed.isRetryable,
696757
responseHeaders: parsed.responseHeaders,
697758
responseBody: parsed.responseBody,
698-
metadata: parsed.metadata,
759+
metadata: permanent
760+
? {
761+
...parsed.metadata,
762+
code: permanent.code ?? "",
763+
syscall: permanent.syscall ?? "",
764+
message: permanent.message ?? "",
765+
}
766+
: parsed.metadata,
699767
},
700768
{ cause: e },
701769
).toObject()
770+
case e instanceof Error && TRANSIENT_MESSAGES.has(e.message.toLowerCase()):
771+
return new APIError(
772+
{ message: e.message, isRetryable: true, metadata: { code: "NETWORK_ERROR", message: e.message } },
773+
{ cause: e },
774+
).toObject()
702775
case e instanceof Error:
703776
return new NamedError.Unknown({ message: errorMessage(e) }, { cause: e }).toObject()
704777
default:

packages/opencode/src/session/retry.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export type Retryable = {
2525

2626
export const RETRY_INITIAL_DELAY = 2000
2727
export const RETRY_BACKOFF_FACTOR = 2
28+
export const RETRY_MAX_ATTEMPTS = 5
2829
export const RETRY_MAX_DELAY_NO_HEADERS = 30_000 // 30 seconds
2930
export const RETRY_MAX_DELAY = 2_147_483_647 // max 32-bit signed integer for setTimeout
3031

@@ -69,6 +70,7 @@ export function retryable(error: Err, provider: string) {
6970
// context overflow errors should not be retried
7071
if (SessionV1.ContextOverflowError.isInstance(error)) return undefined
7172
if (SessionV1.APIError.isInstance(error)) {
73+
if (MessageV2.isPermanentTransportCode(error.data.metadata?.code)) return undefined
7274
const status = error.data.statusCode
7375
// 5xx errors are transient server failures and should always be retried,
7476
// even when the provider SDK doesn't explicitly mark them as retryable.
@@ -180,6 +182,7 @@ export function policy(opts: {
180182
}) {
181183
return Schedule.fromStepWithMetadata(
182184
Effect.succeed((meta: Schedule.InputMetadata<unknown>) => {
185+
if (meta.attempt > RETRY_MAX_ATTEMPTS) return Cause.done(meta.attempt)
183186
const error = opts.parse(meta.input)
184187
const retry = retryable(error, opts.provider)
185188
if (!retry) return Cause.done(meta.attempt)

packages/opencode/test/session/message-v2.test.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
22
import { SessionV1 } from "@opencode-ai/core/v1/session"
33
import { APICallError } from "ai"
44
import { MessageV2 } from "../../src/session/message-v2"
5+
import { SessionRetry } from "../../src/session/retry"
56
import { ProviderTransform } from "@/provider/transform"
67
import type { Provider } from "@/provider/provider"
78

@@ -1552,6 +1553,130 @@ describe("session.message-v2.fromError", () => {
15521553

15531554
expect(result.name).toBe("MessageAbortedError")
15541555
})
1556+
1557+
test("classifies user-cancelled AbortError as AbortedError when ctx.aborted is true", () => {
1558+
const err = new DOMException("Aborted", "AbortError")
1559+
const result = MessageV2.fromError(err, { providerID, aborted: true })
1560+
expect(result.name).toBe("MessageAbortedError")
1561+
})
1562+
1563+
test("classifies a bare AbortError as a cancel regardless of ctx.aborted", () => {
1564+
// A bare AbortError only comes from controller.abort() with no reason —
1565+
// i.e. a user/parent cancel. Transport timeouts surface as their own
1566+
// specific errors (TimeoutError/HeaderTimeoutError/ResponseStreamError),
1567+
// so a bare AbortError must NOT be reclassified as retryable — otherwise
1568+
// a cancel races the failure channel and gets retried.
1569+
const err = new DOMException("The operation was aborted", "AbortError")
1570+
const result = MessageV2.fromError(err, { providerID })
1571+
expect(result.name).toBe("MessageAbortedError")
1572+
})
1573+
1574+
test("classifies TimeoutError DOMException as retryable APIError", () => {
1575+
// AbortSignal.timeout() in modern fetch surfaces as TimeoutError.
1576+
const err = new DOMException("The operation timed out", "TimeoutError")
1577+
const result = MessageV2.fromError(err, { providerID })
1578+
expect(SessionV1.APIError.isInstance(result)).toBe(true)
1579+
expect((result as SessionV1.APIError).data.isRetryable).toBe(true)
1580+
})
1581+
1582+
test.each([
1583+
["ETIMEDOUT", "Network error (ETIMEDOUT)"],
1584+
["EAI_AGAIN", "Network error (EAI_AGAIN)"],
1585+
["EHOSTUNREACH", "Network error (EHOSTUNREACH)"],
1586+
["ENETUNREACH", "Network error (ENETUNREACH)"],
1587+
["EPIPE", "Network error (EPIPE)"],
1588+
["UND_ERR_CONNECT_TIMEOUT", "Network error (UND_ERR_CONNECT_TIMEOUT)"],
1589+
["UND_ERR_HEADERS_TIMEOUT", "Network error (UND_ERR_HEADERS_TIMEOUT)"],
1590+
["UND_ERR_BODY_TIMEOUT", "Network error (UND_ERR_BODY_TIMEOUT)"],
1591+
["UND_ERR_SOCKET", "Network error (UND_ERR_SOCKET)"],
1592+
])("classifies %s SystemError as retryable APIError", (code, expectedMessage) => {
1593+
const err = Object.assign(new Error(`${code} from test`), { code })
1594+
const result = MessageV2.fromError(err, { providerID })
1595+
expect(SessionV1.APIError.isInstance(result)).toBe(true)
1596+
expect((result as SessionV1.APIError).data.isRetryable).toBe(true)
1597+
expect((result as SessionV1.APIError).data.message).toBe(expectedMessage)
1598+
})
1599+
1600+
test("classifies a fetch failure with a transient cause as retryable", () => {
1601+
const cause = Object.assign(new Error("read ECONNRESET"), { code: "ECONNRESET" })
1602+
const wrapper = Object.assign(new TypeError("fetch failed", { cause }), { code: "ERR_FETCH_FAILED" })
1603+
const result = MessageV2.fromError(wrapper, { providerID })
1604+
expect(SessionV1.APIError.isInstance(result)).toBe(true)
1605+
expect((result as SessionV1.APIError).data.metadata?.code).toBe("ECONNRESET")
1606+
})
1607+
1608+
test.each([
1609+
["ENOTFOUND", "getaddrinfo"],
1610+
["ECONNREFUSED", "connect"],
1611+
])("overrides retryable APICallError caused by %s", (code, syscall) => {
1612+
const cause = Object.assign(new Error(`${syscall} ${code} api.invalid`), { code, syscall })
1613+
const wrapper = Object.assign(new TypeError("fetch failed", { cause }), { code: "ERR_FETCH_FAILED" })
1614+
const error = new APICallError({
1615+
message: "Cannot connect to API",
1616+
url: "https://api.invalid/v1/messages",
1617+
requestBodyValues: { model: "test" },
1618+
cause: wrapper,
1619+
isRetryable: true,
1620+
})
1621+
1622+
const result = MessageV2.fromError(error, { providerID })
1623+
expect(SessionV1.APIError.isInstance(result)).toBe(true)
1624+
if (!SessionV1.APIError.isInstance(result)) throw new Error("expected APIError")
1625+
expect(result.data.isRetryable).toBe(false)
1626+
expect(result.data.metadata).toMatchObject({ code, syscall, url: "https://api.invalid/v1/messages" })
1627+
expect(SessionRetry.retryable(result, "test")).toBeUndefined()
1628+
})
1629+
1630+
test("gives a nested permanent code precedence over a transient wrapper", () => {
1631+
const cause = Object.assign(new Error("getaddrinfo ENOTFOUND api.invalid"), { code: "ENOTFOUND" })
1632+
const wrapper = Object.assign(new Error("socket reset", { cause }), { code: "ECONNRESET" })
1633+
const result = MessageV2.fromError(wrapper, { providerID })
1634+
expect(result.name).toBe("UnknownError")
1635+
})
1636+
1637+
test("stops traversing cyclic error causes", () => {
1638+
const first = Object.assign(new Error("first wrapper"), { code: "ERR_FIRST" })
1639+
const second = Object.assign(new Error("second wrapper", { cause: first }), { code: "ERR_SECOND" })
1640+
first.cause = second
1641+
const result = MessageV2.fromError(first, { providerID })
1642+
expect(result.name).toBe("UnknownError")
1643+
})
1644+
1645+
test("bounds deeply nested error causes", () => {
1646+
const cause = Object.assign(new Error("read ECONNRESET"), { code: "ECONNRESET" })
1647+
const wrapper = Array.from({ length: 32 }, (_, index) => index).reduce<Error>(
1648+
(current, index) => new Error(`wrapper ${index}`, { cause: current }),
1649+
cause,
1650+
)
1651+
const result = MessageV2.fromError(wrapper, { providerID })
1652+
expect(result.name).toBe("UnknownError")
1653+
})
1654+
1655+
test.each(["socket hang up", "SSE read timed out", "other side closed", "terminated"])(
1656+
"classifies '%s' bare Error message as retryable APIError",
1657+
(message) => {
1658+
const result = MessageV2.fromError(new Error(message), { providerID })
1659+
expect(SessionV1.APIError.isInstance(result)).toBe(true)
1660+
expect((result as SessionV1.APIError).data.isRetryable).toBe(true)
1661+
},
1662+
)
1663+
1664+
test.each([
1665+
"fetch failed",
1666+
"Failed to fetch account configuration",
1667+
"network error: certificate has expired",
1668+
"Network request failed because access is forbidden",
1669+
"connect error: invalid provider base URL",
1670+
"request terminated because the API key is invalid",
1671+
])("does not treat '%s' as a transient transport error", (message) => {
1672+
const result = MessageV2.fromError(new Error(message), { providerID })
1673+
expect(result.name).toBe("UnknownError")
1674+
})
1675+
1676+
test("leaves unrelated Error messages classified as Unknown", () => {
1677+
const result = MessageV2.fromError(new Error("Some unrelated bug"), { providerID })
1678+
expect(result.name).toBe("UnknownError")
1679+
})
15551680
})
15561681

15571682
describe("session.message-v2.latest", () => {

0 commit comments

Comments
 (0)