fix: handle Discord gateway metadata fetch failures (#44397)

Merged via squash.

Prepared head SHA: edd17c0eff
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
This commit is contained in:
Josh Lehman 2026-03-12 21:52:17 -07:00 committed by GitHub
parent 8023f4c701
commit 6d0939d84e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 257 additions and 35 deletions

View File

@ -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

View File

@ -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<Response, "ok" | "status" | "text">;
type DiscordGatewayFetchInit = Record<string, unknown> & {
headers?: Record<string, string>;
};
type DiscordGatewayFetch = (
input: string,
init?: DiscordGatewayFetchInit,
) => Promise<DiscordGatewayMetadataResponse>;
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 "<empty>";
}
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<APIGatewayBotInfo> {
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<APIGatewayBotInfo>;
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<string>;
}): GatewayPlugin {
class SafeGatewayPlugin extends GatewayPlugin {
constructor() {
super(params.options);
}
override async registerClient(client: Parameters<GatewayPlugin["registerClient"]>[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<GatewayPlugin["registerClient"]>[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<string, unknown>);
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),
});
}
}

View File

@ -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<void>;
}
).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<void>;
}
).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<void>;
}
).registerClient({
options: { token: "token-123" },
}),
).rejects.toThrow("Failed to get gateway information from Discord: fetch failed");
expect(baseRegisterClientSpy).not.toHaveBeenCalled();
});
});

View File

@ -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);

View File

@ -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",