diff --git a/CHANGELOG.md b/CHANGELOG.md index 27c6e82b1..53f572276 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/discord/api.test.ts b/src/discord/api.test.ts index 9ba2d2f68..d06f94bf3 100644 --- a/src/discord/api.test.ts +++ b/src/discord/api.test.ts @@ -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>( + "/users/@me/guilds", + "test", + fetcher as typeof fetch, + { retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0 } }, + ); + + expect(result).toHaveLength(1); + expect(calls).toBe(2); + }); }); diff --git a/src/discord/api.ts b/src/discord/api.ts index de72c8ba1..774342020 100644 --- a/src/discord/api.ts +++ b/src/discord/api.ts @@ -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,23 +65,60 @@ 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( path: string, token: string, fetcher: typeof fetch = fetch, + options?: DiscordFetchOptions, ): Promise { const fetchImpl = resolveFetch(fetcher); if (!fetchImpl) { throw new Error("fetch is not available"); } - const res = await fetchImpl(`${DISCORD_API_BASE}${path}`, { - headers: { Authorization: `Bot ${token}` }, - }); - if (!res.ok) { - const text = await res.text().catch(() => ""); - const detail = formatDiscordApiErrorText(text); - const suffix = detail ? `: ${detail}` : ""; - throw new Error(`Discord API ${path} failed (${res.status})${suffix}`); - } - return (await res.json()) as T; + + const retryConfig = resolveRetryConfig(DISCORD_API_RETRY_DEFAULTS, options?.retry); + return retryAsync( + async () => { + const res = await fetchImpl(`${DISCORD_API_BASE}${path}`, { + headers: { Authorization: `Bot ${token}` }, + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + const detail = formatDiscordApiErrorText(text); + const suffix = detail ? `: ${detail}` : ""; + 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, + }, + ); } diff --git a/src/discord/monitor/provider.ts b/src/discord/monitor/provider.ts index f8122ea38..899622236 100644 --- a/src/discord/monitor/provider.ts +++ b/src/discord/monitor/provider.ts @@ -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) { 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(); let botUserId: string | undefined;