fix(opencode): preserve websocket api errors (#30321)
This commit is contained in:
@@ -97,9 +97,9 @@ export function createWebSocketFetch(options?: CreateWebSocketFetchOptions) {
|
||||
maxConnectionAge,
|
||||
init?.signal,
|
||||
)
|
||||
let resolveFirstEvent: (started: boolean) => void = () => {}
|
||||
let resolveFirstEvent: (event: boolean | OpenAIWebSocket.WrappedError) => void = () => {}
|
||||
let rejectFirstEvent: (error: Error) => void = () => {}
|
||||
const firstEvent = new Promise<boolean>((resolve, reject) => {
|
||||
const firstEvent = new Promise<boolean | OpenAIWebSocket.WrappedError>((resolve, reject) => {
|
||||
resolveFirstEvent = resolve
|
||||
rejectFirstEvent = reject
|
||||
})
|
||||
@@ -108,7 +108,7 @@ export function createWebSocketFetch(options?: CreateWebSocketFetchOptions) {
|
||||
body,
|
||||
idleTimeout,
|
||||
signal: init?.signal ?? undefined,
|
||||
onFirstEvent: () => resolveFirstEvent(true),
|
||||
onFirstEvent: (error) => resolveFirstEvent(error ?? true),
|
||||
onTerminal: (event) => {
|
||||
entry.busy = false
|
||||
entry.lastUsedAt = Date.now()
|
||||
@@ -140,7 +140,14 @@ export function createWebSocketFetch(options?: CreateWebSocketFetchOptions) {
|
||||
throw error
|
||||
},
|
||||
})
|
||||
if (await firstEvent) return response
|
||||
const first = await firstEvent
|
||||
if (first !== false) {
|
||||
if (first === true || first.status < 200 || first.status > 599) return response
|
||||
return new Response(first.body, {
|
||||
status: first.status,
|
||||
headers: { "content-type": "application/json", ...first.headers },
|
||||
})
|
||||
}
|
||||
if (!entry.fallback) return response
|
||||
log.debug("http fallback", { key, reason: "websocket_retries_exhausted" })
|
||||
return httpFetch(input, httpInit)
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
// fallback, and continuation state intentionally live above this file.
|
||||
|
||||
import WebSocket from "ws"
|
||||
import { APICallError } from "ai"
|
||||
import { ProviderError } from "@/provider/error"
|
||||
import { errorMessage } from "@/util/error"
|
||||
import { ProxyEnv } from "@/util/proxy-env"
|
||||
import { isRecord } from "@/util/record"
|
||||
|
||||
export const PROTOCOL_HEADER = "responses_websockets=2026-02-06"
|
||||
|
||||
@@ -20,7 +22,7 @@ export interface StreamResponsesWebSocketOptions {
|
||||
body: Record<string, unknown>
|
||||
idleTimeout?: number
|
||||
signal?: AbortSignal
|
||||
onFirstEvent?: () => void
|
||||
onFirstEvent?: (error?: WrappedError) => void
|
||||
onComplete?: (event: Record<string, unknown>) => void
|
||||
onTerminal?: (event: Record<string, unknown>) => void
|
||||
onRetryableTerminal?: (event: Record<string, unknown>) => Promise<WebSocket | undefined>
|
||||
@@ -28,6 +30,12 @@ export interface StreamResponsesWebSocketOptions {
|
||||
onAbort?: (error: Error) => void
|
||||
}
|
||||
|
||||
export interface WrappedError {
|
||||
status: number
|
||||
headers?: Record<string, string>
|
||||
body: string
|
||||
}
|
||||
|
||||
export function toWebSocketUrl(url: string) {
|
||||
return url.replace(/^http/, "ws")
|
||||
}
|
||||
@@ -186,7 +194,7 @@ export function streamResponsesWebSocket(options: StreamResponsesWebSocketOption
|
||||
}
|
||||
})()
|
||||
|
||||
if (event?.type === "error" && !emitted && options.onRetryableTerminal) {
|
||||
if (event?.type === "error" && options.onRetryableTerminal) {
|
||||
cleanupSocket()
|
||||
if (idleTimer) clearTimeout(idleTimer)
|
||||
idleTimer = undefined
|
||||
@@ -210,6 +218,25 @@ export function streamResponsesWebSocket(options: StreamResponsesWebSocketOption
|
||||
}
|
||||
}
|
||||
|
||||
const wrappedError = parseWrappedError(event, text)
|
||||
if (wrappedError && event) {
|
||||
if (!emitted) options.onFirstEvent?.(wrappedError)
|
||||
completed = true
|
||||
cleanup()
|
||||
options.onTerminal?.(event)
|
||||
controller?.error(
|
||||
new APICallError({
|
||||
message: wrappedError.message,
|
||||
url: socket.url,
|
||||
requestBodyValues: options.body,
|
||||
statusCode: wrappedError.status,
|
||||
responseHeaders: wrappedError.headers,
|
||||
responseBody: wrappedError.body,
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (!emitted) options.onFirstEvent?.()
|
||||
controller?.enqueue(
|
||||
encoder.encode(
|
||||
@@ -312,6 +339,26 @@ export function streamResponsesWebSocket(options: StreamResponsesWebSocketOption
|
||||
)
|
||||
}
|
||||
|
||||
function parseWrappedError(event: Record<string, unknown> | undefined, body: string) {
|
||||
if (event?.type !== "error") return
|
||||
const status = event.status ?? event.status_code
|
||||
if (typeof status !== "number" || (status >= 200 && status < 300)) return
|
||||
return {
|
||||
status,
|
||||
headers: isRecord(event.headers)
|
||||
? Object.fromEntries(
|
||||
Object.entries(event.headers).flatMap(([key, value]) =>
|
||||
typeof value === "string" || typeof value === "number" || typeof value === "boolean"
|
||||
? [[key, String(value)]]
|
||||
: [],
|
||||
),
|
||||
)
|
||||
: undefined,
|
||||
body,
|
||||
message: isRecord(event.error) && typeof event.error.message === "string" ? event.error.message : `${status}`,
|
||||
}
|
||||
}
|
||||
|
||||
function cancelError(reason: unknown) {
|
||||
if (isAbortError(reason)) return reason
|
||||
if (reason instanceof Error) return reason
|
||||
|
||||
Reference in New Issue
Block a user