fix(discord): add timeouts to PluralKit lookup requests (#104121)

* fix(discord): add timeouts to PluralKit lookup requests

* fix(discord): bound PluralKit preflight cancellation

* docs(changelog): note PluralKit lookup deadline

* chore: keep changelog release-owned

* test(discord): reject PluralKit aborts with errors

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Alix-007
2026-07-11 17:44:52 -07:00
committed by GitHub
co-authored by Peter Steinberger
parent a37962e90c
commit e7c3f7be6c
4 changed files with 105 additions and 13 deletions
@@ -20,6 +20,7 @@ export async function resolveDiscordPreflightPluralKitInfo(params: {
const info = await fetchPluralKitMessageInfo({
messageId: params.message.id,
config: params.config,
signal: params.abortSignal,
});
return isPreflightAborted(params.abortSignal) ? null : info;
} catch (err) {
@@ -222,6 +222,7 @@ async function runGuildPreflight(params: {
cfg?: import("openclaw/plugin-sdk/config-contracts").OpenClawConfig;
guildEntries?: Parameters<typeof preflightDiscordMessage>[0]["guildEntries"];
includeGuildObject?: boolean;
abortSignal?: AbortSignal;
}) {
return preflightDiscordMessage({
...createPreflightArgs({
@@ -237,6 +238,7 @@ async function runGuildPreflight(params: {
client: createGuildTextClient(params.channelId),
}),
guildEntries: params.guildEntries,
abortSignal: params.abortSignal,
});
}
@@ -982,6 +984,7 @@ describe("preflightDiscordMessage", () => {
});
it("canonicalizes PluralKit webhook messages to the original Discord message id", async () => {
const abortController = new AbortController();
fetchPluralKitMessageInfoMock.mockResolvedValue({
id: "proxy-456",
original: "orig-123",
@@ -1007,15 +1010,17 @@ describe("preflightDiscordMessage", () => {
discordConfig: {
pluralkit: { enabled: true },
} as DiscordConfig,
abortSignal: abortController.signal,
});
expect(fetchPluralKitMessageInfoMock).toHaveBeenCalledTimes(1);
const pluralKitCall = firstMockArg(
fetchPluralKitMessageInfoMock,
"fetchPluralKitMessageInfo",
) as { messageId?: unknown; config?: { enabled?: unknown } } | undefined;
) as { messageId?: unknown; config?: { enabled?: unknown }; signal?: AbortSignal } | undefined;
expect(pluralKitCall?.messageId).toBe("proxy-456");
expect(pluralKitCall?.config?.enabled).toBe(true);
expect(pluralKitCall?.signal).toBe(abortController.signal);
const preflight = expectPreflightResult(result);
expect(preflight.sender.isPluralKit).toBe(true);
expect(preflight.canonicalMessageId).toBe("orig-123");
+69
View File
@@ -92,6 +92,75 @@ describe("fetchPluralKitMessageInfo", () => {
expect(receivedHeaders?.Authorization).toBe("pk_test");
});
it("aborts PluralKit response body reads that exceed the lookup timeout", async () => {
vi.useFakeTimers();
try {
let observedSignal: AbortSignal | undefined;
const fetcher = vi.fn<typeof fetch>(async (_url, init) => {
observedSignal = init?.signal ?? undefined;
const body = new ReadableStream<Uint8Array>({
start(controller) {
observedSignal?.addEventListener(
"abort",
() => controller.error(new DOMException("PluralKit lookup timed out", "AbortError")),
{ once: true },
);
},
});
return new Response(body, {
status: 200,
headers: { "content-type": "application/json" },
});
});
const lookupPromise = fetchPluralKitMessageInfo({
messageId: "slow-body",
config: { enabled: true },
fetcher,
});
await vi.advanceTimersByTimeAsync(0);
expect(fetcher).toHaveBeenCalledOnce();
expect(observedSignal?.aborted).toBe(false);
const lookupRejection = expect(lookupPromise).rejects.toThrow(/timed out|abort/i);
await vi.advanceTimersByTimeAsync(10_000);
await lookupRejection;
expect(observedSignal?.aborted).toBe(true);
} finally {
vi.useRealTimers();
}
});
it("relays parent cancellation to the PluralKit request", async () => {
const parent = new AbortController();
let observedSignal: AbortSignal | undefined;
const fetcher = vi.fn<typeof fetch>(async (_url, init) => {
observedSignal = init?.signal ?? undefined;
return await new Promise<Response>((_resolve, reject) => {
observedSignal?.addEventListener(
"abort",
() => {
const reason = observedSignal?.reason;
reject(reason instanceof Error ? reason : new Error("PluralKit request aborted"));
},
{ once: true },
);
});
});
const lookupPromise = fetchPluralKitMessageInfo({
messageId: "cancelled",
config: { enabled: true },
fetcher,
signal: parent.signal,
});
parent.abort(new Error("preflight stopped"));
await expect(lookupPromise).rejects.toThrow("preflight stopped");
expect(observedSignal?.aborted).toBe(true);
});
it("bounds PluralKit API error bodies without using response.text()", async () => {
const tracked = cancelTrackedResponse(`${"plural failure ".repeat(1024)}tail`, {
status: 500,
+29 -12
View File
@@ -1,4 +1,5 @@
// Discord plugin module implements pluralkit behavior.
import { buildTimeoutAbortSignal } from "openclaw/plugin-sdk/extension-shared";
import { resolveFetch } from "openclaw/plugin-sdk/fetch-runtime";
import {
readProviderJsonResponse,
@@ -7,6 +8,7 @@ import {
const PLURALKIT_API_BASE = "https://api.pluralkit.me/v2";
const PLURALKIT_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
const PLURALKIT_LOOKUP_TIMEOUT_MS = 10_000;
export type DiscordPluralKitConfig = {
enabled?: boolean;
@@ -37,6 +39,7 @@ export async function fetchPluralKitMessageInfo(params: {
messageId: string;
config?: DiscordPluralKitConfig;
fetcher?: typeof fetch;
signal?: AbortSignal;
}): Promise<PluralKitMessageInfo | null> {
if (!params.config?.enabled) {
return null;
@@ -49,18 +52,32 @@ export async function fetchPluralKitMessageInfo(params: {
if (params.config.token?.trim()) {
headers.Authorization = params.config.token.trim();
}
const res = await fetchImpl(`${PLURALKIT_API_BASE}/messages/${params.messageId}`, {
headers,
const url = `${PLURALKIT_API_BASE}/messages/${params.messageId}`;
const timeout = buildTimeoutAbortSignal({
signal: params.signal,
timeoutMs: PLURALKIT_LOOKUP_TIMEOUT_MS,
operation: "discord.pluralkit.lookup",
url,
});
if (res.status === 404) {
return null;
try {
const res = await fetchImpl(url, {
headers,
signal: timeout.signal,
});
if (res.status === 404) {
return null;
}
if (!res.ok) {
const text = await readResponseTextLimited(res, PLURALKIT_ERROR_BODY_LIMIT_BYTES).catch(
() => "",
);
const detail = text.trim() ? `: ${text.trim()}` : "";
throw new Error(`PluralKit API failed (${res.status})${detail}`);
}
return await readProviderJsonResponse<PluralKitMessageInfo>(res, "PluralKit message");
} finally {
// Keep the deadline active through bounded error and JSON body reads; header-only
// coverage would leave a stalled response stream holding the inbound preflight.
timeout.cleanup();
}
if (!res.ok) {
const text = await readResponseTextLimited(res, PLURALKIT_ERROR_BODY_LIMIT_BYTES).catch(
() => "",
);
const detail = text.trim() ? `: ${text.trim()}` : "";
throw new Error(`PluralKit API failed (${res.status})${detail}`);
}
return await readProviderJsonResponse<PluralKitMessageInfo>(res, "PluralKit message");
}