mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
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:
co-authored by
Peter Steinberger
parent
a37962e90c
commit
e7c3f7be6c
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user