fix(discord): normalize gateway fatal type errors

This commit is contained in:
Peter Steinberger
2026-04-06 15:59:40 +01:00
parent c7a562683a
commit 7f336aba56
6 changed files with 76 additions and 11 deletions

View File

@@ -89,7 +89,7 @@ describe("waitForDiscordGatewayStop", () => {
emitGatewayEvent(fatalEvent);
await expect(promise).rejects.toThrow("boom");
await expect(promise).rejects.toThrow("discord gateway fatal: Error: boom");
expect(disconnect).toHaveBeenCalledTimes(1);
expect(detachLifecycle).toHaveBeenCalledTimes(1);
});
@@ -178,7 +178,7 @@ describe("waitForDiscordGatewayStop", () => {
emitGatewayEvent(firstEvent);
await expect(promise).rejects.toThrow("first failure");
await expect(promise).rejects.toThrow("discord gateway fatal: Error: first failure");
expect(seenEvents.map((event) => event.message)).toEqual([
firstEvent.message,
secondEvent.message,

View File

@@ -1,7 +1,8 @@
import type { EventEmitter } from "node:events";
import type { DiscordGatewayHandle } from "./monitor/gateway-handle.js";
import type {
import {
DiscordGatewayEvent,
DiscordGatewayLifecycleError,
DiscordGatewaySupervisor,
} from "./monitor/gateway-supervisor.js";
@@ -59,7 +60,7 @@ export async function waitForDiscordGatewayStop(
const onGatewayEvent = (event: DiscordGatewayEvent) => {
const shouldStop = (params.onGatewayEvent?.(event) ?? "stop") === "stop";
if (shouldStop) {
finishReject(event.err);
finishReject(new DiscordGatewayLifecycleError(event));
}
};
const onForceStop = (err: unknown) => {

View File

@@ -2,6 +2,7 @@ import { EventEmitter } from "node:events";
import { describe, expect, it, vi } from "vitest";
import {
classifyDiscordGatewayEvent,
DiscordGatewayLifecycleError,
createDiscordGatewaySupervisor,
} from "./gateway-supervisor.js";
@@ -32,6 +33,20 @@ describe("classifyDiscordGatewayEvent", () => {
expect(transientEvent.message).toBe("TypeError");
expect(transientEvent.shouldStopLifecycle).toBe(true);
});
it("wraps fatal lifecycle stops with discord-specific context", () => {
const event = classifyDiscordGatewayEvent({
err: new TypeError(),
isDisallowedIntentsError: () => false,
});
const wrapped = new DiscordGatewayLifecycleError(event);
expect(wrapped.name).toBe("DiscordGatewayLifecycleError");
expect(wrapped.message).toBe("discord gateway fatal: TypeError");
expect(wrapped.eventType).toBe("fatal");
expect(wrapped.cause).toBeInstanceOf(TypeError);
});
});
describe("createDiscordGatewaySupervisor", () => {
@@ -101,4 +116,23 @@ describe("createDiscordGatewaySupervisor", () => {
expect.stringContaining("suppressed late gateway reconnect-exhausted error after dispose"),
);
});
it("dedupes identical late gateway errors after dispose", () => {
const emitter = new EventEmitter();
const runtime = { error: vi.fn() };
const supervisor = createDiscordGatewaySupervisor({
gateway: { emitter },
isDisallowedIntentsError: () => false,
runtime: runtime as never,
});
supervisor.dispose();
emitter.emit("error", new TypeError());
emitter.emit("error", new TypeError());
expect(runtime.error).toHaveBeenCalledTimes(1);
expect(runtime.error).toHaveBeenCalledWith(
expect.stringContaining("suppressed late gateway fatal error after dispose: TypeError"),
);
});
});

View File

@@ -17,6 +17,18 @@ export type DiscordGatewayEvent = {
shouldStopLifecycle: boolean;
};
export class DiscordGatewayLifecycleError extends Error {
readonly eventType: DiscordGatewayEventType;
constructor(event: Pick<DiscordGatewayEvent, "type" | "message" | "err">) {
super(`discord gateway ${event.type}: ${event.message}`, {
cause: event.err instanceof Error ? event.err : undefined,
});
this.name = "DiscordGatewayLifecycleError";
this.eventType = event.type;
}
}
export type DiscordGatewaySupervisor = {
emitter?: EventEmitter;
attachLifecycle: (handler: (event: DiscordGatewayEvent) => void) => void;
@@ -97,9 +109,15 @@ export function createDiscordGatewaySupervisor(params: {
let lifecycleHandler: ((event: DiscordGatewayEvent) => void) | undefined;
let phase: GatewaySupervisorPhase = "buffering";
const seenLateEventKeys = new Set<string>();
const logLateEvent =
(state: Extract<GatewaySupervisorPhase, "disposed" | "teardown">) =>
(event: DiscordGatewayEvent) => {
const key = `${state}:${event.type}:${event.message}`;
if (seenLateEventKeys.has(key)) {
return;
}
seenLateEventKeys.add(key);
params.runtime.error?.(
danger(
`discord: suppressed late gateway ${event.type} error ${

View File

@@ -383,7 +383,7 @@ describe("runDiscordGatewayLifecycle", () => {
});
await expect(runDiscordGatewayLifecycle(lifecycleParams)).rejects.toThrow(
"Fatal Gateway error: 4000",
"discord gateway fatal: Error: Fatal Gateway error: 4000",
);
expectLifecycleCleanup({
@@ -406,7 +406,7 @@ describe("runDiscordGatewayLifecycle", () => {
});
await expect(runDiscordGatewayLifecycle(lifecycleParams)).rejects.toThrow(
"Max reconnect attempts (50) reached after code 1005",
"discord gateway reconnect-exhausted: Error: Max reconnect attempts (50) reached after code 1005",
);
expectLifecycleCleanup({
@@ -438,9 +438,11 @@ describe("runDiscordGatewayLifecycle", () => {
lifecyclePromise.catch(() => {});
await vi.advanceTimersByTimeAsync(1_500);
await expect(lifecyclePromise).rejects.toThrow("Fatal Gateway error: 4001");
await expect(lifecyclePromise).rejects.toThrow(
"discord gateway fatal: Error: Fatal Gateway error: 4001",
);
expect(runtimeError).toHaveBeenCalledWith(
expect.stringContaining("discord gateway error: Error: Fatal Gateway error: 4001"),
expect.stringContaining("discord gateway fatal: Error: Fatal Gateway error: 4001"),
);
expect(gateway.disconnect).not.toHaveBeenCalled();
expect(gateway.connect).not.toHaveBeenCalled();

View File

@@ -6,7 +6,11 @@ import { getDiscordGatewayEmitter, waitForDiscordGatewayStop } from "../monitor.
import type { DiscordVoiceManager } from "../voice/manager.js";
import type { MutableDiscordGateway } from "./gateway-handle.js";
import { registerGateway, unregisterGateway } from "./gateway-registry.js";
import type { DiscordGatewayEvent, DiscordGatewaySupervisor } from "./gateway-supervisor.js";
import {
DiscordGatewayLifecycleError,
type DiscordGatewayEvent,
type DiscordGatewaySupervisor,
} from "./gateway-supervisor.js";
import type { DiscordMonitorStatusSink } from "./status.js";
const DISCORD_GATEWAY_READY_TIMEOUT_MS = 15_000;
@@ -401,7 +405,13 @@ export async function runDiscordGatewayLifecycle(params: {
if (event.shouldStopLifecycle) {
lifecycleStopping = true;
}
params.runtime.error?.(danger(`discord gateway error: ${event.message}`));
params.runtime.error?.(
danger(
event.shouldStopLifecycle
? `discord gateway ${event.type}: ${event.message}`
: `discord gateway error: ${event.message}`,
),
);
return event.shouldStopLifecycle ? "stop" : "continue";
};
const drainPendingGatewayErrors = (): "continue" | "stop" =>
@@ -413,7 +423,7 @@ export async function runDiscordGatewayLifecycle(params: {
if (event.type === "disallowed-intents") {
return "stop";
}
throw event.err;
throw new DiscordGatewayLifecycleError(event);
});
try {
if (params.execApprovalsHandler) {