



















@@ -153,6 +153,154 @@ describe("RequestClient", () => {
153153expect(fetchSpy).toHaveBeenCalledTimes(2);
154154});
155155156+it("retries queued rate limit responses after the learned reset", async () => {
157+vi.useFakeTimers();
158+vi.setSystemTime(0);
159+const responses = [
160+Promise.resolve(
161+createJsonResponse(
162+{ message: "Rate limited", retry_after: 0.1, global: false },
163+{
164+status: 429,
165+headers: {
166+"X-RateLimit-Bucket": "channel-messages",
167+"X-RateLimit-Limit": "1",
168+"X-RateLimit-Remaining": "0",
169+},
170+},
171+),
172+),
173+Promise.resolve(
174+createJsonResponse(
175+{ id: "retried" },
176+{
177+headers: {
178+"X-RateLimit-Bucket": "channel-messages",
179+"X-RateLimit-Limit": "1",
180+"X-RateLimit-Remaining": "1",
181+},
182+},
183+),
184+),
185+];
186+const fetchSpy = vi.fn(async () => {
187+const response = responses.shift();
188+if (!response) {
189+throw new Error("unexpected request");
190+}
191+return await response;
192+});
193+const client = new RequestClient("test-token", { fetch: fetchSpy });
194+195+const request = client.get("/channels/c1/messages");
196+await Promise.resolve();
197+expect(fetchSpy).toHaveBeenCalledTimes(1);
198+expect(client.queueSize).toBe(1);
199+200+await vi.advanceTimersByTimeAsync(99);
201+expect(fetchSpy).toHaveBeenCalledTimes(1);
202+203+await vi.advanceTimersByTimeAsync(1);
204+await expect(request).resolves.toEqual({ id: "retried" });
205+expect(fetchSpy).toHaveBeenCalledTimes(2);
206+expect(client.queueSize).toBe(0);
207+expect(client.getSchedulerMetrics().buckets).toEqual([]);
208+});
209+210+it("honors maxRateLimitRetries for queued requests", async () => {
211+const fetchSpy = vi.fn(async () =>
212+createJsonResponse(
213+{ message: "Rate limited", retry_after: 0.1, global: false },
214+{
215+status: 429,
216+headers: { "X-RateLimit-Bucket": "channel-messages" },
217+},
218+),
219+);
220+const client = new RequestClient("test-token", {
221+fetch: fetchSpy,
222+scheduler: { maxRateLimitRetries: 0 },
223+});
224+225+await expect(client.get("/channels/c1/messages")).rejects.toMatchObject({
226+name: "RateLimitError",
227+retryAfter: 0.1,
228+});
229+expect(fetchSpy).toHaveBeenCalledTimes(1);
230+expect(client.queueSize).toBe(0);
231+});
232+233+it("does not requeue an active rate limit after the queue is cleared", async () => {
234+const response = createDeferred<Response>();
235+const fetchSpy = vi.fn(async () => {
236+if (fetchSpy.mock.calls.length > 1) {
237+throw new Error("unexpected retry after clearQueue");
238+}
239+return await response.promise;
240+});
241+const client = new RequestClient("test-token", { fetch: fetchSpy });
242+243+const request = client.get("/channels/c1/messages");
244+await vi.waitFor(() => expect(fetchSpy).toHaveBeenCalledTimes(1));
245+expect(client.queueSize).toBe(1);
246+247+client.clearQueue();
248+expect(client.queueSize).toBe(1);
249+250+response.resolve(
251+createJsonResponse(
252+{ message: "Rate limited", retry_after: 0, global: false },
253+{
254+status: 429,
255+headers: { "X-RateLimit-Bucket": "channel-messages" },
256+},
257+),
258+);
259+260+await expect(request).rejects.toMatchObject({
261+name: "RateLimitError",
262+retryAfter: 0,
263+});
264+expect(fetchSpy).toHaveBeenCalledTimes(1);
265+expect(client.queueSize).toBe(0);
266+});
267+268+it("retries queued global rate limits after Retry-After", async () => {
269+vi.useFakeTimers();
270+vi.setSystemTime(0);
271+const responses = [
272+Promise.resolve(
273+createJsonResponse(
274+{ message: "Rate limited", retry_after: 0.1, global: true },
275+{
276+status: 429,
277+headers: { "X-RateLimit-Global": "true" },
278+},
279+),
280+),
281+Promise.resolve(createJsonResponse({ id: "after-global" })),
282+];
283+const fetchSpy = vi.fn(async () => {
284+const response = responses.shift();
285+if (!response) {
286+throw new Error("unexpected request");
287+}
288+return await response;
289+});
290+const client = new RequestClient("test-token", { fetch: fetchSpy });
291+292+const request = client.get("/channels/c1/messages");
293+await Promise.resolve();
294+expect(fetchSpy).toHaveBeenCalledTimes(1);
295+296+await vi.advanceTimersByTimeAsync(99);
297+expect(fetchSpy).toHaveBeenCalledTimes(1);
298+299+await vi.advanceTimersByTimeAsync(1);
300+await expect(request).resolves.toEqual({ id: "after-global" });
301+expect(fetchSpy).toHaveBeenCalledTimes(2);
302+});
303+156304it("preserves Discord error codes on rate limit errors", async () => {
157305const client = new RequestClient("test-token", {
158306queueRequests: false,
@@ -175,6 +323,43 @@ describe("RequestClient", () => {
175323});
176324});
177325326+it("parses HTTP-date Retry-After headers on rate limit errors", async () => {
327+vi.useFakeTimers();
328+vi.setSystemTime(new Date("2026-05-01T12:00:00.000Z"));
329+const client = new RequestClient("test-token", {
330+queueRequests: false,
331+fetch: async () =>
332+new Response(JSON.stringify({ message: "Slow down", global: false }), {
333+status: 429,
334+headers: { "Retry-After": "Fri, 01 May 2026 12:00:05 GMT" },
335+}),
336+});
337+338+await expect(client.get("/channels/c1/messages")).rejects.toMatchObject({
339+name: "RateLimitError",
340+retryAfter: 5,
341+});
342+});
343+344+it("falls back to Retry-After when the rate limit body value is malformed", async () => {
345+const client = new RequestClient("test-token", {
346+queueRequests: false,
347+fetch: async () =>
348+new Response(
349+JSON.stringify({ message: "Slow down", retry_after: "not-a-number", global: false }),
350+{
351+status: 429,
352+headers: { "Retry-After": "7" },
353+},
354+),
355+});
356+357+await expect(client.get("/channels/c1/messages")).rejects.toMatchObject({
358+name: "RateLimitError",
359+retryAfter: 7,
360+});
361+});
362+178363it("tracks invalid requests and exposes bucket scheduler metrics", async () => {
179364const client = new RequestClient("test-token", {
180365queueRequests: false,
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。