fix: add provider retry policy

This commit is contained in:
Peter Steinberger
2026-01-07 17:48:19 +00:00
parent 8db522d6a6
commit de55f4e111
15 changed files with 779 additions and 101 deletions

View File

@@ -1,3 +1,4 @@
import { RateLimitError } from "@buape/carbon";
import { PermissionFlagsBits, Routes } from "discord-api-types/v10";
import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -662,3 +663,133 @@ describe("sendPollDiscord", () => {
);
});
});
function createMockRateLimitError(retryAfter = 0.001): RateLimitError {
const response = new Response(null, {
status: 429,
headers: {
"X-RateLimit-Scope": "user",
"X-RateLimit-Bucket": "test-bucket",
},
});
return new RateLimitError(response, {
message: "You are being rate limited.",
retry_after: retryAfter,
global: false,
});
}
describe("retry rate limits", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("retries on Discord rate limits", async () => {
const { rest, postMock } = makeRest();
const rateLimitError = createMockRateLimitError(0);
postMock
.mockRejectedValueOnce(rateLimitError)
.mockResolvedValueOnce({ id: "msg1", channel_id: "789" });
const res = await sendMessageDiscord("channel:789", "hello", {
rest,
token: "t",
retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 },
});
expect(res.messageId).toBe("msg1");
expect(postMock).toHaveBeenCalledTimes(2);
});
it("uses retry_after delays when rate limited", async () => {
vi.useFakeTimers();
const setTimeoutSpy = vi.spyOn(global, "setTimeout");
const { rest, postMock } = makeRest();
const rateLimitError = createMockRateLimitError(0.5);
postMock
.mockRejectedValueOnce(rateLimitError)
.mockResolvedValueOnce({ id: "msg1", channel_id: "789" });
const promise = sendMessageDiscord("channel:789", "hello", {
rest,
token: "t",
retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 1000, jitter: 0 },
});
await vi.runAllTimersAsync();
await expect(promise).resolves.toEqual({
messageId: "msg1",
channelId: "789",
});
expect(setTimeoutSpy.mock.calls[0]?.[1]).toBe(500);
setTimeoutSpy.mockRestore();
vi.useRealTimers();
});
it("stops after max retry attempts", async () => {
const { rest, postMock } = makeRest();
const rateLimitError = createMockRateLimitError(0);
postMock.mockRejectedValue(rateLimitError);
await expect(
sendMessageDiscord("channel:789", "hello", {
rest,
token: "t",
retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 },
}),
).rejects.toBeInstanceOf(RateLimitError);
expect(postMock).toHaveBeenCalledTimes(2);
});
it("does not retry non-rate-limit errors", async () => {
const { rest, postMock } = makeRest();
postMock.mockRejectedValueOnce(new Error("network error"));
await expect(
sendMessageDiscord("channel:789", "hello", { rest, token: "t" }),
).rejects.toThrow("network error");
expect(postMock).toHaveBeenCalledTimes(1);
});
it("retries reactions on rate limits", async () => {
const { rest, putMock } = makeRest();
const rateLimitError = createMockRateLimitError(0);
putMock
.mockRejectedValueOnce(rateLimitError)
.mockResolvedValueOnce(undefined);
const res = await reactMessageDiscord("chan1", "msg1", "ok", {
rest,
token: "t",
retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 },
});
expect(res.ok).toBe(true);
expect(putMock).toHaveBeenCalledTimes(2);
});
it("retries media upload without duplicating overflow text", async () => {
const { rest, postMock } = makeRest();
const rateLimitError = createMockRateLimitError(0);
const text = "a".repeat(2005);
postMock
.mockRejectedValueOnce(rateLimitError)
.mockResolvedValueOnce({ id: "msg1", channel_id: "789" })
.mockResolvedValueOnce({ id: "msg2", channel_id: "789" });
const res = await sendMessageDiscord("channel:789", text, {
rest,
token: "t",
mediaUrl: "https://example.com/photo.jpg",
retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 },
});
expect(res.messageId).toBe("msg1");
expect(postMock).toHaveBeenCalledTimes(3);
});
});

View File

@@ -19,6 +19,11 @@ import {
import { chunkMarkdownText } from "../auto-reply/chunk.js";
import { loadConfig } from "../config/config.js";
import type { RetryConfig } from "../infra/retry.js";
import {
createDiscordRetryRunner,
type RetryRunner,
} from "../infra/retry-policy.js";
import {
normalizePollDurationHours,
normalizePollInput,
@@ -35,6 +40,7 @@ const DISCORD_POLL_MAX_ANSWERS = 10;
const DISCORD_POLL_MAX_DURATION_HOURS = 32 * 24;
const DISCORD_MISSING_PERMISSIONS = 50013;
const DISCORD_CANNOT_DM = 50007;
type DiscordRequest = RetryRunner;
export class DiscordSendError extends Error {
kind?: "missing-permissions" | "dm-blocked";
@@ -72,6 +78,7 @@ type DiscordSendOpts = {
verbose?: boolean;
rest?: RequestClient;
replyTo?: string;
retry?: RetryConfig;
};
export type DiscordSendResult = {
@@ -82,6 +89,8 @@ export type DiscordSendResult = {
export type DiscordReactOpts = {
token?: string;
rest?: RequestClient;
verbose?: boolean;
retry?: RetryConfig;
};
export type DiscordReactionUser = {
@@ -187,6 +196,24 @@ function resolveRest(token: string, rest?: RequestClient) {
return rest ?? new RequestClient(token);
}
type DiscordClientOpts = {
token?: string;
rest?: RequestClient;
retry?: RetryConfig;
verbose?: boolean;
};
function createDiscordClient(opts: DiscordClientOpts, cfg = loadConfig()) {
const token = resolveToken(opts.token);
const rest = resolveRest(token, opts.rest);
const request = createDiscordRetryRunner({
retry: opts.retry,
configRetry: cfg.discord?.retry,
verbose: opts.verbose,
});
return { token, rest, request };
}
function normalizeReactionEmoji(raw: string) {
const trimmed = raw.trim();
if (!trimmed) {
@@ -358,13 +385,18 @@ async function buildDiscordSendError(
async function resolveChannelId(
rest: RequestClient,
recipient: DiscordRecipient,
request: DiscordRequest,
): Promise<{ channelId: string; dm?: boolean }> {
if (recipient.kind === "channel") {
return { channelId: recipient.id };
}
const dmChannel = (await rest.post(Routes.userChannels(), {
body: { recipient_id: recipient.id },
})) as { id: string };
const dmChannel = (await request(
() =>
rest.post(Routes.userChannels(), {
body: { recipient_id: recipient.id },
}) as Promise<{ id: string }>,
"dm-channel",
)) as { id: string };
if (!dmChannel?.id) {
throw new Error("Failed to create Discord DM channel");
}
@@ -375,7 +407,8 @@ async function sendDiscordText(
rest: RequestClient,
channelId: string,
text: string,
replyTo?: string,
replyTo: string | undefined,
request: DiscordRequest,
) {
if (!text.trim()) {
throw new Error("Message must be non-empty for Discord sends");
@@ -384,21 +417,29 @@ async function sendDiscordText(
? { message_id: replyTo, fail_if_not_exists: false }
: undefined;
if (text.length <= DISCORD_TEXT_LIMIT) {
const res = (await rest.post(Routes.channelMessages(channelId), {
body: { content: text, message_reference: messageReference },
})) as { id: string; channel_id: string };
const res = (await request(
() =>
rest.post(Routes.channelMessages(channelId), {
body: { content: text, message_reference: messageReference },
}) as Promise<{ id: string; channel_id: string }>,
"text",
)) as { id: string; channel_id: string };
return res;
}
const chunks = chunkMarkdownText(text, DISCORD_TEXT_LIMIT);
let last: { id: string; channel_id: string } | null = null;
let isFirst = true;
for (const chunk of chunks) {
last = (await rest.post(Routes.channelMessages(channelId), {
body: {
content: chunk,
message_reference: isFirst ? messageReference : undefined,
},
})) as { id: string; channel_id: string };
last = (await request(
() =>
rest.post(Routes.channelMessages(channelId), {
body: {
content: chunk,
message_reference: isFirst ? messageReference : undefined,
},
}) as Promise<{ id: string; channel_id: string }>,
"text",
)) as { id: string; channel_id: string };
isFirst = false;
}
if (!last) {
@@ -412,7 +453,8 @@ async function sendDiscordMedia(
channelId: string,
text: string,
mediaUrl: string,
replyTo?: string,
replyTo: string | undefined,
request: DiscordRequest,
) {
const media = await loadWebMedia(mediaUrl);
const caption =
@@ -420,22 +462,26 @@ async function sendDiscordMedia(
const messageReference = replyTo
? { message_id: replyTo, fail_if_not_exists: false }
: undefined;
const res = (await rest.post(Routes.channelMessages(channelId), {
body: {
content: caption || undefined,
message_reference: messageReference,
files: [
{
data: media.buffer,
name: media.fileName ?? "upload",
const res = (await request(
() =>
rest.post(Routes.channelMessages(channelId), {
body: {
content: caption || undefined,
message_reference: messageReference,
files: [
{
data: media.buffer,
name: media.fileName ?? "upload",
},
],
},
],
},
})) as { id: string; channel_id: string };
}) as Promise<{ id: string; channel_id: string }>,
"media",
)) as { id: string; channel_id: string };
if (text.length > DISCORD_TEXT_LIMIT) {
const remaining = text.slice(DISCORD_TEXT_LIMIT).trim();
if (remaining) {
await sendDiscordText(rest, channelId, remaining);
await sendDiscordText(rest, channelId, remaining, undefined, request);
}
}
return res;
@@ -471,10 +517,10 @@ export async function sendMessageDiscord(
text: string,
opts: DiscordSendOpts = {},
): Promise<DiscordSendResult> {
const token = resolveToken(opts.token);
const rest = resolveRest(token, opts.rest);
const cfg = loadConfig();
const { token, rest, request } = createDiscordClient(opts, cfg);
const recipient = parseRecipient(to);
const { channelId } = await resolveChannelId(rest, recipient);
const { channelId } = await resolveChannelId(rest, recipient, request);
let result:
| { id: string; channel_id: string }
| { id: string | null; channel_id: string };
@@ -486,9 +532,16 @@ export async function sendMessageDiscord(
text,
opts.mediaUrl,
opts.replyTo,
request,
);
} else {
result = await sendDiscordText(rest, channelId, text, opts.replyTo);
result = await sendDiscordText(
rest,
channelId,
text,
opts.replyTo,
request,
);
}
} catch (err) {
throw await buildDiscordSendError(err, {
@@ -510,18 +563,22 @@ export async function sendStickerDiscord(
stickerIds: string[],
opts: DiscordSendOpts & { content?: string } = {},
): Promise<DiscordSendResult> {
const token = resolveToken(opts.token);
const rest = resolveRest(token, opts.rest);
const cfg = loadConfig();
const { rest, request } = createDiscordClient(opts, cfg);
const recipient = parseRecipient(to);
const { channelId } = await resolveChannelId(rest, recipient);
const { channelId } = await resolveChannelId(rest, recipient, request);
const content = opts.content?.trim();
const stickers = normalizeStickerIds(stickerIds);
const res = (await rest.post(Routes.channelMessages(channelId), {
body: {
content: content || undefined,
sticker_ids: stickers,
},
})) as { id: string; channel_id: string };
const res = (await request(
() =>
rest.post(Routes.channelMessages(channelId), {
body: {
content: content || undefined,
sticker_ids: stickers,
},
}) as Promise<{ id: string; channel_id: string }>,
"sticker",
)) as { id: string; channel_id: string };
return {
messageId: res.id ? String(res.id) : "unknown",
channelId: String(res.channel_id ?? channelId),
@@ -533,18 +590,22 @@ export async function sendPollDiscord(
poll: PollInput,
opts: DiscordSendOpts & { content?: string } = {},
): Promise<DiscordSendResult> {
const token = resolveToken(opts.token);
const rest = resolveRest(token, opts.rest);
const cfg = loadConfig();
const { rest, request } = createDiscordClient(opts, cfg);
const recipient = parseRecipient(to);
const { channelId } = await resolveChannelId(rest, recipient);
const { channelId } = await resolveChannelId(rest, recipient, request);
const content = opts.content?.trim();
const payload = normalizeDiscordPollInput(poll);
const res = (await rest.post(Routes.channelMessages(channelId), {
body: {
content: content || undefined,
poll: payload,
},
})) as { id: string; channel_id: string };
const res = (await request(
() =>
rest.post(Routes.channelMessages(channelId), {
body: {
content: content || undefined,
poll: payload,
},
}) as Promise<{ id: string; channel_id: string }>,
"poll",
)) as { id: string; channel_id: string };
return {
messageId: res.id ? String(res.id) : "unknown",
channelId: String(res.channel_id ?? channelId),
@@ -557,11 +618,13 @@ export async function reactMessageDiscord(
emoji: string,
opts: DiscordReactOpts = {},
) {
const token = resolveToken(opts.token);
const rest = resolveRest(token, opts.rest);
const cfg = loadConfig();
const { rest, request } = createDiscordClient(opts, cfg);
const encoded = normalizeReactionEmoji(emoji);
await rest.put(
Routes.channelMessageOwnReaction(channelId, messageId, encoded),
await request(
() =>
rest.put(Routes.channelMessageOwnReaction(channelId, messageId, encoded)),
"react",
);
return { ok: true };
}