From b059b9ef29e20f18144a55c7a1f45a56cddd39cd Mon Sep 17 00:00:00 2001 From: ken Date: Tue, 9 Jun 2026 07:39:37 +0700 Subject: [PATCH] fix(opencode): retry transient network errors instead of surfacing as terminal with raw content When a network request drops mid-flight (ECONNRESET, ECONNREFUSED, fetch failure, premature close, etc.), the error was classified as NamedError.Unknown and never retried, leaving a large raw error body on screen. Two changes: 1. Add isTransientNetworkError() detection in MessageV2.fromError() that catches SystemError codes (ECONNRESET, ECONNREFUSED, ETIMEDOUT, ...), TypeError from fetch, and common transient message patterns. These now produce an APIError with isRetryable: true so SessionRetry retries. 2. Truncate e.responseBody in ProviderError.message() at 200 chars to prevent dumping long HTML error pages or raw content into the visible error message. Closes #31133, #20822, #15350, #21893 --- packages/opencode/src/provider/error.ts | 3 ++- packages/opencode/src/session/message-v2.ts | 23 +++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/provider/error.ts b/packages/opencode/src/provider/error.ts index 21149a2cf389..66ef3970a0c9 100644 --- a/packages/opencode/src/provider/error.ts +++ b/packages/opencode/src/provider/error.ts @@ -66,7 +66,8 @@ function message(providerID: ProviderV2.ID, e: APICallError) { return msg } - return `${msg}: ${e.responseBody}` + const body = e.responseBody.length > 200 ? e.responseBody.slice(0, 200) + "..." : e.responseBody + return `${msg}: ${body}` }).trim() } diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 1590e0890372..9c1d0cd50594 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -611,6 +611,17 @@ export function latest(msgs: WithParts[]) { return { user, assistant, finished, tasks } } +const TRANSIENT_CODES = new Set(["ECONNRESET", "ECONNREFUSED", "ETIMEDOUT", "ENOTFOUND", "EAI_AGAIN", "ENETUNREACH", "EPIPE"]) +const TRANSIENT_PATTERNS = ["failed to fetch", "network connection was lost", "network request failed", "socket hang up", "unexpected end of data", "premature close", "response closed without sending", "load failed", "fetch failed", "body is unusable"] + +function isTransientNetworkError(e: unknown): e is Error { + if (!(e instanceof Error)) return false + const code = (e as Error & { code?: string }).code + if (code && TRANSIENT_CODES.has(code)) return true + const msg = e.message.toLowerCase() + return TRANSIENT_PATTERNS.some((p) => msg.includes(p)) +} + export function fromError( e: unknown, ctx: { providerID: ProviderV2.ID; aborted?: boolean }, @@ -710,6 +721,18 @@ export function fromError( }, { cause: e }, ).toObject() + case isTransientNetworkError(e): + return new APIError( + { + message: e.message, + isRetryable: true, + metadata: { + type: "network_error", + code: e.name, + }, + }, + { cause: e }, + ).toObject() case e instanceof Error: return new NamedError.Unknown({ message: errorMessage(e) }, { cause: e }).toObject() default: