mirror of https://github.com/openclaw/openclaw.git
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:
parent
8023f4c701
commit
6d0939d84e
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue