fix: handle Discord gateway metadata fetch failures

Normalize Discord /gateway/bot startup failures so plain-text upstream 5xx responses do not crash the launchd-managed gateway. Use the same safe fetch path for proxied and non-proxied Discord startup, and expand transient unhandled-rejection detection for the exact upstream-connect error message family.

Regeneration-Prompt: |
  Investigate a launchd-managed OpenClaw gateway restart loop tied to Discord startup. Logs show GatewayPlugin.registerClient calling Discord /gateway/bot and crashing when Discord or an upstream proxy returns a plain-text 503 body like "upstream connect error or disconnect/reset before headers", which then bubbles into an unhandled rejection and exits the whole gateway.
  Keep the change additive and tightly scoped to Discord gateway startup and rejection classification. Preserve existing proxy support, but stop assuming the gateway metadata response is always JSON or HTTP 200. Treat transport and upstream 5xx failures as transient so Discord can retry without killing the entire gateway process. Add regression tests covering both normal metadata fetches and the plain-text 503 body that previously triggered the restart loop.
This commit is contained in:
Josh Lehman
2026-03-12 14:34:26 -07:00
parent 8023f4c701
commit 013a15d0fd
4 changed files with 217 additions and 35 deletions

View File

@@ -7,6 +7,15 @@ 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 DiscordGatewayFetch = (
input: string,
init?: Record<string, unknown>,
) => Promise<DiscordGatewayMetadataResponse>;
export function resolveDiscordGatewayIntents(
intentsConfig?: import("../../config/types.discord.js").DiscordIntentsConfig,
): number {
@@ -27,6 +36,128 @@ 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?: Record<string, unknown>;
}): Promise<APIGatewayBotInfo> {
let response: DiscordGatewayMetadataResponse;
try {
response = await params.fetchImpl(DISCORD_GATEWAY_BOT_URL, {
headers: {
Authorization: `Bot ${params.token}`,
},
...params.fetchInit,
});
} catch (error) {
throw createGatewayMetadataError({
detail: error instanceof Error ? error.message : String(error),
transient: true,
cause: error,
});
}
const body = await response.text();
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?: Record<string, unknown>;
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 +171,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 +183,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" },

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