fix(discord): cool down Cloudflare 429 responses

This commit is contained in:
Peter Steinberger
2026-04-29 18:05:59 +01:00
parent d4e88e7a2f
commit 950a9b5500
8 changed files with 243 additions and 51 deletions

View File

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

View File

@@ -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(
"<!doctype html><html><head><title>Error 1015</title></head><body><h1>You are being rate limited</h1><script>raw()</script></body></html>",
{ 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("<html");
expect(message).not.toContain("<script");
});
it("honors Retry-After for Cloudflare HTML application lookup rate limits", async () => {
const fetcher = withFetchPreconnect(
async () =>
new Response("<html><title>Error 1015</title><body>rate limited</body></html>", {
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("<html");
});
it("retries rate limits before succeeding", async () => {
let calls = 0;
const fetcher = withFetchPreconnect(async () => {

View File

@@ -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<RetryConfig>,
): 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<T>(
});
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<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,
retryAfterMs: (err) => getDiscordApiRetryAfterMs(err, retryConfig),
},
);
}

View File

@@ -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(/<script\b[^>]*>[\s\S]*?<\/script>/gi, " ")
.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, " ")
.replace(/<[^>]+>/g, " ")
.replace(/&nbsp;/gi, " ")
.replace(/&amp;/gi, "&")
.replace(/&lt;/gi, "<")
.replace(/&gt;/gi, ">")
.replace(/\s+/g, " ")
.trim();
if (!summary) {
return opts.emptyText;
}
return summary.slice(0, DISCORD_RESPONSE_BODY_SUMMARY_MAX_CHARS);
}
export function isDiscordHtmlResponseBody(body: string, contentType?: string | null): boolean {
return (
/\bhtml\b/i.test(contentType ?? "") ||
/^\s*<!doctype\s+html\b/i.test(body) ||
/^\s*<html\b/i.test(body)
);
}
export function isDiscordRateLimitResponseBody(body: string): boolean {
const normalized = body.toLowerCase();
return (
normalized.includes("error 1015") ||
normalized.includes("cloudflare") ||
normalized.includes("rate limit")
);
}

View File

@@ -0,0 +1,29 @@
import { describe, expect, it, vi } from "vitest";
import { fetchDiscordGatewayInfo, resolveGatewayInfoWithFallback } from "./gateway-metadata.js";
describe("Discord gateway metadata", () => {
it("falls back on Cloudflare HTML rate limits without logging raw HTML", async () => {
const error = await fetchDiscordGatewayInfo({
token: "test",
fetchImpl: async () =>
new Response("<html><title>Error 1015</title><body>rate limited</body></html>", {
status: 429,
headers: { "content-type": "text/html" },
}),
}).catch((err: unknown) => err);
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
const resolved = resolveGatewayInfoWithFallback({ runtime, error });
expect(resolved.usedFallback).toBe(true);
expect(resolved.info.url).toBe("wss://gateway.discord.gg/");
const logs = runtime.log.mock.calls.map((call) => String(call[0])).join("\n");
expect(logs).toContain("gateway metadata lookup failed transiently");
expect(logs).toContain("Error 1015");
expect(logs).not.toContain("<html");
});
});

View File

@@ -3,9 +3,9 @@ import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { captureHttpExchange } from "openclaw/plugin-sdk/proxy-capture";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import { Type } from "typebox";
import { Check, Errors } from "typebox/value";
import { isDiscordRateLimitResponseBody, summarizeDiscordResponseBody } from "../error-body.js";
import { withAbortTimeout } from "./timeouts.js";
const DISCORD_GATEWAY_BOT_URL = "https://discord.com/api/v10/gateway/bot";
@@ -80,18 +80,21 @@ export function resolveDiscordGatewayInfoTimeoutMs(params?: {
}
function summarizeGatewayResponseBody(body: string): string {
const normalized = body.trim().replace(/\s+/g, " ");
if (!normalized) {
return "<empty>";
}
return normalized.slice(0, 240);
return summarizeDiscordResponseBody(body, { emptyText: "<empty>" }) ?? "<empty>";
}
function isDiscordGatewayRateLimitResponse(status: number, body: string): boolean {
return status === 429 && isDiscordRateLimitResponseBody(body);
}
function isTransientDiscordGatewayResponse(status: number, body: string): boolean {
if (status >= 500) {
return true;
}
const normalized = normalizeLowercaseStringOrEmpty(body);
if (isDiscordGatewayRateLimitResponse(status, body)) {
return true;
}
const normalized = body.toLowerCase();
return (
normalized.includes("upstream connect error") ||
normalized.includes("disconnect/reset before headers") ||

View File

@@ -1,5 +1,7 @@
import { withFetchPreconnect } from "openclaw/plugin-sdk/test-env";
import { describe, expect, it } from "vitest";
import { resolveDiscordPrivilegedIntentsFromFlags } from "./probe.js";
import { fetchDiscordApplicationId, resolveDiscordPrivilegedIntentsFromFlags } from "./probe.js";
import { jsonResponse } from "./test-http-helpers.js";
describe("resolveDiscordPrivilegedIntentsFromFlags", () => {
it("reports disabled when no bits set", () => {
@@ -36,4 +38,37 @@ describe("resolveDiscordPrivilegedIntentsFromFlags", () => {
messageContent: "enabled",
});
});
it("retries Cloudflare HTML rate limits during application id lookup", async () => {
let calls = 0;
const fetcher = withFetchPreconnect(async () => {
calls += 1;
if (calls === 1) {
return new Response("<html><title>Error 1015</title></html>", {
status: 429,
headers: { "content-type": "text/html", "retry-after": "0" },
});
}
return jsonResponse({ id: "app-1" });
});
await expect(fetchDiscordApplicationId("unparseable.token", 1_000, fetcher)).resolves.toBe(
"app-1",
);
expect(calls).toBe(2);
});
it("derives application id from parseable tokens before probing REST", async () => {
let calls = 0;
const fetcher = withFetchPreconnect(async () => {
calls += 1;
return new Response("<html><title>Error 1015</title></html>", {
status: 429,
headers: { "content-type": "text/html" },
});
});
await expect(fetchDiscordApplicationId("MTIz.abc.def", 1_000, fetcher)).resolves.toBe("123");
expect(calls).toBe(0);
});
});

View File

@@ -2,6 +2,7 @@ import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-contract";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { resolveFetch } from "openclaw/plugin-sdk/fetch-runtime";
import { fetchWithTimeout } from "openclaw/plugin-sdk/text-runtime";
import { DiscordApiError, fetchDiscord } from "./api.js";
import { normalizeDiscordToken } from "./token.js";
const DISCORD_API_BASE = "https://discord.com/api/v10";
@@ -40,31 +41,29 @@ async function fetchDiscordApplicationMe(
fetcher: typeof fetch,
): Promise<{ id?: string; flags?: number } | undefined> {
try {
const appResponse = await fetchDiscordApplicationMeResponse(token, timeoutMs, fetcher);
if (!appResponse || !appResponse.ok) {
const normalized = normalizeDiscordToken(token, "channels.discord.token");
if (!normalized) {
return undefined;
}
return (await appResponse.json()) as { id?: string; flags?: number };
return await fetchDiscord<{ id?: string; flags?: number }>(
"/oauth2/applications/@me",
normalized,
createDiscordTimeoutFetch(fetcher, timeoutMs),
);
} catch {
return undefined;
}
}
async function fetchDiscordApplicationMeResponse(
token: string,
timeoutMs: number,
fetcher: typeof fetch,
): Promise<Response | undefined> {
const normalized = normalizeDiscordToken(token, "channels.discord.token");
if (!normalized) {
return undefined;
}
return await fetchWithTimeout(
`${DISCORD_API_BASE}/oauth2/applications/@me`,
{ headers: { Authorization: `Bot ${normalized}` } },
timeoutMs,
getResolvedFetch(fetcher),
);
function createDiscordTimeoutFetch(fetcher: typeof fetch, timeoutMs: number): typeof fetch {
const fetchImpl = getResolvedFetch(fetcher);
return ((input: RequestInfo | URL, init?: RequestInit) =>
fetchWithTimeout(
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url,
init ?? {},
timeoutMs,
fetchImpl,
)) as typeof fetch;
}
export function resolveDiscordPrivilegedIntentsFromFlags(
@@ -211,23 +210,27 @@ export async function fetchDiscordApplicationId(
if (!normalized) {
return undefined;
}
const parsedApplicationId = parseApplicationIdFromToken(token);
if (parsedApplicationId) {
return parsedApplicationId;
}
try {
const res = await fetchDiscordApplicationMeResponse(token, timeoutMs, fetcher);
if (!res) {
const json = await fetchDiscord<{ id?: string }>(
"/oauth2/applications/@me",
normalized,
createDiscordTimeoutFetch(fetcher, timeoutMs),
);
if (json?.id) {
return json.id;
}
return undefined;
} catch (error) {
if (error instanceof DiscordApiError) {
if (error.status === 429) {
throw error;
}
return undefined;
}
if (res.ok) {
const json = (await res.json()) as { id?: string };
if (json?.id) {
return json.id;
}
}
// Non-ok HTTP response (401, 403, etc.) — fail fast so credential
// errors surface immediately rather than being masked by the fallback.
return undefined;
} catch {
// Transport / timeout error — fall back to extracting the application
// ID directly from the token to keep the bot starting.
return parseApplicationIdFromToken(token);
}
}