mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 07:10:43 +00:00
fix(discord): cool down Cloudflare 429 responses
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
38
extensions/discord/src/error-body.ts
Normal file
38
extensions/discord/src/error-body.ts
Normal 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(/ /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*<!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")
|
||||
);
|
||||
}
|
||||
29
extensions/discord/src/monitor/gateway-metadata.test.ts
Normal file
29
extensions/discord/src/monitor/gateway-metadata.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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") ||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user