Skip to content

Commit f70ac0c

Browse files
committed
fix: harden discord rate-limit handling
1 parent 09a72f1 commit f70ac0c

4 files changed

Lines changed: 122 additions & 13 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ Docs: https://docs.clawd.bot
3535
- Tailscale: retry serve/funnel with sudo only for permission errors and keep original failure details. (#1551) Thanks @sweepies.
3636
- Agents: add CLI log hint to "agent failed before reply" messages. (#1550) Thanks @sweepies.
3737
- Discord: limit autoThread mention bypass to bot-owned threads; keep ack reactions mention-gated. (#1511) Thanks @pvoo.
38+
- Discord: retry rate-limited allowlist resolution + command deploy to avoid gateway crashes.
3839
- Gateway: accept null optional fields in exec approval requests. (#1511) Thanks @pvoo.
3940
- Exec: honor tools.exec ask/security defaults for elevated approvals (avoid unwanted prompts).
4041
- TUI: forward unknown slash commands (for example, `/context`) to the Gateway.

src/discord/api.test.ts

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@ describe("fetchDiscord", () => {
2020

2121
let error: unknown;
2222
try {
23-
await fetchDiscord("/users/@me/guilds", "test", fetcher as typeof fetch);
23+
await fetchDiscord("/users/@me/guilds", "test", fetcher as typeof fetch, {
24+
retry: { attempts: 1 },
25+
});
2426
} catch (err) {
2527
error = err;
2628
}
@@ -36,7 +38,37 @@ describe("fetchDiscord", () => {
3638
it("preserves non-JSON error text", async () => {
3739
const fetcher = async () => new Response("Not Found", { status: 404 });
3840
await expect(
39-
fetchDiscord("/users/@me/guilds", "test", fetcher as typeof fetch),
41+
fetchDiscord("/users/@me/guilds", "test", fetcher as typeof fetch, {
42+
retry: { attempts: 1 },
43+
}),
4044
).rejects.toThrow("Discord API /users/@me/guilds failed (404): Not Found");
4145
});
46+
47+
it("retries rate limits before succeeding", async () => {
48+
let calls = 0;
49+
const fetcher = async () => {
50+
calls += 1;
51+
if (calls === 1) {
52+
return jsonResponse(
53+
{
54+
message: "You are being rate limited.",
55+
retry_after: 0,
56+
global: false,
57+
},
58+
429,
59+
);
60+
}
61+
return jsonResponse([{ id: "1", name: "Guild" }], 200);
62+
};
63+
64+
const result = await fetchDiscord<Array<{ id: string; name: string }>>(
65+
"/users/@me/guilds",
66+
"test",
67+
fetcher as typeof fetch,
68+
{ retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0 } },
69+
);
70+
71+
expect(result).toHaveLength(1);
72+
expect(calls).toBe(2);
73+
});
4274
});

src/discord/api.ts

Lines changed: 67 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
import { resolveFetch } from "../infra/fetch.js";
2+
import { resolveRetryConfig, retryAsync, type RetryConfig } from "../infra/retry.js";
23

34
const DISCORD_API_BASE = "https://discord.com/api/v10";
5+
const DISCORD_API_RETRY_DEFAULTS = {
6+
attempts: 3,
7+
minDelayMs: 500,
8+
maxDelayMs: 30_000,
9+
jitter: 0.1,
10+
};
411

512
type DiscordApiErrorPayload = {
613
message?: string;
@@ -21,6 +28,19 @@ function parseDiscordApiErrorPayload(text: string): DiscordApiErrorPayload | nul
2128
return null;
2229
}
2330

31+
function parseRetryAfterSeconds(text: string, response: Response): number | undefined {
32+
const payload = parseDiscordApiErrorPayload(text);
33+
const retryAfter =
34+
payload && typeof payload.retry_after === "number" && Number.isFinite(payload.retry_after)
35+
? payload.retry_after
36+
: undefined;
37+
if (retryAfter !== undefined) return retryAfter;
38+
const header = response.headers.get("Retry-After");
39+
if (!header) return undefined;
40+
const parsed = Number(header);
41+
return Number.isFinite(parsed) ? parsed : undefined;
42+
}
43+
2444
function formatRetryAfterSeconds(value: number | undefined): string | undefined {
2545
if (value === undefined || !Number.isFinite(value) || value < 0) return undefined;
2646
const rounded = value < 10 ? value.toFixed(1) : Math.round(value).toString();
@@ -45,23 +65,60 @@ function formatDiscordApiErrorText(text: string): string | undefined {
4565
return retryAfter ? `${message} (retry after ${retryAfter})` : message;
4666
}
4767

68+
export class DiscordApiError extends Error {
69+
status: number;
70+
retryAfter?: number;
71+
72+
constructor(message: string, status: number, retryAfter?: number) {
73+
super(message);
74+
this.status = status;
75+
this.retryAfter = retryAfter;
76+
}
77+
}
78+
79+
export type DiscordFetchOptions = {
80+
retry?: RetryConfig;
81+
label?: string;
82+
};
83+
4884
export async function fetchDiscord<T>(
4985
path: string,
5086
token: string,
5187
fetcher: typeof fetch = fetch,
88+
options?: DiscordFetchOptions,
5289
): Promise<T> {
5390
const fetchImpl = resolveFetch(fetcher);
5491
if (!fetchImpl) {
5592
throw new Error("fetch is not available");
5693
}
57-
const res = await fetchImpl(`${DISCORD_API_BASE}${path}`, {
58-
headers: { Authorization: `Bot ${token}` },
59-
});
60-
if (!res.ok) {
61-
const text = await res.text().catch(() => "");
62-
const detail = formatDiscordApiErrorText(text);
63-
const suffix = detail ? `: ${detail}` : "";
64-
throw new Error(`Discord API ${path} failed (${res.status})${suffix}`);
65-
}
66-
return (await res.json()) as T;
94+
95+
const retryConfig = resolveRetryConfig(DISCORD_API_RETRY_DEFAULTS, options?.retry);
96+
return retryAsync(
97+
async () => {
98+
const res = await fetchImpl(`${DISCORD_API_BASE}${path}`, {
99+
headers: { Authorization: `Bot ${token}` },
100+
});
101+
if (!res.ok) {
102+
const text = await res.text().catch(() => "");
103+
const detail = formatDiscordApiErrorText(text);
104+
const suffix = detail ? `: ${detail}` : "";
105+
const retryAfter = res.status === 429 ? parseRetryAfterSeconds(text, res) : undefined;
106+
throw new DiscordApiError(
107+
`Discord API ${path} failed (${res.status})${suffix}`,
108+
res.status,
109+
retryAfter,
110+
);
111+
}
112+
return (await res.json()) as T;
113+
},
114+
{
115+
...retryConfig,
116+
label: options?.label ?? path,
117+
shouldRetry: (err) => err instanceof DiscordApiError && err.status === 429,
118+
retryAfterMs: (err) =>
119+
err instanceof DiscordApiError && typeof err.retryAfter === "number"
120+
? err.retryAfter * 1000
121+
: undefined,
122+
},
123+
);
67124
}

src/discord/monitor/provider.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type { ClawdbotConfig, ReplyToMode } from "../../config/config.js";
1515
import { loadConfig } from "../../config/config.js";
1616
import { danger, logVerbose, shouldLogVerbose, warn } from "../../globals.js";
1717
import { formatErrorMessage } from "../../infra/errors.js";
18+
import { createDiscordRetryRunner } from "../../infra/retry-policy.js";
1819
import { createSubsystemLogger } from "../../logging/subsystem.js";
1920
import type { RuntimeEnv } from "../../runtime.js";
2021
import { resolveDiscordAccount } from "../accounts.js";
@@ -62,6 +63,22 @@ function summarizeGuilds(entries?: Record<string, unknown>) {
6263
return `${sample.join(", ")}${suffix}`;
6364
}
6465

66+
async function deployDiscordCommands(params: {
67+
client: Client;
68+
runtime: RuntimeEnv;
69+
enabled: boolean;
70+
}) {
71+
if (!params.enabled) return;
72+
const runWithRetry = createDiscordRetryRunner({ verbose: shouldLogVerbose() });
73+
try {
74+
await runWithRetry(() => params.client.handleDeployRequest(), "command deploy");
75+
} catch (err) {
76+
params.runtime.error?.(
77+
danger(`discord: failed to deploy native commands: ${formatErrorMessage(err)}`),
78+
);
79+
}
80+
}
81+
6582
export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
6683
const cfg = opts.config ?? loadConfig();
6784
const account = resolveDiscordAccount({
@@ -365,7 +382,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
365382
clientId: applicationId,
366383
publicKey: "a",
367384
token,
368-
autoDeploy: nativeEnabled,
385+
autoDeploy: false,
369386
},
370387
{
371388
commands,
@@ -396,6 +413,8 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
396413
],
397414
);
398415

416+
await deployDiscordCommands({ client, runtime, enabled: nativeEnabled });
417+
399418
const logger = createSubsystemLogger("discord/monitor");
400419
const guildHistories = new Map<string, HistoryEntry[]>();
401420
let botUserId: string | undefined;

0 commit comments

Comments
 (0)