diff --git a/CHANGELOG.md b/CHANGELOG.md
index ebc4fb648da..6ded4891c4f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -60,6 +60,7 @@ Docs: https://docs.openclaw.ai
- Ollama: keep explicit local model runs on target-provider runtime hooks when PI discovery is skipped, so one-shot Ollama calls no longer cold-load unrelated provider runtimes before streaming. Fixes #74078. Thanks @sakalaboator.
- Slack/prompts: rely on Slack `interactiveReplies` guidance instead of generic `inlineButtons` config hints so enabled Slack button directives are not contradicted. Fixes #46647. Thanks @jeremykoerber.
- Slack/reactions: treat duplicate `already_reacted` responses as idempotent success so repeated agent reaction adds no longer surface as tool failures. Fixes #69005. Thanks @shipitsteven and @martingarramon.
+- Channels/Discord: cool down Cloudflare/Error 1015 HTML 429 REST failures during startup application lookup and gateway metadata fetches, sanitizing HTML bodies before logging and honoring Retry-After before falling back to a conservative cooldown. Fixes #38853. Thanks @djgeorg3 and @Garyko0730.
- Slack/tools: expose `fileId` in the shared message tool schema so `download-file` can receive Slack attachment IDs from inbound placeholders. Fixes #45574. Thanks @chadvegas.
- Exec: reject invalid per-call `host` values instead of silently falling back to the default target, so hostname-like values fail before commands run. Fixes #74426. Thanks @scr00ge-00 and @vyctorbrzezowski.
- Google/Gemini: send non-empty placeholder content when a Gemini run is triggered with empty or filtered user content, avoiding `contents is not specified` API errors. Thanks @CaoYuhaoCarl.
diff --git a/extensions/discord/src/api.test.ts b/extensions/discord/src/api.test.ts
index 07bf2a9aab6..28acd657e02 100644
--- a/extensions/discord/src/api.test.ts
+++ b/extensions/discord/src/api.test.ts
@@ -1,6 +1,6 @@
import { withFetchPreconnect } from "openclaw/plugin-sdk/test-env";
import { beforeEach, describe, expect, it, vi } from "vitest";
-import { fetchDiscord } from "./api.js";
+import { DiscordApiError, fetchDiscord } from "./api.js";
import { jsonResponse } from "./test-http-helpers.js";
describe("fetchDiscord", () => {
@@ -46,6 +46,60 @@ describe("fetchDiscord", () => {
).rejects.toThrow("Discord API /users/@me/guilds failed (404): Not Found");
});
+ it("sanitizes Cloudflare HTML rate limits and applies a fallback cooldown", async () => {
+ const fetcher = withFetchPreconnect(
+ async () =>
+ new Response(
+ "
Error 1015You are being rate limited
",
+ { status: 429, headers: { "content-type": "text/html" } },
+ ),
+ );
+
+ let error: unknown;
+ try {
+ await fetchDiscord("/users/@me/guilds", "test", fetcher, {
+ retry: { attempts: 1 },
+ });
+ } catch (err) {
+ error = err;
+ }
+
+ expect(error).toBeInstanceOf(DiscordApiError);
+ expect((error as DiscordApiError).retryAfter).toBe(60);
+ const message = String(error);
+ expect(message).toContain("Discord API /users/@me/guilds failed (429)");
+ expect(message).toContain("rate limited by Discord upstream");
+ expect(message).toContain("Error 1015");
+ expect(message).not.toContain(" {
+ const fetcher = withFetchPreconnect(
+ async () =>
+ new Response("Error 1015rate limited", {
+ status: 429,
+ headers: { "content-type": "text/html", "retry-after": "7" },
+ }),
+ );
+
+ let error: unknown;
+ try {
+ await fetchDiscord("/oauth2/applications/@me", "test", fetcher, {
+ retry: { attempts: 1 },
+ });
+ } catch (err) {
+ error = err;
+ }
+
+ expect(error).toBeInstanceOf(DiscordApiError);
+ expect((error as DiscordApiError).retryAfter).toBe(7);
+ const message = String(error);
+ expect(message).toContain("Discord API /oauth2/applications/@me failed (429)");
+ expect(message).toContain("Error 1015");
+ expect(message).not.toContain(" {
let calls = 0;
const fetcher = withFetchPreconnect(async () => {
diff --git a/extensions/discord/src/api.ts b/extensions/discord/src/api.ts
index 8c4c7703f3b..3ac9807fc73 100644
--- a/extensions/discord/src/api.ts
+++ b/extensions/discord/src/api.ts
@@ -4,14 +4,16 @@ import {
retryAsync,
type RetryConfig,
} from "openclaw/plugin-sdk/retry-runtime";
+import { isDiscordHtmlResponseBody, summarizeDiscordResponseBody } from "./error-body.js";
const DISCORD_API_BASE = "https://discord.com/api/v10";
const DISCORD_API_RETRY_DEFAULTS = {
attempts: 3,
minDelayMs: 500,
- maxDelayMs: 30_000,
+ maxDelayMs: 5 * 60_000,
jitter: 0.1,
};
+const DISCORD_API_429_FALLBACK_RETRY_AFTER_SECONDS = 60;
type DiscordApiErrorPayload = {
message?: string;
@@ -50,7 +52,14 @@ function parseRetryAfterSeconds(text: string, response: Response): number | unde
return undefined;
}
const parsed = Number(header);
- return Number.isFinite(parsed) ? parsed : undefined;
+ if (Number.isFinite(parsed) && parsed >= 0) {
+ return parsed;
+ }
+ const retryAt = Date.parse(header);
+ if (!Number.isFinite(retryAt)) {
+ return undefined;
+ }
+ return Math.max(0, (retryAt - Date.now()) / 1000);
}
function formatRetryAfterSeconds(value: number | undefined): string | undefined {
@@ -61,7 +70,7 @@ function formatRetryAfterSeconds(value: number | undefined): string | undefined
return `${rounded}s`;
}
-function formatDiscordApiErrorText(text: string): string | undefined {
+function formatDiscordApiErrorText(text: string, response: Response): string | undefined {
const trimmed = text.trim();
if (!trimmed) {
return undefined;
@@ -69,7 +78,17 @@ function formatDiscordApiErrorText(text: string): string | undefined {
const payload = parseDiscordApiErrorPayload(trimmed);
if (!payload) {
const looksJson = trimmed.startsWith("{") && trimmed.endsWith("}");
- return looksJson ? "unknown error" : trimmed;
+ if (looksJson) {
+ return "unknown error";
+ }
+ const summary = summarizeDiscordResponseBody(trimmed);
+ if (isDiscordHtmlResponseBody(trimmed, response.headers.get("content-type"))) {
+ if (!summary) {
+ return response.status === 429 ? "rate limited by Discord upstream" : undefined;
+ }
+ return response.status === 429 ? `rate limited by Discord upstream: ${summary}` : summary;
+ }
+ return summary;
}
const message =
typeof payload.message === "string" && payload.message.trim()
@@ -92,6 +111,16 @@ export class DiscordApiError extends Error {
}
}
+function getDiscordApiRetryAfterMs(
+ err: unknown,
+ retryConfig: Required,
+): number | undefined {
+ if (!(err instanceof DiscordApiError) || typeof err.retryAfter !== "number") {
+ return undefined;
+ }
+ return Math.min(Math.max(0, err.retryAfter * 1000), retryConfig.maxDelayMs);
+}
+
export type DiscordFetchOptions = {
retry?: RetryConfig;
label?: string;
@@ -116,9 +145,12 @@ export async function fetchDiscord(
});
if (!res.ok) {
const text = await res.text().catch(() => "");
- const detail = formatDiscordApiErrorText(text);
+ const detail = formatDiscordApiErrorText(text, res);
const suffix = detail ? `: ${detail}` : "";
- const retryAfter = res.status === 429 ? parseRetryAfterSeconds(text, res) : undefined;
+ const retryAfter =
+ res.status === 429
+ ? (parseRetryAfterSeconds(text, res) ?? DISCORD_API_429_FALLBACK_RETRY_AFTER_SECONDS)
+ : undefined;
throw new DiscordApiError(
`Discord API ${path} failed (${res.status})${suffix}`,
res.status,
@@ -131,10 +163,7 @@ export async function fetchDiscord(
...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,
+ retryAfterMs: (err) => getDiscordApiRetryAfterMs(err, retryConfig),
},
);
}
diff --git a/extensions/discord/src/error-body.ts b/extensions/discord/src/error-body.ts
new file mode 100644
index 00000000000..fd2dbca6df4
--- /dev/null
+++ b/extensions/discord/src/error-body.ts
@@ -0,0 +1,38 @@
+const DISCORD_RESPONSE_BODY_SUMMARY_MAX_CHARS = 240;
+
+export function summarizeDiscordResponseBody(
+ body: string,
+ opts: { emptyText?: string } = {},
+): string | undefined {
+ const summary = body
+ .replace(/