refactor: centralize discord api errors

This commit is contained in:
Peter Steinberger
2026-01-20 17:28:15 +00:00
parent 26fcca087b
commit 80e6c070bf
6 changed files with 110 additions and 92 deletions

42
src/discord/api.test.ts Normal file
View File

@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import { fetchDiscord } from "./api.js";
function jsonResponse(body: unknown, status = 200) {
return new Response(JSON.stringify(body), { status });
}
describe("fetchDiscord", () => {
it("formats rate limit payloads without raw JSON", async () => {
const fetcher = async () =>
jsonResponse(
{
message: "You are being rate limited.",
retry_after: 0.631,
global: false,
},
429,
);
let error: unknown;
try {
await fetchDiscord("/users/@me/guilds", "test", fetcher as typeof fetch);
} catch (err) {
error = err;
}
const message = String(error);
expect(message).toContain("Discord API /users/@me/guilds failed (429)");
expect(message).toContain("You are being rate limited.");
expect(message).toContain("retry after 0.6s");
expect(message).not.toContain("{");
expect(message).not.toContain("retry_after");
});
it("preserves non-JSON error text", async () => {
const fetcher = async () => new Response("Not Found", { status: 404 });
await expect(
fetchDiscord("/users/@me/guilds", "test", fetcher as typeof fetch),
).rejects.toThrow("Discord API /users/@me/guilds failed (404): Not Found");
});
});

61
src/discord/api.ts Normal file
View File

@@ -0,0 +1,61 @@
const DISCORD_API_BASE = "https://discord.com/api/v10";
type DiscordApiErrorPayload = {
message?: string;
retry_after?: number;
code?: number;
global?: boolean;
};
function parseDiscordApiErrorPayload(text: string): DiscordApiErrorPayload | null {
const trimmed = text.trim();
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return null;
try {
const payload = JSON.parse(trimmed);
if (payload && typeof payload === "object") return payload as DiscordApiErrorPayload;
} catch {
return null;
}
return null;
}
function formatRetryAfterSeconds(value: number | undefined): string | undefined {
if (value === undefined || !Number.isFinite(value) || value < 0) return undefined;
const rounded = value < 10 ? value.toFixed(1) : Math.round(value).toString();
return `${rounded}s`;
}
function formatDiscordApiErrorText(text: string): string | undefined {
const trimmed = text.trim();
if (!trimmed) return undefined;
const payload = parseDiscordApiErrorPayload(trimmed);
if (!payload) {
const looksJson = trimmed.startsWith("{") && trimmed.endsWith("}");
return looksJson ? "unknown error" : trimmed;
}
const message =
typeof payload.message === "string" && payload.message.trim()
? payload.message.trim()
: "unknown error";
const retryAfter = formatRetryAfterSeconds(
typeof payload.retry_after === "number" ? payload.retry_after : undefined,
);
return retryAfter ? `${message} (retry after ${retryAfter})` : message;
}
export async function fetchDiscord<T>(
path: string,
token: string,
fetcher: typeof fetch = fetch,
): Promise<T> {
const res = await fetcher(`${DISCORD_API_BASE}${path}`, {
headers: { Authorization: `Bot ${token}` },
});
if (!res.ok) {
const text = await res.text().catch(() => "");
const detail = formatDiscordApiErrorText(text);
const suffix = detail ? `: ${detail}` : "";
throw new Error(`Discord API ${path} failed (${res.status})${suffix}`);
}
return (await res.json()) as T;
}

View File

@@ -1,27 +1,15 @@
import type { ChannelDirectoryEntry } from "../channels/plugins/types.js";
import type { DirectoryConfigParams } from "../channels/plugins/directory-config.js";
import { resolveDiscordAccount } from "./accounts.js";
import { fetchDiscord } from "./api.js";
import { normalizeDiscordSlug } from "./monitor/allow-list.js";
import { normalizeDiscordToken } from "./token.js";
const DISCORD_API_BASE = "https://discord.com/api/v10";
type DiscordGuild = { id: string; name: string };
type DiscordUser = { id: string; username: string; global_name?: string; bot?: boolean };
type DiscordMember = { user: DiscordUser; nick?: string | null };
type DiscordChannel = { id: string; name?: string | null };
async function fetchDiscord<T>(path: string, token: string): Promise<T> {
const res = await fetch(`${DISCORD_API_BASE}${path}`, {
headers: { Authorization: `Bot ${token}` },
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`Discord API ${path} failed (${res.status}): ${text || "unknown error"}`);
}
return (await res.json()) as T;
}
function normalizeQuery(value?: string | null): string {
return value?.trim().toLowerCase() ?? "";
}

View File

@@ -14,6 +14,7 @@ import {
import type { ClawdbotConfig, ReplyToMode } from "../../config/config.js";
import { loadConfig } from "../../config/config.js";
import { danger, logVerbose, shouldLogVerbose, warn } from "../../globals.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import type { RuntimeEnv } from "../../runtime.js";
import { resolveDiscordAccount } from "../accounts.js";
@@ -61,54 +62,6 @@ function summarizeGuilds(entries?: Record<string, unknown>) {
return `${sample.join(", ")}${suffix}`;
}
type DiscordApiErrorPayload = {
message?: string;
retry_after?: number;
code?: number;
global?: boolean;
};
function parseDiscordApiErrorPayload(text: string): DiscordApiErrorPayload | null {
const trimmed = text.trim();
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return null;
try {
const payload = JSON.parse(trimmed);
if (payload && typeof payload === "object") return payload as DiscordApiErrorPayload;
} catch {
return null;
}
return null;
}
function formatRetryAfterSeconds(value: number | undefined): string | undefined {
if (value === undefined || !Number.isFinite(value) || value < 0) return undefined;
const rounded = value < 10 ? value.toFixed(1) : Math.round(value).toString();
return `${rounded}s`;
}
function formatDiscordResolveError(err: unknown): string {
const raw = err instanceof Error ? err.message : String(err);
const match = raw.match(/^(Discord API [^]+ failed \(\d+\))(?:\s*:\s*(.*))?$/);
if (!match) return raw;
const prefix = match[1];
const detail = match[2]?.trim();
if (!detail) return prefix;
const payload = parseDiscordApiErrorPayload(detail);
if (!payload) {
const looksJson = detail.startsWith("{") && detail.endsWith("}");
return looksJson ? `${prefix}: unknown error` : `${prefix}: ${detail}`;
}
const message =
typeof payload.message === "string" && payload.message.trim()
? payload.message.trim()
: "unknown error";
const retryAfter = formatRetryAfterSeconds(
typeof payload.retry_after === "number" ? payload.retry_after : undefined,
);
const retryHint = retryAfter ? ` (retry after ${retryAfter})` : "";
return `${prefix}: ${message}${retryHint}`;
}
export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
const cfg = opts.config ?? loadConfig();
const account = resolveDiscordAccount({
@@ -245,7 +198,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
}
} catch (err) {
runtime.log?.(
`discord channel resolve failed; using config entries. ${formatDiscordResolveError(err)}`,
`discord channel resolve failed; using config entries. ${formatErrorMessage(err)}`,
);
}
}
@@ -273,7 +226,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
summarizeMapping("discord users", mapping, unresolved, runtime);
} catch (err) {
runtime.log?.(
`discord user resolve failed; using config entries. ${formatDiscordResolveError(err)}`,
`discord user resolve failed; using config entries. ${formatErrorMessage(err)}`,
);
}
}
@@ -355,9 +308,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
summarizeMapping("discord channel users", mapping, unresolved, runtime);
} catch (err) {
runtime.log?.(
`discord channel user resolve failed; using config entries. ${formatDiscordResolveError(
err,
)}`,
`discord channel user resolve failed; using config entries. ${formatErrorMessage(err)}`,
);
}
}

View File

@@ -1,10 +1,9 @@
import type { RESTGetAPIChannelResult, RESTGetAPIGuildChannelsResult } from "discord-api-types/v10";
import { fetchDiscord } from "./api.js";
import { normalizeDiscordSlug } from "./monitor/allow-list.js";
import { normalizeDiscordToken } from "./token.js";
const DISCORD_API_BASE = "https://discord.com/api/v10";
type DiscordGuildSummary = {
id: string;
name: string;
@@ -60,17 +59,6 @@ function parseDiscordChannelInput(raw: string): {
return { guild: trimmed, guildOnly: true };
}
async function fetchDiscord<T>(path: string, token: string, fetcher: typeof fetch): Promise<T> {
const res = await fetcher(`${DISCORD_API_BASE}${path}`, {
headers: { Authorization: `Bot ${token}` },
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`Discord API ${path} failed (${res.status}): ${text || "unknown error"}`);
}
return (await res.json()) as T;
}
async function listGuilds(token: string, fetcher: typeof fetch): Promise<DiscordGuildSummary[]> {
const raw = await fetchDiscord<Array<{ id: string; name: string }>>(
"/users/@me/guilds",

View File

@@ -1,8 +1,7 @@
import { fetchDiscord } from "./api.js";
import { normalizeDiscordSlug } from "./monitor/allow-list.js";
import { normalizeDiscordToken } from "./token.js";
const DISCORD_API_BASE = "https://discord.com/api/v10";
type DiscordGuildSummary = {
id: string;
name: string;
@@ -54,17 +53,6 @@ function parseDiscordUserInput(raw: string): {
return { userName: trimmed.replace(/^@/, "") };
}
async function fetchDiscord<T>(path: string, token: string, fetcher: typeof fetch): Promise<T> {
const res = await fetcher(`${DISCORD_API_BASE}${path}`, {
headers: { Authorization: `Bot ${token}` },
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`Discord API ${path} failed (${res.status}): ${text || "unknown error"}`);
}
return (await res.json()) as T;
}
async function listGuilds(token: string, fetcher: typeof fetch): Promise<DiscordGuildSummary[]> {
const raw = await fetchDiscord<Array<{ id: string; name: string }>>(
"/users/@me/guilds",