fix: quiet Discord slash command deploy rate limits

This commit is contained in:
Peter Steinberger
2026-04-30 21:12:46 +01:00
parent 027ea5f08b
commit bb3a0c9545
5 changed files with 346 additions and 152 deletions

View File

@@ -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.

View File

@@ -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<string, unknown> | 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<string, unknown>) : null;
}
function readDiscordDeployObjectField(value: unknown, field: string): unknown {
return value && typeof value === "object" && field in value
? (value as Record<string, unknown>)[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))
);
}

View File

@@ -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<RestMethodName, RestMethod>;
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<string, unknown> | 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<string, unknown>) : 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 {

View File

@@ -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,

View File

@@ -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";