import { resolveFetch } from "../infra/fetch.js"; import { resolveRetryConfig, retryAsync, type RetryConfig } from "../infra/retry.js"; const DISCORD_API_BASE = "https://discord.com/api/v10"; const DISCORD_API_RETRY_DEFAULTS = { attempts: 3, minDelayMs: 500, maxDelayMs: 30_000, jitter: 0.1, }; 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 parseRetryAfterSeconds(text: string, response: Response): number | undefined { const payload = parseDiscordApiErrorPayload(text); const retryAfter = payload && typeof payload.retry_after === "number" && Number.isFinite(payload.retry_after) ? payload.retry_after : undefined; if (retryAfter !== undefined) { return retryAfter; } const header = response.headers.get("Retry-After"); if (!header) { return undefined; } const parsed = Number(header); return Number.isFinite(parsed) ? parsed : undefined; } 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 class DiscordApiError extends Error { status: number; retryAfter?: number; constructor(message: string, status: number, retryAfter?: number) { super(message); this.status = status; this.retryAfter = retryAfter; } } export type DiscordFetchOptions = { retry?: RetryConfig; label?: string; }; export async function fetchDiscord( path: string, token: string, fetcher: typeof fetch = fetch, options?: DiscordFetchOptions, ): Promise { const fetchImpl = resolveFetch(fetcher); if (!fetchImpl) { throw new Error("fetch is not available"); } const retryConfig = resolveRetryConfig(DISCORD_API_RETRY_DEFAULTS, options?.retry); return retryAsync( async () => { const res = await fetchImpl(`${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}` : ""; const retryAfter = res.status === 429 ? parseRetryAfterSeconds(text, res) : undefined; throw new DiscordApiError( `Discord API ${path} failed (${res.status})${suffix}`, res.status, retryAfter, ); } return (await res.json()) as 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, }, ); }