From 6d0939d84ea5e509da7a910a05db5d7dd098669a Mon Sep 17 00:00:00 2001 From: Josh Lehman Date: Thu, 12 Mar 2026 21:52:17 -0700 Subject: [PATCH] fix: handle Discord gateway metadata fetch failures (#44397) Merged via squash. Prepared head SHA: edd17c0effe4f90887ac94ce549f44a69fe19eb2 Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com> Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com> Reviewed-by: @jalehman --- CHANGELOG.md | 1 + src/discord/monitor/gateway-plugin.ts | 191 +++++++++++++++++---- src/discord/monitor/provider.proxy.test.ts | 91 +++++++++- src/infra/unhandled-rejections.test.ts | 7 + src/infra/unhandled-rejections.ts | 2 + 5 files changed, 257 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e6e85666b08..d6f0f5d0893 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -87,6 +87,7 @@ Docs: https://docs.openclaw.ai - ACP/client final-message delivery: preserve terminal assistant text snapshots before resolving `end_turn`, so ACP clients no longer drop the last visible reply when the gateway sends the final message body on the terminal chat event. (#17615) Thanks @pjeby. - Telegram/Discord status reactions: show a temporary compacting reaction during auto-compaction pauses and restore thinking afterward so the bot no longer appears frozen while context is being compacted. (#35474) thanks @Cypherm. - Agents/compaction: compare post-compaction token sanity checks against full-session pre-compaction totals and skip the check when token estimation fails, so sessions with large bootstrap context keep real token counts instead of falling back to unknown. (#28347) thanks @efe-arv. +- Discord/gateway startup: treat plain-text and transient `/gateway/bot` metadata fetch failures as transient startup errors so Discord gateway boot no longer crashes on unhandled rejections. (#44397) Thanks @jalehman. ## 2026.3.11 diff --git a/src/discord/monitor/gateway-plugin.ts b/src/discord/monitor/gateway-plugin.ts index c86b6259c5e..b4030bcb386 100644 --- a/src/discord/monitor/gateway-plugin.ts +++ b/src/discord/monitor/gateway-plugin.ts @@ -7,6 +7,18 @@ import type { DiscordAccountConfig } from "../../config/types.js"; import { danger } from "../../globals.js"; import type { RuntimeEnv } from "../../runtime.js"; +const DISCORD_GATEWAY_BOT_URL = "https://discord.com/api/v10/gateway/bot"; +const DEFAULT_DISCORD_GATEWAY_URL = "wss://gateway.discord.gg/"; + +type DiscordGatewayMetadataResponse = Pick; +type DiscordGatewayFetchInit = Record & { + headers?: Record; +}; +type DiscordGatewayFetch = ( + input: string, + init?: DiscordGatewayFetchInit, +) => Promise; + export function resolveDiscordGatewayIntents( intentsConfig?: import("../../config/types.discord.js").DiscordIntentsConfig, ): number { @@ -27,6 +39,138 @@ export function resolveDiscordGatewayIntents( return intents; } +function summarizeGatewayResponseBody(body: string): string { + const normalized = body.trim().replace(/\s+/g, " "); + if (!normalized) { + return ""; + } + return normalized.slice(0, 240); +} + +function isTransientDiscordGatewayResponse(status: number, body: string): boolean { + if (status >= 500) { + return true; + } + const normalized = body.toLowerCase(); + return ( + normalized.includes("upstream connect error") || + normalized.includes("disconnect/reset before headers") || + normalized.includes("reset reason:") + ); +} + +function createGatewayMetadataError(params: { + detail: string; + transient: boolean; + cause?: unknown; +}): Error { + if (params.transient) { + return new Error("Failed to get gateway information from Discord: fetch failed", { + cause: params.cause ?? new Error(params.detail), + }); + } + return new Error(`Failed to get gateway information from Discord: ${params.detail}`, { + cause: params.cause, + }); +} + +async function fetchDiscordGatewayInfo(params: { + token: string; + fetchImpl: DiscordGatewayFetch; + fetchInit?: DiscordGatewayFetchInit; +}): Promise { + let response: DiscordGatewayMetadataResponse; + try { + response = await params.fetchImpl(DISCORD_GATEWAY_BOT_URL, { + ...params.fetchInit, + headers: { + ...params.fetchInit?.headers, + Authorization: `Bot ${params.token}`, + }, + }); + } catch (error) { + throw createGatewayMetadataError({ + detail: error instanceof Error ? error.message : String(error), + transient: true, + cause: error, + }); + } + + let body: string; + try { + body = await response.text(); + } catch (error) { + throw createGatewayMetadataError({ + detail: error instanceof Error ? error.message : String(error), + transient: true, + cause: error, + }); + } + const summary = summarizeGatewayResponseBody(body); + const transient = isTransientDiscordGatewayResponse(response.status, body); + + if (!response.ok) { + throw createGatewayMetadataError({ + detail: `Discord API /gateway/bot failed (${response.status}): ${summary}`, + transient, + }); + } + + try { + const parsed = JSON.parse(body) as Partial; + return { + ...parsed, + url: + typeof parsed.url === "string" && parsed.url.trim() + ? parsed.url + : DEFAULT_DISCORD_GATEWAY_URL, + } as APIGatewayBotInfo; + } catch (error) { + throw createGatewayMetadataError({ + detail: `Discord API /gateway/bot returned invalid JSON: ${summary}`, + transient, + cause: error, + }); + } +} + +function createGatewayPlugin(params: { + options: { + reconnect: { maxAttempts: number }; + intents: number; + autoInteractions: boolean; + }; + fetchImpl: DiscordGatewayFetch; + fetchInit?: DiscordGatewayFetchInit; + wsAgent?: HttpsProxyAgent; +}): GatewayPlugin { + class SafeGatewayPlugin extends GatewayPlugin { + constructor() { + super(params.options); + } + + override async registerClient(client: Parameters[0]) { + if (!this.gatewayInfo) { + this.gatewayInfo = await fetchDiscordGatewayInfo({ + token: client.options.token, + fetchImpl: params.fetchImpl, + fetchInit: params.fetchInit, + }); + } + return super.registerClient(client); + } + + override createWebSocket(url: string) { + if (!params.wsAgent) { + return super.createWebSocket(url); + } + return new WebSocket(url, { agent: params.wsAgent }); + } + } + + return new SafeGatewayPlugin(); +} + export function createDiscordGatewayPlugin(params: { discordConfig: DiscordAccountConfig; runtime: RuntimeEnv; @@ -40,7 +184,10 @@ export function createDiscordGatewayPlugin(params: { }; if (!proxy) { - return new GatewayPlugin(options); + return createGatewayPlugin({ + options, + fetchImpl: (input, init) => fetch(input, init as RequestInit), + }); } try { @@ -49,39 +196,17 @@ export function createDiscordGatewayPlugin(params: { params.runtime.log?.("discord: gateway proxy enabled"); - class ProxyGatewayPlugin extends GatewayPlugin { - constructor() { - super(options); - } - - override async registerClient(client: Parameters[0]) { - if (!this.gatewayInfo) { - try { - const response = await undiciFetch("https://discord.com/api/v10/gateway/bot", { - headers: { - Authorization: `Bot ${client.options.token}`, - }, - dispatcher: fetchAgent, - } as Record); - this.gatewayInfo = (await response.json()) as APIGatewayBotInfo; - } catch (error) { - throw new Error( - `Failed to get gateway information from Discord: ${error instanceof Error ? error.message : String(error)}`, - { cause: error }, - ); - } - } - return super.registerClient(client); - } - - override createWebSocket(url: string) { - return new WebSocket(url, { agent: wsAgent }); - } - } - - return new ProxyGatewayPlugin(); + return createGatewayPlugin({ + options, + fetchImpl: (input, init) => undiciFetch(input, init), + fetchInit: { dispatcher: fetchAgent }, + wsAgent, + }); } catch (err) { params.runtime.error?.(danger(`discord: invalid gateway proxy: ${String(err)}`)); - return new GatewayPlugin(options); + return createGatewayPlugin({ + options, + fetchImpl: (input, init) => fetch(input, init as RequestInit), + }); } } diff --git a/src/discord/monitor/provider.proxy.test.ts b/src/discord/monitor/provider.proxy.test.ts index 4d43469e2e4..0b45fd2a2e7 100644 --- a/src/discord/monitor/provider.proxy.test.ts +++ b/src/discord/monitor/provider.proxy.test.ts @@ -4,6 +4,7 @@ const { GatewayIntents, baseRegisterClientSpy, GatewayPlugin, + globalFetchMock, HttpsProxyAgent, getLastAgent, restProxyAgentSpy, @@ -17,6 +18,7 @@ const { const undiciProxyAgentSpy = vi.fn(); const restProxyAgentSpy = vi.fn(); const undiciFetchMock = vi.fn(); + const globalFetchMock = vi.fn(); const baseRegisterClientSpy = vi.fn(); const webSocketSpy = vi.fn(); @@ -60,6 +62,7 @@ const { baseRegisterClientSpy, GatewayIntents, GatewayPlugin, + globalFetchMock, HttpsProxyAgent, getLastAgent: () => HttpsProxyAgent.lastCreated, restProxyAgentSpy, @@ -121,7 +124,9 @@ describe("createDiscordGatewayPlugin", () => { } beforeEach(() => { + vi.stubGlobal("fetch", globalFetchMock); baseRegisterClientSpy.mockClear(); + globalFetchMock.mockClear(); restProxyAgentSpy.mockClear(); undiciFetchMock.mockClear(); undiciProxyAgentSpy.mockClear(); @@ -130,6 +135,60 @@ describe("createDiscordGatewayPlugin", () => { resetLastAgent(); }); + it("uses safe gateway metadata lookup without proxy", async () => { + const runtime = createRuntime(); + globalFetchMock.mockResolvedValue({ + ok: true, + status: 200, + text: async () => JSON.stringify({ url: "wss://gateway.discord.gg" }), + } as Response); + const plugin = createDiscordGatewayPlugin({ + discordConfig: {}, + runtime, + }); + + await ( + plugin as unknown as { + registerClient: (client: { options: { token: string } }) => Promise; + } + ).registerClient({ + options: { token: "token-123" }, + }); + + expect(globalFetchMock).toHaveBeenCalledWith( + "https://discord.com/api/v10/gateway/bot", + expect.objectContaining({ + headers: { Authorization: "Bot token-123" }, + }), + ); + expect(baseRegisterClientSpy).toHaveBeenCalledTimes(1); + }); + + it("maps plain-text Discord 503 responses to fetch failed", async () => { + const runtime = createRuntime(); + globalFetchMock.mockResolvedValue({ + ok: false, + status: 503, + text: async () => + "upstream connect error or disconnect/reset before headers. reset reason: overflow", + } as Response); + const plugin = createDiscordGatewayPlugin({ + discordConfig: {}, + runtime, + }); + + await expect( + ( + plugin as unknown as { + registerClient: (client: { options: { token: string } }) => Promise; + } + ).registerClient({ + options: { token: "token-123" }, + }), + ).rejects.toThrow("Failed to get gateway information from Discord: fetch failed"); + expect(baseRegisterClientSpy).not.toHaveBeenCalled(); + }); + it("uses proxy agent for gateway WebSocket when configured", async () => { const runtime = createRuntime(); @@ -161,7 +220,7 @@ describe("createDiscordGatewayPlugin", () => { runtime, }); - expect(Object.getPrototypeOf(plugin)).toBe(GatewayPlugin.prototype); + expect(Object.getPrototypeOf(plugin)).not.toBe(GatewayPlugin.prototype); expect(runtime.error).toHaveBeenCalled(); expect(runtime.log).not.toHaveBeenCalled(); }); @@ -169,7 +228,9 @@ describe("createDiscordGatewayPlugin", () => { it("uses proxy fetch for gateway metadata lookup before registering", async () => { const runtime = createRuntime(); undiciFetchMock.mockResolvedValue({ - json: async () => ({ url: "wss://gateway.discord.gg" }), + ok: true, + status: 200, + text: async () => JSON.stringify({ url: "wss://gateway.discord.gg" }), } as Response); const plugin = createDiscordGatewayPlugin({ discordConfig: { proxy: "http://proxy.test:8080" }, @@ -194,4 +255,30 @@ describe("createDiscordGatewayPlugin", () => { ); expect(baseRegisterClientSpy).toHaveBeenCalledTimes(1); }); + + it("maps body read failures to fetch failed", async () => { + const runtime = createRuntime(); + globalFetchMock.mockResolvedValue({ + ok: true, + status: 200, + text: async () => { + throw new Error("body stream closed"); + }, + } as unknown as Response); + const plugin = createDiscordGatewayPlugin({ + discordConfig: {}, + runtime, + }); + + await expect( + ( + plugin as unknown as { + registerClient: (client: { options: { token: string } }) => Promise; + } + ).registerClient({ + options: { token: "token-123" }, + }), + ).rejects.toThrow("Failed to get gateway information from Discord: fetch failed"); + expect(baseRegisterClientSpy).not.toHaveBeenCalled(); + }); }); diff --git a/src/infra/unhandled-rejections.test.ts b/src/infra/unhandled-rejections.test.ts index 5df7ee6949e..32992fdb3a8 100644 --- a/src/infra/unhandled-rejections.test.ts +++ b/src/infra/unhandled-rejections.test.ts @@ -130,6 +130,13 @@ describe("isTransientNetworkError", () => { expect(isTransientNetworkError(error)).toBe(true); }); + it("returns true for wrapped Discord upstream-connect parse failures", () => { + const error = new Error( + `Failed to get gateway information from Discord: Unexpected token 'u', "upstream connect error or disconnect/reset before headers. reset reason: overflow" is not valid JSON`, + ); + expect(isTransientNetworkError(error)).toBe(true); + }); + it("returns false for non-network fetch-failed wrappers from tools", () => { const error = new Error("Web fetch failed (404): Not Found"); expect(isTransientNetworkError(error)).toBe(false); diff --git a/src/infra/unhandled-rejections.ts b/src/infra/unhandled-rejections.ts index 44a6bb22584..ca99b649719 100644 --- a/src/infra/unhandled-rejections.ts +++ b/src/infra/unhandled-rejections.ts @@ -61,6 +61,8 @@ const TRANSIENT_NETWORK_MESSAGE_SNIPPETS = [ "network error", "network is unreachable", "temporary failure in name resolution", + "upstream connect error", + "disconnect/reset before headers", "tlsv1 alert", "ssl routines", "packet length too long",