Skip to content

Commit 6d0939d

Browse files
authored
fix: handle Discord gateway metadata fetch failures (openclaw#44397)
Merged via squash. Prepared head SHA: edd17c0 Co-authored-by: jalehman <[email protected]> Co-authored-by: jalehman <[email protected]> Reviewed-by: @jalehman
1 parent 8023f4c commit 6d0939d

File tree

5 files changed

+257
-35
lines changed

5 files changed

+257
-35
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ Docs: https://docs.openclaw.ai
8787
- 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.
8888
- 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.
8989
- 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.
90+
- 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.
9091

9192
## 2026.3.11
9293

src/discord/monitor/gateway-plugin.ts

Lines changed: 158 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,18 @@ import type { DiscordAccountConfig } from "../../config/types.js";
77
import { danger } from "../../globals.js";
88
import type { RuntimeEnv } from "../../runtime.js";
99

10+
const DISCORD_GATEWAY_BOT_URL = "https://discord.com/api/v10/gateway/bot";
11+
const DEFAULT_DISCORD_GATEWAY_URL = "wss://gateway.discord.gg/";
12+
13+
type DiscordGatewayMetadataResponse = Pick<Response, "ok" | "status" | "text">;
14+
type DiscordGatewayFetchInit = Record<string, unknown> & {
15+
headers?: Record<string, string>;
16+
};
17+
type DiscordGatewayFetch = (
18+
input: string,
19+
init?: DiscordGatewayFetchInit,
20+
) => Promise<DiscordGatewayMetadataResponse>;
21+
1022
export function resolveDiscordGatewayIntents(
1123
intentsConfig?: import("../../config/types.discord.js").DiscordIntentsConfig,
1224
): number {
@@ -27,6 +39,138 @@ export function resolveDiscordGatewayIntents(
2739
return intents;
2840
}
2941

42+
function summarizeGatewayResponseBody(body: string): string {
43+
const normalized = body.trim().replace(/\s+/g, " ");
44+
if (!normalized) {
45+
return "<empty>";
46+
}
47+
return normalized.slice(0, 240);
48+
}
49+
50+
function isTransientDiscordGatewayResponse(status: number, body: string): boolean {
51+
if (status >= 500) {
52+
return true;
53+
}
54+
const normalized = body.toLowerCase();
55+
return (
56+
normalized.includes("upstream connect error") ||
57+
normalized.includes("disconnect/reset before headers") ||
58+
normalized.includes("reset reason:")
59+
);
60+
}
61+
62+
function createGatewayMetadataError(params: {
63+
detail: string;
64+
transient: boolean;
65+
cause?: unknown;
66+
}): Error {
67+
if (params.transient) {
68+
return new Error("Failed to get gateway information from Discord: fetch failed", {
69+
cause: params.cause ?? new Error(params.detail),
70+
});
71+
}
72+
return new Error(`Failed to get gateway information from Discord: ${params.detail}`, {
73+
cause: params.cause,
74+
});
75+
}
76+
77+
async function fetchDiscordGatewayInfo(params: {
78+
token: string;
79+
fetchImpl: DiscordGatewayFetch;
80+
fetchInit?: DiscordGatewayFetchInit;
81+
}): Promise<APIGatewayBotInfo> {
82+
let response: DiscordGatewayMetadataResponse;
83+
try {
84+
response = await params.fetchImpl(DISCORD_GATEWAY_BOT_URL, {
85+
...params.fetchInit,
86+
headers: {
87+
...params.fetchInit?.headers,
88+
Authorization: `Bot ${params.token}`,
89+
},
90+
});
91+
} catch (error) {
92+
throw createGatewayMetadataError({
93+
detail: error instanceof Error ? error.message : String(error),
94+
transient: true,
95+
cause: error,
96+
});
97+
}
98+
99+
let body: string;
100+
try {
101+
body = await response.text();
102+
} catch (error) {
103+
throw createGatewayMetadataError({
104+
detail: error instanceof Error ? error.message : String(error),
105+
transient: true,
106+
cause: error,
107+
});
108+
}
109+
const summary = summarizeGatewayResponseBody(body);
110+
const transient = isTransientDiscordGatewayResponse(response.status, body);
111+
112+
if (!response.ok) {
113+
throw createGatewayMetadataError({
114+
detail: `Discord API /gateway/bot failed (${response.status}): ${summary}`,
115+
transient,
116+
});
117+
}
118+
119+
try {
120+
const parsed = JSON.parse(body) as Partial<APIGatewayBotInfo>;
121+
return {
122+
...parsed,
123+
url:
124+
typeof parsed.url === "string" && parsed.url.trim()
125+
? parsed.url
126+
: DEFAULT_DISCORD_GATEWAY_URL,
127+
} as APIGatewayBotInfo;
128+
} catch (error) {
129+
throw createGatewayMetadataError({
130+
detail: `Discord API /gateway/bot returned invalid JSON: ${summary}`,
131+
transient,
132+
cause: error,
133+
});
134+
}
135+
}
136+
137+
function createGatewayPlugin(params: {
138+
options: {
139+
reconnect: { maxAttempts: number };
140+
intents: number;
141+
autoInteractions: boolean;
142+
};
143+
fetchImpl: DiscordGatewayFetch;
144+
fetchInit?: DiscordGatewayFetchInit;
145+
wsAgent?: HttpsProxyAgent<string>;
146+
}): GatewayPlugin {
147+
class SafeGatewayPlugin extends GatewayPlugin {
148+
constructor() {
149+
super(params.options);
150+
}
151+
152+
override async registerClient(client: Parameters<GatewayPlugin["registerClient"]>[0]) {
153+
if (!this.gatewayInfo) {
154+
this.gatewayInfo = await fetchDiscordGatewayInfo({
155+
token: client.options.token,
156+
fetchImpl: params.fetchImpl,
157+
fetchInit: params.fetchInit,
158+
});
159+
}
160+
return super.registerClient(client);
161+
}
162+
163+
override createWebSocket(url: string) {
164+
if (!params.wsAgent) {
165+
return super.createWebSocket(url);
166+
}
167+
return new WebSocket(url, { agent: params.wsAgent });
168+
}
169+
}
170+
171+
return new SafeGatewayPlugin();
172+
}
173+
30174
export function createDiscordGatewayPlugin(params: {
31175
discordConfig: DiscordAccountConfig;
32176
runtime: RuntimeEnv;
@@ -40,7 +184,10 @@ export function createDiscordGatewayPlugin(params: {
40184
};
41185

42186
if (!proxy) {
43-
return new GatewayPlugin(options);
187+
return createGatewayPlugin({
188+
options,
189+
fetchImpl: (input, init) => fetch(input, init as RequestInit),
190+
});
44191
}
45192

46193
try {
@@ -49,39 +196,17 @@ export function createDiscordGatewayPlugin(params: {
49196

50197
params.runtime.log?.("discord: gateway proxy enabled");
51198

52-
class ProxyGatewayPlugin extends GatewayPlugin {
53-
constructor() {
54-
super(options);
55-
}
56-
57-
override async registerClient(client: Parameters<GatewayPlugin["registerClient"]>[0]) {
58-
if (!this.gatewayInfo) {
59-
try {
60-
const response = await undiciFetch("https://discord.com/api/v10/gateway/bot", {
61-
headers: {
62-
Authorization: `Bot ${client.options.token}`,
63-
},
64-
dispatcher: fetchAgent,
65-
} as Record<string, unknown>);
66-
this.gatewayInfo = (await response.json()) as APIGatewayBotInfo;
67-
} catch (error) {
68-
throw new Error(
69-
`Failed to get gateway information from Discord: ${error instanceof Error ? error.message : String(error)}`,
70-
{ cause: error },
71-
);
72-
}
73-
}
74-
return super.registerClient(client);
75-
}
76-
77-
override createWebSocket(url: string) {
78-
return new WebSocket(url, { agent: wsAgent });
79-
}
80-
}
81-
82-
return new ProxyGatewayPlugin();
199+
return createGatewayPlugin({
200+
options,
201+
fetchImpl: (input, init) => undiciFetch(input, init),
202+
fetchInit: { dispatcher: fetchAgent },
203+
wsAgent,
204+
});
83205
} catch (err) {
84206
params.runtime.error?.(danger(`discord: invalid gateway proxy: ${String(err)}`));
85-
return new GatewayPlugin(options);
207+
return createGatewayPlugin({
208+
options,
209+
fetchImpl: (input, init) => fetch(input, init as RequestInit),
210+
});
86211
}
87212
}

src/discord/monitor/provider.proxy.test.ts

Lines changed: 89 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ const {
44
GatewayIntents,
55
baseRegisterClientSpy,
66
GatewayPlugin,
7+
globalFetchMock,
78
HttpsProxyAgent,
89
getLastAgent,
910
restProxyAgentSpy,
@@ -17,6 +18,7 @@ const {
1718
const undiciProxyAgentSpy = vi.fn();
1819
const restProxyAgentSpy = vi.fn();
1920
const undiciFetchMock = vi.fn();
21+
const globalFetchMock = vi.fn();
2022
const baseRegisterClientSpy = vi.fn();
2123
const webSocketSpy = vi.fn();
2224

@@ -60,6 +62,7 @@ const {
6062
baseRegisterClientSpy,
6163
GatewayIntents,
6264
GatewayPlugin,
65+
globalFetchMock,
6366
HttpsProxyAgent,
6467
getLastAgent: () => HttpsProxyAgent.lastCreated,
6568
restProxyAgentSpy,
@@ -121,7 +124,9 @@ describe("createDiscordGatewayPlugin", () => {
121124
}
122125

123126
beforeEach(() => {
127+
vi.stubGlobal("fetch", globalFetchMock);
124128
baseRegisterClientSpy.mockClear();
129+
globalFetchMock.mockClear();
125130
restProxyAgentSpy.mockClear();
126131
undiciFetchMock.mockClear();
127132
undiciProxyAgentSpy.mockClear();
@@ -130,6 +135,60 @@ describe("createDiscordGatewayPlugin", () => {
130135
resetLastAgent();
131136
});
132137

138+
it("uses safe gateway metadata lookup without proxy", async () => {
139+
const runtime = createRuntime();
140+
globalFetchMock.mockResolvedValue({
141+
ok: true,
142+
status: 200,
143+
text: async () => JSON.stringify({ url: "wss://gateway.discord.gg" }),
144+
} as Response);
145+
const plugin = createDiscordGatewayPlugin({
146+
discordConfig: {},
147+
runtime,
148+
});
149+
150+
await (
151+
plugin as unknown as {
152+
registerClient: (client: { options: { token: string } }) => Promise<void>;
153+
}
154+
).registerClient({
155+
options: { token: "token-123" },
156+
});
157+
158+
expect(globalFetchMock).toHaveBeenCalledWith(
159+
"https://discord.com/api/v10/gateway/bot",
160+
expect.objectContaining({
161+
headers: { Authorization: "Bot token-123" },
162+
}),
163+
);
164+
expect(baseRegisterClientSpy).toHaveBeenCalledTimes(1);
165+
});
166+
167+
it("maps plain-text Discord 503 responses to fetch failed", async () => {
168+
const runtime = createRuntime();
169+
globalFetchMock.mockResolvedValue({
170+
ok: false,
171+
status: 503,
172+
text: async () =>
173+
"upstream connect error or disconnect/reset before headers. reset reason: overflow",
174+
} as Response);
175+
const plugin = createDiscordGatewayPlugin({
176+
discordConfig: {},
177+
runtime,
178+
});
179+
180+
await expect(
181+
(
182+
plugin as unknown as {
183+
registerClient: (client: { options: { token: string } }) => Promise<void>;
184+
}
185+
).registerClient({
186+
options: { token: "token-123" },
187+
}),
188+
).rejects.toThrow("Failed to get gateway information from Discord: fetch failed");
189+
expect(baseRegisterClientSpy).not.toHaveBeenCalled();
190+
});
191+
133192
it("uses proxy agent for gateway WebSocket when configured", async () => {
134193
const runtime = createRuntime();
135194

@@ -161,15 +220,17 @@ describe("createDiscordGatewayPlugin", () => {
161220
runtime,
162221
});
163222

164-
expect(Object.getPrototypeOf(plugin)).toBe(GatewayPlugin.prototype);
223+
expect(Object.getPrototypeOf(plugin)).not.toBe(GatewayPlugin.prototype);
165224
expect(runtime.error).toHaveBeenCalled();
166225
expect(runtime.log).not.toHaveBeenCalled();
167226
});
168227

169228
it("uses proxy fetch for gateway metadata lookup before registering", async () => {
170229
const runtime = createRuntime();
171230
undiciFetchMock.mockResolvedValue({
172-
json: async () => ({ url: "wss://gateway.discord.gg" }),
231+
ok: true,
232+
status: 200,
233+
text: async () => JSON.stringify({ url: "wss://gateway.discord.gg" }),
173234
} as Response);
174235
const plugin = createDiscordGatewayPlugin({
175236
discordConfig: { proxy: "http://proxy.test:8080" },
@@ -194,4 +255,30 @@ describe("createDiscordGatewayPlugin", () => {
194255
);
195256
expect(baseRegisterClientSpy).toHaveBeenCalledTimes(1);
196257
});
258+
259+
it("maps body read failures to fetch failed", async () => {
260+
const runtime = createRuntime();
261+
globalFetchMock.mockResolvedValue({
262+
ok: true,
263+
status: 200,
264+
text: async () => {
265+
throw new Error("body stream closed");
266+
},
267+
} as unknown as Response);
268+
const plugin = createDiscordGatewayPlugin({
269+
discordConfig: {},
270+
runtime,
271+
});
272+
273+
await expect(
274+
(
275+
plugin as unknown as {
276+
registerClient: (client: { options: { token: string } }) => Promise<void>;
277+
}
278+
).registerClient({
279+
options: { token: "token-123" },
280+
}),
281+
).rejects.toThrow("Failed to get gateway information from Discord: fetch failed");
282+
expect(baseRegisterClientSpy).not.toHaveBeenCalled();
283+
});
197284
});

src/infra/unhandled-rejections.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,13 @@ describe("isTransientNetworkError", () => {
130130
expect(isTransientNetworkError(error)).toBe(true);
131131
});
132132

133+
it("returns true for wrapped Discord upstream-connect parse failures", () => {
134+
const error = new Error(
135+
`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`,
136+
);
137+
expect(isTransientNetworkError(error)).toBe(true);
138+
});
139+
133140
it("returns false for non-network fetch-failed wrappers from tools", () => {
134141
const error = new Error("Web fetch failed (404): Not Found");
135142
expect(isTransientNetworkError(error)).toBe(false);

src/infra/unhandled-rejections.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ const TRANSIENT_NETWORK_MESSAGE_SNIPPETS = [
6161
"network error",
6262
"network is unreachable",
6363
"temporary failure in name resolution",
64+
"upstream connect error",
65+
"disconnect/reset before headers",
6466
"tlsv1 alert",
6567
"ssl routines",
6668
"packet length too long",

0 commit comments

Comments
 (0)