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
constOPENAI_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+
constDEFAULT_CHUNK_TIMEOUT_MS=120_000
46+
3747
functionwrapSSE(res: Response,ms: number,ctl: AbortController){
3848
if(typeofms!=="number"||ms<=0)returnres
3949
if(!res.body)returnres
@@ -1730,15 +1740,23 @@ const layer = Layer.effect(
17301740
if(existing)returnexisting
17311741

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

17381756
options["fetch"]=async(input: any,init?: BunFetchRequestInit)=>{
17391757
constfetchFn=customFetch??fetch
17401758
constopts=init??{}
1741-
constchunkAbortCtl=typeofchunkTimeout==="number"&&chunkTimeout>0 ? newAbortController() : undefined
1759+
constchunkAbortCtl=chunkTimeout>0 ? newAbortController() : undefined
17421760
constheaderTimeoutMs=headerTimeout===false ? undefined : headerTimeout
17431761
constheaderTimeoutCtl=typeofheaderTimeoutMs==="number" ? timeoutController(headerTimeoutMs) : undefined
17441762
constsignals: 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+
constTRANSIENT_SYS_CODES=newSet([
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+
constPERMANENT_SYS_CODES=newSet(["ENOTFOUND","ECONNREFUSED"])
619+
constCAUSE_MAX_DEPTH=8
620+
621+
exportfunctionisPermanentTransportCode(code: unknown){
622+
returntypeofcode==="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+
constTRANSIENT_MESSAGES=newSet(["socket hang up","sse read timed out","other side closed","terminated"])
629+
630+
functionclassifyTransportError(error: unknown){
631+
constseen=newSet<Error>()
632+
letcurrent=error
633+
lettransient: SystemError|undefined
634+
for(letdepth=0;depth<CAUSE_MAX_DEPTH;depth++){
635+
if(!(currentinstanceofError)||seen.has(current))break
636+
seen.add(current)
637+
constcode="code"incurrent&&typeofcurrent.code==="string" ? current.code : undefined
638+
if(isPermanentTransportCode(code))return{type: "permanent"asconst,error: currentasSystemError}
639+
if(!transient&&code&&TRANSIENT_SYS_CODES.has(code))transient=currentasSystemError
640+
current=current.cause
641+
}
642+
if(transient)return{type: "transient"asconst,error: transient}
643+
returnundefined
644+
}
645+
603646
exportfunctionfromError(
604647
e: unknown,
605648
ctx: {providerID: ProviderV2.ID;aborted?: boolean},
606649
): NonNullable<Assistant["error"]>{
650+
consttransport=classifyTransportError(e)
651+
consttransient=transport?.type==="transient" ? transport.error : undefined
652+
constpermanent=transport?.type==="permanent" ? transport.error : undefined
653+
constsysCode=transient?.code
607654
switch(true){
608655
caseeinstanceofDOMException&&e.name==="AbortError":
609-
returnnewAbortedError(
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+
returnnewAbortedError({message: e.message},{cause: e}).toObject()
669+
caseeinstanceofDOMException&&e.name==="TimeoutError":
670+
// Modern fetch surfaces AbortSignal.timeout() as a DOMException with
671+
// name "TimeoutError" rather than "AbortError". Always retryable.
672+
returnnewAPIError(
673+
{message: e.message||"Request timed out",isRetryable: true,metadata: {code: "TimeoutError"}},
674+
{cause: e},
614675
).toObject()
615676
caseOutputLengthError.isInstance(e):
616677
returne
@@ -622,15 +683,15 @@ export function fromError(
622683
},
623684
{cause: e},
624685
).toObject()
625-
case(easSystemError)?.code==="ECONNRESET":
686+
casetypeofsysCode==="string"&&TRANSIENT_SYS_CODES.has(sysCode):
626687
returnnewAPIError(
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: (easSystemError).code??"",
632-
syscall: (easSystemError).syscall??"",
633-
message: (easSystemError).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+
caseeinstanceofError&&TRANSIENT_MESSAGES.has(e.message.toLowerCase()):
771+
returnnewAPIError(
772+
{message: e.message,isRetryable: true,metadata: {code: "NETWORK_ERROR",message: e.message}},
773+
{cause: e},
774+
).toObject()
702775
caseeinstanceofError:
703776
returnnewNamedError.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
exportconstRETRY_INITIAL_DELAY=2000
2727
exportconstRETRY_BACKOFF_FACTOR=2
28+
exportconstRETRY_MAX_ATTEMPTS=5
2829
exportconstRETRY_MAX_DELAY_NO_HEADERS=30_000// 30 seconds
2930
exportconstRETRY_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))returnundefined
7172
if(SessionV1.APIError.isInstance(error)){
73+
if(MessageV2.isPermanentTransportCode(error.data.metadata?.code))returnundefined
7274
conststatus=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
returnSchedule.fromStepWithMetadata(
182184
Effect.succeed((meta: Schedule.InputMetadata<unknown>)=>{
185+
if(meta.attempt>RETRY_MAX_ATTEMPTS)returnCause.done(meta.attempt)
183186
consterror=opts.parse(meta.input)
184187
constretry=retryable(error,opts.provider)
185188
if(!retry)returnCause.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
importtype{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+
consterr=newDOMException("Aborted","AbortError")
1559+
constresult=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+
consterr=newDOMException("The operation was aborted","AbortError")
1570+
constresult=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+
consterr=newDOMException("The operation timed out","TimeoutError")
1577+
constresult=MessageV2.fromError(err,{ providerID })
1578+
expect(SessionV1.APIError.isInstance(result)).toBe(true)
1579+
expect((resultasSessionV1.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+
consterr=Object.assign(newError(`${code} from test`),{ code })
1594+
constresult=MessageV2.fromError(err,{ providerID })
1595+
expect(SessionV1.APIError.isInstance(result)).toBe(true)
1596+
expect((resultasSessionV1.APIError).data.isRetryable).toBe(true)
1597+
expect((resultasSessionV1.APIError).data.message).toBe(expectedMessage)
1598+
})
1599+
1600+
test("classifies a fetch failure with a transient cause as retryable",()=>{
1601+
constcause=Object.assign(newError("read ECONNRESET"),{code: "ECONNRESET"})
1602+
constwrapper=Object.assign(newTypeError("fetch failed",{ cause }),{code: "ERR_FETCH_FAILED"})
1603+
constresult=MessageV2.fromError(wrapper,{ providerID })
1604+
expect(SessionV1.APIError.isInstance(result)).toBe(true)
1605+
expect((resultasSessionV1.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+
constcause=Object.assign(newError(`${syscall}${code} api.invalid`),{ code, syscall })
1613+
constwrapper=Object.assign(newTypeError("fetch failed",{ cause }),{code: "ERR_FETCH_FAILED"})
1614+
consterror=newAPICallError({
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+
constresult=MessageV2.fromError(error,{ providerID })
1623+
expect(SessionV1.APIError.isInstance(result)).toBe(true)
1624+
if(!SessionV1.APIError.isInstance(result))thrownewError("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+
constcause=Object.assign(newError("getaddrinfo ENOTFOUND api.invalid"),{code: "ENOTFOUND"})
1632+
constwrapper=Object.assign(newError("socket reset",{ cause }),{code: "ECONNRESET"})
1633+
constresult=MessageV2.fromError(wrapper,{ providerID })
1634+
expect(result.name).toBe("UnknownError")
1635+
})
1636+
1637+
test("stops traversing cyclic error causes",()=>{
1638+
constfirst=Object.assign(newError("first wrapper"),{code: "ERR_FIRST"})
1639+
constsecond=Object.assign(newError("second wrapper",{cause: first}),{code: "ERR_SECOND"})
1640+
first.cause=second
1641+
constresult=MessageV2.fromError(first,{ providerID })
1642+
expect(result.name).toBe("UnknownError")
1643+
})
1644+
1645+
test("bounds deeply nested error causes",()=>{
1646+
constcause=Object.assign(newError("read ECONNRESET"),{code: "ECONNRESET"})
1647+
constwrapper=Array.from({length: 32},(_,index)=>index).reduce<Error>(
1648+
(current,index)=>newError(`wrapper ${index}`,{cause: current}),
1649+
cause,
1650+
)
1651+
constresult=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+
constresult=MessageV2.fromError(newError(message),{ providerID })
1659+
expect(SessionV1.APIError.isInstance(result)).toBe(true)
1660+
expect((resultasSessionV1.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+
constresult=MessageV2.fromError(newError(message),{ providerID })
1673+
expect(result.name).toBe("UnknownError")
1674+
})
1675+
1676+
test("leaves unrelated Error messages classified as Unknown",()=>{
1677+
constresult=MessageV2.fromError(newError("Some unrelated bug"),{ providerID })
1678+
expect(result.name).toBe("UnknownError")
1679+
})
15551680
})
15561681

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

0 commit comments

Comments
 (0)