fix: harden discord rate-limit handling
This commit is contained in:
@@ -35,6 +35,7 @@ Docs: https://docs.clawd.bot
|
||||
- Tailscale: retry serve/funnel with sudo only for permission errors and keep original failure details. (#1551) Thanks @sweepies.
|
||||
- Agents: add CLI log hint to "agent failed before reply" messages. (#1550) Thanks @sweepies.
|
||||
- Discord: limit autoThread mention bypass to bot-owned threads; keep ack reactions mention-gated. (#1511) Thanks @pvoo.
|
||||
- Discord: retry rate-limited allowlist resolution + command deploy to avoid gateway crashes.
|
||||
- Gateway: accept null optional fields in exec approval requests. (#1511) Thanks @pvoo.
|
||||
- Exec: honor tools.exec ask/security defaults for elevated approvals (avoid unwanted prompts).
|
||||
- TUI: forward unknown slash commands (for example, `/context`) to the Gateway.
|
||||
|
||||
@@ -20,7 +20,9 @@ describe("fetchDiscord", () => {
|
||||
|
||||
let error: unknown;
|
||||
try {
|
||||
await fetchDiscord("/users/@me/guilds", "test", fetcher as typeof fetch);
|
||||
await fetchDiscord("/users/@me/guilds", "test", fetcher as typeof fetch, {
|
||||
retry: { attempts: 1 },
|
||||
});
|
||||
} catch (err) {
|
||||
error = err;
|
||||
}
|
||||
@@ -36,7 +38,37 @@ describe("fetchDiscord", () => {
|
||||
it("preserves non-JSON error text", async () => {
|
||||
const fetcher = async () => new Response("Not Found", { status: 404 });
|
||||
await expect(
|
||||
fetchDiscord("/users/@me/guilds", "test", fetcher as typeof fetch),
|
||||
fetchDiscord("/users/@me/guilds", "test", fetcher as typeof fetch, {
|
||||
retry: { attempts: 1 },
|
||||
}),
|
||||
).rejects.toThrow("Discord API /users/@me/guilds failed (404): Not Found");
|
||||
});
|
||||
|
||||
it("retries rate limits before succeeding", async () => {
|
||||
let calls = 0;
|
||||
const fetcher = async () => {
|
||||
calls += 1;
|
||||
if (calls === 1) {
|
||||
return jsonResponse(
|
||||
{
|
||||
message: "You are being rate limited.",
|
||||
retry_after: 0,
|
||||
global: false,
|
||||
},
|
||||
429,
|
||||
);
|
||||
}
|
||||
return jsonResponse([{ id: "1", name: "Guild" }], 200);
|
||||
};
|
||||
|
||||
const result = await fetchDiscord<Array<{ id: string; name: string }>>(
|
||||
"/users/@me/guilds",
|
||||
"test",
|
||||
fetcher as typeof fetch,
|
||||
{ retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0 } },
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { resolveFetch } from "../infra/fetch.js";
|
||||
import { resolveRetryConfig, retryAsync, type RetryConfig } from "../infra/retry.js";
|
||||
|
||||
const DISCORD_API_BASE = "https://discord.com/api/v10";
|
||||
const DISCORD_API_RETRY_DEFAULTS = {
|
||||
attempts: 3,
|
||||
minDelayMs: 500,
|
||||
maxDelayMs: 30_000,
|
||||
jitter: 0.1,
|
||||
};
|
||||
|
||||
type DiscordApiErrorPayload = {
|
||||
message?: string;
|
||||
@@ -21,6 +28,19 @@ function parseDiscordApiErrorPayload(text: string): DiscordApiErrorPayload | nul
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseRetryAfterSeconds(text: string, response: Response): number | undefined {
|
||||
const payload = parseDiscordApiErrorPayload(text);
|
||||
const retryAfter =
|
||||
payload && typeof payload.retry_after === "number" && Number.isFinite(payload.retry_after)
|
||||
? payload.retry_after
|
||||
: undefined;
|
||||
if (retryAfter !== undefined) return retryAfter;
|
||||
const header = response.headers.get("Retry-After");
|
||||
if (!header) return undefined;
|
||||
const parsed = Number(header);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
function formatRetryAfterSeconds(value: number | undefined): string | undefined {
|
||||
if (value === undefined || !Number.isFinite(value) || value < 0) return undefined;
|
||||
const rounded = value < 10 ? value.toFixed(1) : Math.round(value).toString();
|
||||
@@ -45,15 +65,36 @@ function formatDiscordApiErrorText(text: string): string | undefined {
|
||||
return retryAfter ? `${message} (retry after ${retryAfter})` : message;
|
||||
}
|
||||
|
||||
export class DiscordApiError extends Error {
|
||||
status: number;
|
||||
retryAfter?: number;
|
||||
|
||||
constructor(message: string, status: number, retryAfter?: number) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.retryAfter = retryAfter;
|
||||
}
|
||||
}
|
||||
|
||||
export type DiscordFetchOptions = {
|
||||
retry?: RetryConfig;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export async function fetchDiscord<T>(
|
||||
path: string,
|
||||
token: string,
|
||||
fetcher: typeof fetch = fetch,
|
||||
options?: DiscordFetchOptions,
|
||||
): Promise<T> {
|
||||
const fetchImpl = resolveFetch(fetcher);
|
||||
if (!fetchImpl) {
|
||||
throw new Error("fetch is not available");
|
||||
}
|
||||
|
||||
const retryConfig = resolveRetryConfig(DISCORD_API_RETRY_DEFAULTS, options?.retry);
|
||||
return retryAsync(
|
||||
async () => {
|
||||
const res = await fetchImpl(`${DISCORD_API_BASE}${path}`, {
|
||||
headers: { Authorization: `Bot ${token}` },
|
||||
});
|
||||
@@ -61,7 +102,23 @@ export async function fetchDiscord<T>(
|
||||
const text = await res.text().catch(() => "");
|
||||
const detail = formatDiscordApiErrorText(text);
|
||||
const suffix = detail ? `: ${detail}` : "";
|
||||
throw new Error(`Discord API ${path} failed (${res.status})${suffix}`);
|
||||
const retryAfter = res.status === 429 ? parseRetryAfterSeconds(text, res) : undefined;
|
||||
throw new DiscordApiError(
|
||||
`Discord API ${path} failed (${res.status})${suffix}`,
|
||||
res.status,
|
||||
retryAfter,
|
||||
);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
},
|
||||
{
|
||||
...retryConfig,
|
||||
label: options?.label ?? path,
|
||||
shouldRetry: (err) => err instanceof DiscordApiError && err.status === 429,
|
||||
retryAfterMs: (err) =>
|
||||
err instanceof DiscordApiError && typeof err.retryAfter === "number"
|
||||
? err.retryAfter * 1000
|
||||
: undefined,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { ClawdbotConfig, ReplyToMode } from "../../config/config.js";
|
||||
import { loadConfig } from "../../config/config.js";
|
||||
import { danger, logVerbose, shouldLogVerbose, warn } from "../../globals.js";
|
||||
import { formatErrorMessage } from "../../infra/errors.js";
|
||||
import { createDiscordRetryRunner } from "../../infra/retry-policy.js";
|
||||
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||
import type { RuntimeEnv } from "../../runtime.js";
|
||||
import { resolveDiscordAccount } from "../accounts.js";
|
||||
@@ -62,6 +63,22 @@ function summarizeGuilds(entries?: Record<string, unknown>) {
|
||||
return `${sample.join(", ")}${suffix}`;
|
||||
}
|
||||
|
||||
async function deployDiscordCommands(params: {
|
||||
client: Client;
|
||||
runtime: RuntimeEnv;
|
||||
enabled: boolean;
|
||||
}) {
|
||||
if (!params.enabled) return;
|
||||
const runWithRetry = createDiscordRetryRunner({ verbose: shouldLogVerbose() });
|
||||
try {
|
||||
await runWithRetry(() => params.client.handleDeployRequest(), "command deploy");
|
||||
} catch (err) {
|
||||
params.runtime.error?.(
|
||||
danger(`discord: failed to deploy native commands: ${formatErrorMessage(err)}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
const cfg = opts.config ?? loadConfig();
|
||||
const account = resolveDiscordAccount({
|
||||
@@ -365,7 +382,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
clientId: applicationId,
|
||||
publicKey: "a",
|
||||
token,
|
||||
autoDeploy: nativeEnabled,
|
||||
autoDeploy: false,
|
||||
},
|
||||
{
|
||||
commands,
|
||||
@@ -396,6 +413,8 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
],
|
||||
);
|
||||
|
||||
await deployDiscordCommands({ client, runtime, enabled: nativeEnabled });
|
||||
|
||||
const logger = createSubsystemLogger("discord/monitor");
|
||||
const guildHistories = new Map<string, HistoryEntry[]>();
|
||||
let botUserId: string | undefined;
|
||||
|
||||
Reference in New Issue
Block a user