Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 145 additions & 3 deletions app/lib/discord/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@ import { log } from "@/lib/log";
import type { Username } from "@/lib/session";
import { server } from "@/mocks/node";

import { getChannelMessages, getMessageChain, postChannelMessage } from "./api";
import {
_resetRateLimitState,
_setRateLimitGate,
getChannelMessages,
getMessageChain,
postChannelMessage,
} from "./api";

vi.mock(import("server-only"), () => ({}));

Expand Down Expand Up @@ -708,6 +714,7 @@ describe("rate limiting", () => {
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
_resetRateLimitState();
});

it("should retry after 429 and succeed", async () => {
Expand Down Expand Up @@ -759,6 +766,7 @@ describe("rate limiting", () => {
elapsedMs: 0,
retryAfterMs: 35000,
global: true,
retries: 1,
},
"Discord rate limit exceeded max wait time",
);
Expand Down Expand Up @@ -786,7 +794,12 @@ describe("rate limiting", () => {

expect(attempts).toBe(2);
expect(logWarnSpy).toHaveBeenCalledWith(
{ endpoint: expect.any(String), retryAfterMs: 1000, global: false },
{
endpoint: expect.any(String),
retryAfterMs: 1000,
global: false,
retries: 1,
},
"Discord rate limited, retrying",
);
});
Expand Down Expand Up @@ -823,7 +836,12 @@ describe("rate limiting", () => {

expect(attempts).toBe(2);
expect(logWarnSpy).toHaveBeenCalledWith(
{ endpoint: expect.any(String), retryAfterMs: 15000, global: false },
{
endpoint: expect.any(String),
retryAfterMs: 15000,
global: false,
retries: 1,
},
"Discord rate limited, retrying",
);
expect(logErrorSpy).toHaveBeenCalledWith(
Expand All @@ -832,8 +850,132 @@ describe("rate limiting", () => {
elapsedMs: 15000,
retryAfterMs: 16000,
global: false,
retries: 2,
},
"Discord rate limit exceeded max wait time",
);
});

it("should throw when max retries exceeded", async () => {
const logWarnSpy = vi.spyOn(log, "warn").mockImplementation(() => {});
const logErrorSpy = vi.spyOn(log, "error").mockImplementation(() => {});
let attempts = 0;

server.use(
http.get(`${DISCORD_BASE_URL}/channels/:channelId/messages`, () => {
attempts++;
return HttpResponse.json(
{ message: "Rate limited", retry_after: 1, global: false },
{ status: 429 },
);
}),
);

const promise = getChannelMessages();
const assertion = expect(promise).rejects.toThrow(
"Discord rate limit exceeded",
);

await vi.advanceTimersByTimeAsync(10000);
await assertion;

// 1 initial + 5 retries = 6 total attempts
expect(attempts).toBe(6);
expect(logWarnSpy).toHaveBeenCalledTimes(5);
expect(logErrorSpy).toHaveBeenCalledWith(
expect.objectContaining({ retries: 6 }),
"Discord rate limit exceeded max wait time",
);
});

it("should share rate limit state across concurrent requests", async () => {
vi.spyOn(log, "warn").mockImplementation(() => {});
let fetchCount = 0;

server.use(
http.get(`${DISCORD_BASE_URL}/channels/:channelId/messages`, () => {
fetchCount++;
if (fetchCount === 1) {
return HttpResponse.json(
{ message: "Rate limited", retry_after: 2, global: false },
{ status: 429 },
);
}
return HttpResponse.json([]);
}),
);

// First request gets 429 and sets the shared gate
const promise1 = getChannelMessages();
await vi.advanceTimersByTimeAsync(0);

// Second request arrives while rate limit gate is active
const promise2 = getChannelMessages();

// Advance past the 2-second rate limit window
await vi.advanceTimersByTimeAsync(2000);

await Promise.all([promise1, promise2]);

// Without shared state: 4 fetches (both hit 429 independently, both retry)
// With shared state: 3 fetches (first gets 429, second waits on gate, both succeed after delay)
expect(fetchCount).toBe(3);
});

it("should throw when shared gate wait exceeds timeout", async () => {
const logErrorSpy = vi.spyOn(log, "error").mockImplementation(() => {});

// Directly set a gate 31 seconds in the future — exceeds RATE_LIMIT_TIMEOUT_MS (30s)
const endpoint = "channels/test-discord-channel-id/messages";
_setRateLimitGate(endpoint, Date.now() + 31_000);

// A fresh request checks the gate: waitMs=31000, elapsedMs≈0, 0+31000 > 30000 → throws
await expect(getChannelMessages()).rejects.toThrow(
"Discord rate limit exceeded",
);

expect(logErrorSpy).toHaveBeenCalledWith(
{ endpoint, elapsedMs: expect.any(Number), waitMs: 31_000, retries: 0 },
"Discord rate limit exceeded max wait time",
);
});

it("should include retry count in log messages", async () => {
const logWarnSpy = vi.spyOn(log, "warn").mockImplementation(() => {});
let attempts = 0;

server.use(
http.get(`${DISCORD_BASE_URL}/channels/:channelId/messages`, () => {
attempts++;
if (attempts <= 3) {
return HttpResponse.json(
{ message: "Rate limited", retry_after: 1, global: false },
{ status: 429 },
);
}
return HttpResponse.json([]);
}),
);

const promise = getChannelMessages();
await vi.advanceTimersByTimeAsync(3000);
await promise;

expect(logWarnSpy).toHaveBeenCalledTimes(3);
expect(logWarnSpy).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ retries: 1 }),
"Discord rate limited, retrying",
);
expect(logWarnSpy).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ retries: 2 }),
"Discord rate limited, retrying",
);
expect(logWarnSpy).toHaveBeenNthCalledWith(
3,
expect.objectContaining({ retries: 3 }),
"Discord rate limited, retrying",
);
});
});
51 changes: 48 additions & 3 deletions app/lib/discord/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { type DiscordMessage, DiscordMessageSchema } from "./schemas";

const BASE_URL = "https://discord.com/api/v10";
const RATE_LIMIT_TIMEOUT_MS = 30_000;
const MAX_RETRIES = 5;

const RateLimitResponseSchema = z.object({
message: z.string(),
Expand All @@ -23,6 +24,19 @@ const RateLimitResponseSchema = z.object({
code: z.number().optional(),
});

// Per-endpoint rate limit tracking: endpoint → timestamp until which requests should wait
const rateLimitUntil = new Map<string, number>();

/** @internal Exported for test cleanup only */
export function _resetRateLimitState(): void {
rateLimitUntil.clear();
}

/** @internal Exported for testing the gate timeout path */
export function _setRateLimitGate(endpoint: string, until: number): void {
rateLimitUntil.set(endpoint, until);
}

async function call<T extends z.ZodType>(
method: string,
endpoint: string,
Expand All @@ -38,8 +52,26 @@ async function call<T extends z.ZodType>(
}

const startTime = performance.now();
let retries = 0;

while (true) {
// Wait for any active rate limit gate on this endpoint
const waitUntil = rateLimitUntil.get(endpoint);
if (waitUntil != null) {
const waitMs = waitUntil - Date.now();
if (waitMs > 0) {
const elapsedMs = performance.now() - startTime;
if (elapsedMs + waitMs > RATE_LIMIT_TIMEOUT_MS) {
log.error(
{ endpoint, elapsedMs, waitMs, retries },
"Discord rate limit exceeded max wait time",
);
throw new Error(`Discord rate limit exceeded`);
}
await new Promise((resolve) => setTimeout(resolve, waitMs));
}
}

const response = await fetch(url, {
method,
body: method === "POST" ? JSON.stringify(params) : undefined,
Expand All @@ -53,6 +85,8 @@ async function call<T extends z.ZodType>(
const json = await response.json();

if (response.status === 429) {
retries++;

const rateLimit = RateLimitResponseSchema.safeParse(json);
const retryAfterMs = rateLimit.success
? rateLimit.data.retry_after * 1000
Expand All @@ -61,23 +95,34 @@ async function call<T extends z.ZodType>(

const elapsedMs = performance.now() - startTime;

if (elapsedMs + retryAfterMs > RATE_LIMIT_TIMEOUT_MS) {
if (
retries > MAX_RETRIES ||
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor naming note: MAX_RETRIES = 5 with retries > MAX_RETRIES allows 6 total API calls (1 initial + 5 retries). Using >= instead would give you exactly MAX_RETRIES calls, or renaming the constant to MAX_ATTEMPTS = 6 would make the intent self-documenting. The current behaviour is conventional for retry logic, and the test comment at line 882 confirms it's intentional — just worth a quick sanity check that 6 total calls is what you want.

elapsedMs + retryAfterMs > RATE_LIMIT_TIMEOUT_MS
) {
log.error(
{ endpoint, elapsedMs, retryAfterMs, global },
{ endpoint, elapsedMs, retryAfterMs, global, retries },
"Discord rate limit exceeded max wait time",
);
throw new Error(`Discord rate limit exceeded`);
}

// Update shared rate limit gate (Math.max avoids overwriting a longer wait)
const newUntil = Date.now() + retryAfterMs;
const existing = rateLimitUntil.get(endpoint) ?? 0;
rateLimitUntil.set(endpoint, Math.max(existing, newUntil));

log.warn(
{ endpoint, retryAfterMs, global },
{ endpoint, retryAfterMs, global, retries },
"Discord rate limited, retrying",
);

await new Promise((resolve) => setTimeout(resolve, retryAfterMs));
continue;
}

// Clear rate limit gate on success
rateLimitUntil.delete(endpoint);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deleting the gate on any successful response is correct for the common case, but there's a subtle race: if Request A and Request B both slept on the gate, A succeeds first and deletes the gate, then B (which has already computed and is sleeping through its waitMs timer) wakes and succeeds too — that's fine because B's timer was already scheduled and the deletion had no effect on it.

The only interesting case is a hypothetical Request C that arrives after A's deletion but before B's timer fires: C sees no gate and fires immediately. Since A just got a 200, the rate limit window has passed, so C getting a 200 is correct behaviour. No issue, just documenting the reasoning in case this edge case comes up in a future review.


if (!response.ok) {
log.error(
{ endpoint, status: response.status, body: json },
Expand Down