Skip to content

Commit e7c3f7b

Browse files
Alix-007steipete
andauthored
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 <[email protected]>
1 parent a37962e commit e7c3f7b

4 files changed

Lines changed: 105 additions & 13 deletions

File tree

extensions/discord/src/monitor/message-handler.preflight-pluralkit.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export async function resolveDiscordPreflightPluralKitInfo(params: {
2020
const info = await fetchPluralKitMessageInfo({
2121
messageId: params.message.id,
2222
config: params.config,
23+
signal: params.abortSignal,
2324
});
2425
return isPreflightAborted(params.abortSignal) ? null : info;
2526
} catch (err) {

extensions/discord/src/monitor/message-handler.preflight.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,7 @@ async function runGuildPreflight(params: {
222222
cfg?: import("openclaw/plugin-sdk/config-contracts").OpenClawConfig;
223223
guildEntries?: Parameters<typeof preflightDiscordMessage>[0]["guildEntries"];
224224
includeGuildObject?: boolean;
225+
abortSignal?: AbortSignal;
225226
}) {
226227
return preflightDiscordMessage({
227228
...createPreflightArgs({
@@ -237,6 +238,7 @@ async function runGuildPreflight(params: {
237238
client: createGuildTextClient(params.channelId),
238239
}),
239240
guildEntries: params.guildEntries,
241+
abortSignal: params.abortSignal,
240242
});
241243
}
242244

@@ -982,6 +984,7 @@ describe("preflightDiscordMessage", () => {
982984
});
983985

984986
it("canonicalizes PluralKit webhook messages to the original Discord message id", async () => {
987+
const abortController = new AbortController();
985988
fetchPluralKitMessageInfoMock.mockResolvedValue({
986989
id: "proxy-456",
987990
original: "orig-123",
@@ -1007,15 +1010,17 @@ describe("preflightDiscordMessage", () => {
10071010
discordConfig: {
10081011
pluralkit: { enabled: true },
10091012
} as DiscordConfig,
1013+
abortSignal: abortController.signal,
10101014
});
10111015

10121016
expect(fetchPluralKitMessageInfoMock).toHaveBeenCalledTimes(1);
10131017
const pluralKitCall = firstMockArg(
10141018
fetchPluralKitMessageInfoMock,
10151019
"fetchPluralKitMessageInfo",
1016-
) as { messageId?: unknown; config?: { enabled?: unknown } } | undefined;
1020+
) as { messageId?: unknown; config?: { enabled?: unknown }; signal?: AbortSignal } | undefined;
10171021
expect(pluralKitCall?.messageId).toBe("proxy-456");
10181022
expect(pluralKitCall?.config?.enabled).toBe(true);
1023+
expect(pluralKitCall?.signal).toBe(abortController.signal);
10191024
const preflight = expectPreflightResult(result);
10201025
expect(preflight.sender.isPluralKit).toBe(true);
10211026
expect(preflight.canonicalMessageId).toBe("orig-123");

extensions/discord/src/pluralkit.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,75 @@ describe("fetchPluralKitMessageInfo", () => {
9292
expect(receivedHeaders?.Authorization).toBe("pk_test");
9393
});
9494

95+
it("aborts PluralKit response body reads that exceed the lookup timeout", async () => {
96+
vi.useFakeTimers();
97+
try {
98+
let observedSignal: AbortSignal | undefined;
99+
const fetcher = vi.fn<typeof fetch>(async (_url, init) => {
100+
observedSignal = init?.signal ?? undefined;
101+
const body = new ReadableStream<Uint8Array>({
102+
start(controller) {
103+
observedSignal?.addEventListener(
104+
"abort",
105+
() => controller.error(new DOMException("PluralKit lookup timed out", "AbortError")),
106+
{ once: true },
107+
);
108+
},
109+
});
110+
return new Response(body, {
111+
status: 200,
112+
headers: { "content-type": "application/json" },
113+
});
114+
});
115+
116+
const lookupPromise = fetchPluralKitMessageInfo({
117+
messageId: "slow-body",
118+
config: { enabled: true },
119+
fetcher,
120+
});
121+
122+
await vi.advanceTimersByTimeAsync(0);
123+
expect(fetcher).toHaveBeenCalledOnce();
124+
expect(observedSignal?.aborted).toBe(false);
125+
const lookupRejection = expect(lookupPromise).rejects.toThrow(/timed out|abort/i);
126+
127+
await vi.advanceTimersByTimeAsync(10_000);
128+
await lookupRejection;
129+
expect(observedSignal?.aborted).toBe(true);
130+
} finally {
131+
vi.useRealTimers();
132+
}
133+
});
134+
135+
it("relays parent cancellation to the PluralKit request", async () => {
136+
const parent = new AbortController();
137+
let observedSignal: AbortSignal | undefined;
138+
const fetcher = vi.fn<typeof fetch>(async (_url, init) => {
139+
observedSignal = init?.signal ?? undefined;
140+
return await new Promise<Response>((_resolve, reject) => {
141+
observedSignal?.addEventListener(
142+
"abort",
143+
() => {
144+
const reason = observedSignal?.reason;
145+
reject(reason instanceof Error ? reason : new Error("PluralKit request aborted"));
146+
},
147+
{ once: true },
148+
);
149+
});
150+
});
151+
152+
const lookupPromise = fetchPluralKitMessageInfo({
153+
messageId: "cancelled",
154+
config: { enabled: true },
155+
fetcher,
156+
signal: parent.signal,
157+
});
158+
parent.abort(new Error("preflight stopped"));
159+
160+
await expect(lookupPromise).rejects.toThrow("preflight stopped");
161+
expect(observedSignal?.aborted).toBe(true);
162+
});
163+
95164
it("bounds PluralKit API error bodies without using response.text()", async () => {
96165
const tracked = cancelTrackedResponse(`${"plural failure ".repeat(1024)}tail`, {
97166
status: 500,

extensions/discord/src/pluralkit.ts

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
// Discord plugin module implements pluralkit behavior.
2+
import { buildTimeoutAbortSignal } from "openclaw/plugin-sdk/extension-shared";
23
import { resolveFetch } from "openclaw/plugin-sdk/fetch-runtime";
34
import {
45
readProviderJsonResponse,
@@ -7,6 +8,7 @@ import {
78

89
const PLURALKIT_API_BASE = "https://api.pluralkit.me/v2";
910
const PLURALKIT_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
11+
const PLURALKIT_LOOKUP_TIMEOUT_MS = 10_000;
1012

1113
export type DiscordPluralKitConfig = {
1214
enabled?: boolean;
@@ -37,6 +39,7 @@ export async function fetchPluralKitMessageInfo(params: {
3739
messageId: string;
3840
config?: DiscordPluralKitConfig;
3941
fetcher?: typeof fetch;
42+
signal?: AbortSignal;
4043
}): Promise<PluralKitMessageInfo | null> {
4144
if (!params.config?.enabled) {
4245
return null;
@@ -49,18 +52,32 @@ export async function fetchPluralKitMessageInfo(params: {
4952
if (params.config.token?.trim()) {
5053
headers.Authorization = params.config.token.trim();
5154
}
52-
const res = await fetchImpl(`${PLURALKIT_API_BASE}/messages/${params.messageId}`, {
53-
headers,
55+
const url = `${PLURALKIT_API_BASE}/messages/${params.messageId}`;
56+
const timeout = buildTimeoutAbortSignal({
57+
signal: params.signal,
58+
timeoutMs: PLURALKIT_LOOKUP_TIMEOUT_MS,
59+
operation: "discord.pluralkit.lookup",
60+
url,
5461
});
55-
if (res.status === 404) {
56-
return null;
57-
}
58-
if (!res.ok) {
59-
const text = await readResponseTextLimited(res, PLURALKIT_ERROR_BODY_LIMIT_BYTES).catch(
60-
() => "",
61-
);
62-
const detail = text.trim() ? `: ${text.trim()}` : "";
63-
throw new Error(`PluralKit API failed (${res.status})${detail}`);
62+
try {
63+
const res = await fetchImpl(url, {
64+
headers,
65+
signal: timeout.signal,
66+
});
67+
if (res.status === 404) {
68+
return null;
69+
}
70+
if (!res.ok) {
71+
const text = await readResponseTextLimited(res, PLURALKIT_ERROR_BODY_LIMIT_BYTES).catch(
72+
() => "",
73+
);
74+
const detail = text.trim() ? `: ${text.trim()}` : "";
75+
throw new Error(`PluralKit API failed (${res.status})${detail}`);
76+
}
77+
return await readProviderJsonResponse<PluralKitMessageInfo>(res, "PluralKit message");
78+
} finally {
79+
// Keep the deadline active through bounded error and JSON body reads; header-only
80+
// coverage would leave a stalled response stream holding the inbound preflight.
81+
timeout.cleanup();
6482
}
65-
return await readProviderJsonResponse<PluralKitMessageInfo>(res, "PluralKit message");
6683
}

0 commit comments

Comments
 (0)