mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:20:43 +00:00
fix: quiet Discord slash command deploy rate limits
This commit is contained in:
@@ -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.
|
||||
|
||||
265
extensions/discord/src/monitor/provider.deploy-errors.ts
Normal file
265
extensions/discord/src/monitor/provider.deploy-errors.ts
Normal 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))
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user