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 1015

You 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(/]*>[\s\S]*?<\/script>/gi, " ") + .replace(/]*>[\s\S]*?<\/style>/gi, " ") + .replace(/<[^>]+>/g, " ") + .replace(/ /gi, " ") + .replace(/&/gi, "&") + .replace(/</gi, "<") + .replace(/>/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* { + it("falls back on Cloudflare HTML rate limits without logging raw HTML", async () => { + const error = await fetchDiscordGatewayInfo({ + token: "test", + fetchImpl: async () => + new Response("Error 1015rate limited", { + 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(""; - } - return normalized.slice(0, 240); + return summarizeDiscordResponseBody(body, { emptyText: "" }) ?? ""; +} + +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") || diff --git a/extensions/discord/src/probe.intents.test.ts b/extensions/discord/src/probe.intents.test.ts index 97122195730..406ef42118d 100644 --- a/extensions/discord/src/probe.intents.test.ts +++ b/extensions/discord/src/probe.intents.test.ts @@ -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("Error 1015", { + 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("Error 1015", { + status: 429, + headers: { "content-type": "text/html" }, + }); + }); + + await expect(fetchDiscordApplicationId("MTIz.abc.def", 1_000, fetcher)).resolves.toBe("123"); + expect(calls).toBe(0); + }); }); diff --git a/extensions/discord/src/probe.ts b/extensions/discord/src/probe.ts index 63749db7c8c..3b2dd1f3222 100644 --- a/extensions/discord/src/probe.ts +++ b/extensions/discord/src/probe.ts @@ -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 { - 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); } }