From bb3a0c954505145e1e0d00dc60f9eb222059dbb7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 30 Apr 2026 21:12:46 +0100 Subject: [PATCH] fix: quiet Discord slash command deploy rate limits --- CHANGELOG.md | 1 + .../src/monitor/provider.deploy-errors.ts | 265 ++++++++++++++++++ .../discord/src/monitor/provider.deploy.ts | 184 +++--------- .../discord/src/monitor/provider.test.ts | 46 +++ extensions/discord/src/monitor/provider.ts | 2 +- 5 files changed, 346 insertions(+), 152 deletions(-) create mode 100644 extensions/discord/src/monitor/provider.deploy-errors.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 38076edbde4..ce64656b14d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Discord: collapse repeated native slash-command deploy rate-limit startup logs into one non-fatal warning while keeping per-request REST timing in verbose output. Thanks @discord. - Providers/OpenAI Codex: preserve existing wrapped Codex streams during OpenAI attribution so PI OAuth bearer injection reaches ChatGPT/Codex Responses, and strip native Codex-only unsupported payload fields without touching custom compatible endpoints. (#75111) Thanks @keshavbotagent. - Agents/tool-result guard: use the resolved runtime context token budget for non-context-engine tool-result overflow checks, so long tool-heavy sessions no longer compact early when `contextTokens` is larger than native `contextWindow`. Fixes #74917. Thanks @kAIborg24. - Gateway/systemd: exit with sysexits 78 for supervised lock and `EADDRINUSE` conflicts so `RestartPreventExitStatus=78` stops `Restart=always` restart loops instead of repeatedly reloading plugins against an occupied port. Fixes #75115. Thanks @yhyatt. diff --git a/extensions/discord/src/monitor/provider.deploy-errors.ts b/extensions/discord/src/monitor/provider.deploy-errors.ts new file mode 100644 index 00000000000..8bed9508e8b --- /dev/null +++ b/extensions/discord/src/monitor/provider.deploy-errors.ts @@ -0,0 +1,265 @@ +import { inspect } from "node:util"; +import { formatDurationSeconds } from "openclaw/plugin-sdk/runtime-env"; +import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime"; +import { RateLimitError } from "../internal/discord.js"; + +const DISCORD_DEPLOY_REJECTED_ENTRY_LIMIT = 3; + +export type DiscordDeployErrorLike = { + status?: unknown; + statusCode?: unknown; + discordCode?: unknown; + retryAfter?: unknown; + scope?: unknown; + rawBody?: unknown; + deployRequestBody?: unknown; +}; + +export type DiscordDeployRateLimitDetails = { + status?: number; + retryAfterMs?: number; + scope?: string; + discordCode?: number | string; +}; + +export function attachDiscordDeployRequestBody(err: unknown, body: unknown) { + if (!err || typeof err !== "object" || body === undefined) { + return; + } + const deployErr = err as DiscordDeployErrorLike; + if (deployErr.deployRequestBody === undefined) { + deployErr.deployRequestBody = body; + } +} + +function stringifyDiscordDeployField(value: unknown): string { + if (typeof value === "string") { + return JSON.stringify(value); + } + try { + return JSON.stringify(value); + } catch { + return inspect(value, { depth: 2, breakLength: 120 }); + } +} + +function readDiscordDeployRejectedFields(value: unknown): string[] { + if (Array.isArray(value)) { + return value.filter((entry): entry is string => typeof entry === "string").slice(0, 6); + } + if (!value || typeof value !== "object") { + return []; + } + return Object.keys(value).slice(0, 6); +} + +function resolveDiscordRejectedDeployEntriesSource( + rawBody: unknown, +): Record | null { + if (!rawBody || typeof rawBody !== "object") { + return null; + } + const payload = rawBody as { errors?: unknown }; + const errors = payload.errors && typeof payload.errors === "object" ? payload.errors : undefined; + const source = errors ?? rawBody; + return source && typeof source === "object" ? (source as Record) : null; +} + +function readDiscordDeployObjectField(value: unknown, field: string): unknown { + return value && typeof value === "object" && field in value + ? (value as Record)[field] + : undefined; +} + +function readFiniteNumber(value: unknown): number | undefined { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (typeof value === "string" && value.trim().length > 0) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; + } + return undefined; +} + +export function resolveDiscordDeployRateLimitDetails( + err: unknown, +): DiscordDeployRateLimitDetails | undefined { + if (!err || typeof err !== "object") { + return undefined; + } + const deployErr = err as DiscordDeployErrorLike; + const status = readFiniteNumber(deployErr.status) ?? readFiniteNumber(deployErr.statusCode); + const retryAfterSeconds = + readFiniteNumber(deployErr.retryAfter) ?? + readFiniteNumber(readDiscordDeployObjectField(deployErr.rawBody, "retry_after")); + const isRateLimit = + err instanceof RateLimitError || status === 429 || retryAfterSeconds !== undefined; + if (!isRateLimit) { + return undefined; + } + const rawGlobal = readDiscordDeployObjectField(deployErr.rawBody, "global"); + const scope = + typeof deployErr.scope === "string" && deployErr.scope.trim().length > 0 + ? deployErr.scope + : rawGlobal === true + ? "global" + : rawGlobal === false + ? "route" + : undefined; + const discordCode = + typeof deployErr.discordCode === "number" || typeof deployErr.discordCode === "string" + ? deployErr.discordCode + : undefined; + return { + status, + retryAfterMs: + retryAfterSeconds === undefined ? undefined : Math.max(0, retryAfterSeconds * 1000), + scope, + discordCode, + }; +} + +export function formatDiscordDeployRateLimitDetails(err: unknown): string { + const rateLimit = resolveDiscordDeployRateLimitDetails(err); + if (!rateLimit) { + return ""; + } + const details: string[] = []; + if (typeof rateLimit.status === "number") { + details.push(`status=${rateLimit.status}`); + } + if (typeof rateLimit.retryAfterMs === "number") { + details.push( + `retryAfter=${formatDurationSeconds(rateLimit.retryAfterMs, { + decimals: 1, + })}`, + ); + } + if (rateLimit.scope) { + details.push(`scope=${rateLimit.scope}`); + } + if (typeof rateLimit.discordCode === "number" || typeof rateLimit.discordCode === "string") { + details.push(`code=${rateLimit.discordCode}`); + } + return details.length > 0 ? ` (${details.join(", ")})` : ""; +} + +export function formatDiscordDeployRateLimitWarning( + err: unknown, + accountId: string, +): string | undefined { + const rateLimit = resolveDiscordDeployRateLimitDetails(err); + if (!rateLimit) { + return undefined; + } + const parts = [`discord: native slash command deploy rate limited for ${accountId}`]; + if (typeof rateLimit.retryAfterMs === "number") { + parts.push( + `retry after ${formatDurationSeconds(rateLimit.retryAfterMs, { + decimals: 1, + })}`, + ); + } + if (rateLimit.scope) { + parts.push(`scope=${rateLimit.scope}`); + } + if (typeof rateLimit.discordCode === "number" || typeof rateLimit.discordCode === "string") { + parts.push(`code=${rateLimit.discordCode}`); + } + return `${parts.join("; ")}. Existing slash commands stay active. Message send/receive is unaffected.`; +} + +function formatDiscordRejectedDeployEntries(params: { + rawBody: unknown; + requestBody: unknown; +}): string[] { + const requestBody = Array.isArray(params.requestBody) ? params.requestBody : null; + const rejectedEntriesSource = resolveDiscordRejectedDeployEntriesSource(params.rawBody); + if (!rejectedEntriesSource || !requestBody || requestBody.length === 0) { + return []; + } + const rawEntries = Object.entries(rejectedEntriesSource).filter(([key]) => /^\d+$/.test(key)); + return rawEntries.slice(0, DISCORD_DEPLOY_REJECTED_ENTRY_LIMIT).flatMap(([key, value]) => { + const index = Number.parseInt(key, 10); + if (!Number.isFinite(index) || index < 0 || index >= requestBody.length) { + return []; + } + const command = requestBody[index]; + if (!command || typeof command !== "object") { + return [`#${index} fields=${readDiscordDeployRejectedFields(value).join("|") || "unknown"}`]; + } + const payload = command as { + name?: unknown; + description?: unknown; + options?: unknown; + }; + const parts = [ + `#${index}`, + `fields=${readDiscordDeployRejectedFields(value).join("|") || "unknown"}`, + ]; + if (typeof payload.name === "string" && payload.name.trim().length > 0) { + parts.push(`name=${payload.name}`); + } + if (payload.description !== undefined) { + parts.push(`description=${stringifyDiscordDeployField(payload.description)}`); + } + if (Array.isArray(payload.options) && payload.options.length > 0) { + parts.push(`options=${payload.options.length}`); + } + return [parts.join(" ")]; + }); +} + +export function formatDiscordDeployErrorDetails(err: unknown): string { + if (!err || typeof err !== "object") { + return ""; + } + const rateLimitDetails = formatDiscordDeployRateLimitDetails(err); + if (rateLimitDetails) { + return rateLimitDetails; + } + const status = (err as DiscordDeployErrorLike).status; + const discordCode = (err as DiscordDeployErrorLike).discordCode; + const rawBody = (err as DiscordDeployErrorLike).rawBody; + const requestBody = (err as DiscordDeployErrorLike).deployRequestBody; + const details: string[] = []; + if (typeof status === "number") { + details.push(`status=${status}`); + } + if (typeof discordCode === "number" || typeof discordCode === "string") { + details.push(`code=${discordCode}`); + } + if (rawBody !== undefined) { + let bodyText = ""; + try { + bodyText = JSON.stringify(rawBody); + } catch { + bodyText = + typeof rawBody === "string" ? rawBody : inspect(rawBody, { depth: 3, breakLength: 120 }); + } + if (bodyText) { + const maxLen = 800; + const trimmed = bodyText.length > maxLen ? `${bodyText.slice(0, maxLen)}...` : bodyText; + details.push(`body=${trimmed}`); + } + } + const rejectedEntries = formatDiscordRejectedDeployEntries({ rawBody, requestBody }); + if (rejectedEntries.length > 0) { + details.push(`rejected=${rejectedEntries.join("; ")}`); + } + return details.length > 0 ? ` (${details.join(", ")})` : ""; +} + +export function isDiscordDeployDailyCreateLimit(err: unknown): boolean { + if (!err || typeof err !== "object") { + return false; + } + const deployErr = err as DiscordDeployErrorLike; + const discordCode = readFiniteNumber(deployErr.discordCode); + const rawCode = readFiniteNumber(readDiscordDeployObjectField(deployErr.rawBody, "code")); + return ( + (discordCode === 30034 || rawCode === 30034) && + /daily application command creates/i.test(formatErrorMessage(err)) + ); +} diff --git a/extensions/discord/src/monitor/provider.deploy.ts b/extensions/discord/src/monitor/provider.deploy.ts index 39e5eced59e..5a8d030e1f8 100644 --- a/extensions/discord/src/monitor/provider.deploy.ts +++ b/extensions/discord/src/monitor/provider.deploy.ts @@ -1,147 +1,20 @@ -import { inspect } from "node:util"; -import { warn, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; +import { formatDurationSeconds, warn, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime"; +import { Client, overwriteApplicationCommands, type RequestClient } from "../internal/discord.js"; import { - Client, - overwriteApplicationCommands, - RateLimitError, - type RequestClient, -} from "../internal/discord.js"; + attachDiscordDeployRequestBody, + formatDiscordDeployErrorDetails, + formatDiscordDeployRateLimitDetails, + formatDiscordDeployRateLimitWarning, + isDiscordDeployDailyCreateLimit, + resolveDiscordDeployRateLimitDetails, +} from "./provider.deploy-errors.js"; import { logDiscordStartupPhase } from "./provider.startup-log.js"; -const DISCORD_DEPLOY_REJECTED_ENTRY_LIMIT = 3; - -type DiscordDeployErrorLike = { - status?: unknown; - discordCode?: unknown; - rawBody?: unknown; - deployRequestBody?: unknown; -}; - type RestMethodName = "get" | "post" | "put" | "patch" | "delete"; type RestMethod = RequestClient[RestMethodName]; type RestMethodMap = Record; -function attachDiscordDeployRequestBody(err: unknown, body: unknown) { - if (!err || typeof err !== "object" || body === undefined) { - return; - } - const deployErr = err as DiscordDeployErrorLike; - if (deployErr.deployRequestBody === undefined) { - deployErr.deployRequestBody = body; - } -} - -function stringifyDiscordDeployField(value: unknown): string { - if (typeof value === "string") { - return JSON.stringify(value); - } - try { - return JSON.stringify(value); - } catch { - return inspect(value, { depth: 2, breakLength: 120 }); - } -} - -function readDiscordDeployRejectedFields(value: unknown): string[] { - if (Array.isArray(value)) { - return value.filter((entry): entry is string => typeof entry === "string").slice(0, 6); - } - if (!value || typeof value !== "object") { - return []; - } - return Object.keys(value).slice(0, 6); -} - -function resolveDiscordRejectedDeployEntriesSource( - rawBody: unknown, -): Record | null { - if (!rawBody || typeof rawBody !== "object") { - return null; - } - const payload = rawBody as { errors?: unknown }; - const errors = payload.errors && typeof payload.errors === "object" ? payload.errors : undefined; - const source = errors ?? rawBody; - return source && typeof source === "object" ? (source as Record) : null; -} - -function formatDiscordRejectedDeployEntries(params: { - rawBody: unknown; - requestBody: unknown; -}): string[] { - const requestBody = Array.isArray(params.requestBody) ? params.requestBody : null; - const rejectedEntriesSource = resolveDiscordRejectedDeployEntriesSource(params.rawBody); - if (!rejectedEntriesSource || !requestBody || requestBody.length === 0) { - return []; - } - const rawEntries = Object.entries(rejectedEntriesSource).filter(([key]) => /^\d+$/.test(key)); - return rawEntries.slice(0, DISCORD_DEPLOY_REJECTED_ENTRY_LIMIT).flatMap(([key, value]) => { - const index = Number.parseInt(key, 10); - if (!Number.isFinite(index) || index < 0 || index >= requestBody.length) { - return []; - } - const command = requestBody[index]; - if (!command || typeof command !== "object") { - return [`#${index} fields=${readDiscordDeployRejectedFields(value).join("|") || "unknown"}`]; - } - const payload = command as { - name?: unknown; - description?: unknown; - options?: unknown; - }; - const parts = [ - `#${index}`, - `fields=${readDiscordDeployRejectedFields(value).join("|") || "unknown"}`, - ]; - if (typeof payload.name === "string" && payload.name.trim().length > 0) { - parts.push(`name=${payload.name}`); - } - if (payload.description !== undefined) { - parts.push(`description=${stringifyDiscordDeployField(payload.description)}`); - } - if (Array.isArray(payload.options) && payload.options.length > 0) { - parts.push(`options=${payload.options.length}`); - } - return [parts.join(" ")]; - }); -} - -export function formatDiscordDeployErrorDetails(err: unknown): string { - if (!err || typeof err !== "object") { - return ""; - } - const status = (err as DiscordDeployErrorLike).status; - const discordCode = (err as DiscordDeployErrorLike).discordCode; - const rawBody = (err as DiscordDeployErrorLike).rawBody; - const requestBody = (err as DiscordDeployErrorLike).deployRequestBody; - const details: string[] = []; - if (typeof status === "number") { - details.push(`status=${status}`); - } - if (typeof discordCode === "number" || typeof discordCode === "string") { - details.push(`code=${discordCode}`); - } - if (rawBody !== undefined) { - let bodyText = ""; - try { - bodyText = JSON.stringify(rawBody); - } catch { - bodyText = - typeof rawBody === "string" ? rawBody : inspect(rawBody, { depth: 3, breakLength: 120 }); - } - if (bodyText) { - const maxLen = 800; - const trimmed = bodyText.length > maxLen ? `${bodyText.slice(0, maxLen)}...` : bodyText; - details.push(`body=${trimmed}`); - } - } - const rejectedEntries = formatDiscordRejectedDeployEntries({ rawBody, requestBody }); - if (rejectedEntries.length > 0) { - details.push(`rejected=${rejectedEntries.join("; ")}`); - } - return details.length > 0 ? ` (${details.join(", ")})` : ""; -} - function readDeployRequestBody(data?: unknown): unknown { return data && typeof data === "object" && "body" in data ? (data as { body?: unknown }).body @@ -179,10 +52,21 @@ function wrapDeployRestMethod(params: { return result; } catch (err) { attachDiscordDeployRequestBody(err, body); - const details = formatDiscordDeployErrorDetails(err); - params.runtime.error?.( - `discord startup [${params.accountId}] native-slash-command-deploy-rest:${params.method}:error ${Math.max(0, Date.now() - params.startupStartedAt)}ms path=${path} requestMs=${Date.now() - startedAt} error=${formatErrorMessage(err)}${details}`, - ); + const rateLimitDetails = formatDiscordDeployRateLimitDetails(err); + if (rateLimitDetails) { + if (params.shouldLogVerbose()) { + params.runtime.log?.( + warn( + `discord startup [${params.accountId}] native-slash-command-deploy-rest:${params.method}:rate-limited ${Math.max(0, Date.now() - params.startupStartedAt)}ms path=${path} requestMs=${Date.now() - startedAt}${rateLimitDetails}`, + ), + ); + } + } else { + const details = formatDiscordDeployErrorDetails(err); + params.runtime.error?.( + `discord startup [${params.accountId}] native-slash-command-deploy-rest:${params.method}:error ${Math.max(0, Date.now() - params.startupStartedAt)}ms path=${path} requestMs=${Date.now() - startedAt} error=${formatErrorMessage(err)}${details}`, + ); + } throw err; } }; @@ -237,10 +121,6 @@ export async function deployDiscordCommands(params: { const maxAttempts = 3; const maxRetryDelayMs = 15_000; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, Math.max(0, ms))); - const isDailyCreateLimit = (err: unknown) => - err instanceof RateLimitError && - err.discordCode === 30034 && - /daily application command creates/i.test(err.message); const restoreDeployRestLogging = installDeployRestLogging({ rest: params.client.rest, runtime: params.runtime, @@ -254,7 +134,7 @@ export async function deployDiscordCommands(params: { await params.client.deployCommands({ mode: "reconcile" }); return; } catch (err) { - if (isDailyCreateLimit(err)) { + if (isDiscordDeployDailyCreateLimit(err)) { params.runtime.log?.( warn( `discord: native slash command deploy skipped for ${accountId}; daily application command create limit reached. Existing slash commands stay active until Discord resets the quota. Message send/receive is unaffected.`, @@ -262,31 +142,33 @@ export async function deployDiscordCommands(params: { ); return; } - if (!(err instanceof RateLimitError) || attempt >= maxAttempts) { + const rateLimitDetails = resolveDiscordDeployRateLimitDetails(err); + if (!rateLimitDetails || attempt >= maxAttempts) { throw err; } - const retryAfterMs = Math.max(0, Math.ceil(err.retryAfter * 1000)); + const retryAfterMs = Math.max(0, Math.ceil(rateLimitDetails.retryAfterMs ?? 0)); if (retryAfterMs > maxRetryDelayMs) { params.runtime.log?.( warn( - `discord: native slash command deploy skipped for ${accountId}; retry_after=${retryAfterMs}ms exceeds startup budget. Existing slash commands stay active. Message send/receive is unaffected.`, + `discord: native slash command deploy skipped for ${accountId}; retry after ${formatDurationSeconds(retryAfterMs, { decimals: 1 })} exceeds startup budget. Existing slash commands stay active. Message send/receive is unaffected.`, ), ); return; } if (params.shouldLogVerbose()) { params.runtime.log?.( - `discord startup [${accountId}] deploy-retry ${Math.max(0, Date.now() - startupStartedAt)}ms attempt=${attempt}/${maxAttempts - 1} retryAfterMs=${retryAfterMs} scope=${err.scope ?? "unknown"} code=${err.discordCode ?? "unknown"}`, + `discord startup [${accountId}] deploy-retry ${Math.max(0, Date.now() - startupStartedAt)}ms attempt=${attempt}/${maxAttempts - 1} retryAfterMs=${retryAfterMs} scope=${rateLimitDetails.scope ?? "unknown"} code=${rateLimitDetails.discordCode ?? "unknown"}`, ); } await sleep(retryAfterMs); } } } catch (err) { - const details = formatDiscordDeployErrorDetails(err); + const rateLimitWarning = formatDiscordDeployRateLimitWarning(err, accountId); params.runtime.log?.( warn( - `discord: native slash command deploy warning (not message send): ${formatErrorMessage(err)}${details}`, + rateLimitWarning ?? + `discord: native slash command deploy warning (not message send): ${formatErrorMessage(err)}${formatDiscordDeployErrorDetails(err)}`, ), ); } finally { diff --git a/extensions/discord/src/monitor/provider.test.ts b/extensions/discord/src/monitor/provider.test.ts index 50636f1338d..2e56bbc94d2 100644 --- a/extensions/discord/src/monitor/provider.test.ts +++ b/extensions/discord/src/monitor/provider.test.ts @@ -797,6 +797,52 @@ describe("monitorDiscordProvider", () => { ).toBe(true); }); + it("logs repeated native command deploy rate limits as one concise warning", async () => { + const runtime = baseRuntime(); + const rateLimitError = createRateLimitError( + new Response(null, { + status: 429, + }), + { + message: "You are being rate limited.", + retry_after: 0, + global: false, + }, + ); + clientDeployCommandsMock.mockRejectedValue(rateLimitError); + + await monitorDiscordProvider({ + config: baseConfig(), + runtime, + }); + + await vi.waitFor(() => expect(clientDeployCommandsMock).toHaveBeenCalledTimes(3)); + const warningMessages = vi + .mocked(runtime.log) + .mock.calls.map((call) => String(call[0])) + .filter((message) => message.includes("native slash command deploy rate limited")); + expect(warningMessages).toHaveLength(1); + expect(warningMessages[0]).toContain("retry after 0s"); + expect(warningMessages[0]).toContain("Message send/receive is unaffected."); + expect(warningMessages[0]).not.toContain("body="); + expect(runtime.error).not.toHaveBeenCalledWith( + expect.stringContaining("native-slash-command-deploy-rest"), + ); + }); + + it("formats Discord deploy rate limits without raw response bodies", () => { + const details = providerTesting.formatDiscordDeployErrorDetails({ + status: 429, + rawBody: { + message: "You are being rate limited.", + retry_after: 3.172, + global: false, + }, + }); + + expect(details).toBe(" (status=429, retryAfter=3.2s, scope=route)"); + }); + it("formats rejected Discord deploy entries with command details", () => { const details = providerTesting.formatDiscordDeployErrorDetails({ status: 400, diff --git a/extensions/discord/src/monitor/provider.ts b/extensions/discord/src/monitor/provider.ts index 1e63bfa8f30..72a815eaf07 100644 --- a/extensions/discord/src/monitor/provider.ts +++ b/extensions/discord/src/monitor/provider.ts @@ -47,9 +47,9 @@ import { type GetPluginCommandSpecs, } from "./provider.commands.js"; import { logDiscordResolvedConfig } from "./provider.config-log.js"; +import { formatDiscordDeployErrorDetails } from "./provider.deploy-errors.js"; import { clearDiscordNativeCommands, - formatDiscordDeployErrorDetails, runDiscordCommandDeployInBackground, } from "./provider.deploy.js"; import { createDiscordProviderInteractionSurface } from "./provider.interactions.js";