Skip to content

Commit 9103a34

Browse files
add retry logic for 429 requests, and a jest that tests that logic
1 parent f25c855 commit 9103a34

2 files changed

Lines changed: 155 additions & 4 deletions

File tree

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import type { MessageException } from "@/api";
2+
import { GalaxyApi } from "@/api/client";
3+
import { useServerMock } from "@/api/client/__mocks__";
4+
5+
import { DEFAULT_CONFIG } from "./rateLimiter";
6+
7+
const { server, http } = useServerMock();
8+
9+
/** Spy to count number of times a 429 response is returned */
10+
const mock429ResponseSpy = jest.fn();
11+
12+
describe("Rate Limiter Middleware", () => {
13+
let consoleWarnSpy: jest.SpyInstance;
14+
let consoleErrorSpy: jest.SpyInstance;
15+
16+
/** Helper to verify that there is a 429 response without retries */
17+
function ensure429AndNoRetries(response: Response, error: MessageException | undefined) {
18+
// Verify the request failed as expected
19+
expect(response.status).toBe(429);
20+
expect(error).toBeDefined();
21+
expect(error?.err_code).toBe(429);
22+
23+
// Verify no retry behavior occurred by confirming console warns/errors were not called
24+
expect(consoleWarnSpy).not.toHaveBeenCalled();
25+
expect(consoleErrorSpy).not.toHaveBeenCalled();
26+
27+
// Verify the mock 429 response was called only once (no retries)
28+
expect(mock429ResponseSpy).toHaveBeenCalledTimes(1);
29+
}
30+
31+
beforeEach(() => {
32+
consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation();
33+
consoleErrorSpy = jest.spyOn(console, "error").mockImplementation();
34+
});
35+
afterEach(() => {
36+
consoleWarnSpy.mockRestore();
37+
consoleErrorSpy.mockRestore();
38+
mock429ResponseSpy.mockReset();
39+
});
40+
41+
it("should retry 429 GET responses", async () => {
42+
// Set up a mock GET endpoint that always returns 429
43+
server.use(
44+
http.get("/api/histories/{history_id}", ({ response }) => {
45+
mock429ResponseSpy();
46+
return response("4XX").json({ err_code: 429, err_msg: "Too Many Requests" }, { status: 429 });
47+
}),
48+
);
49+
50+
const { error, response } = await GalaxyApi().GET("/api/histories/{history_id}", {
51+
params: {
52+
path: { history_id: "test" },
53+
},
54+
});
55+
56+
// Verify the request failed as expected
57+
expect(response.status).toBe(429);
58+
expect(error).toBeDefined();
59+
expect(error?.err_code).toBe(429);
60+
61+
// Verify retry behavior occurred by confirming console warns/errors
62+
expect(consoleWarnSpy).toHaveBeenCalledWith(
63+
expect.stringContaining(`Received 429 from server, waiting ${DEFAULT_CONFIG.retryDelay}ms before retry`),
64+
);
65+
66+
for (let i = 1; i <= DEFAULT_CONFIG.maxRetries; i++) {
67+
expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining(`Retry ${i} also received 429`));
68+
}
69+
70+
expect(consoleErrorSpy).toHaveBeenCalledWith(
71+
expect.stringContaining(`Max retries reached for request to ${response.url}`),
72+
);
73+
74+
// Verify the mock 429 response was called the first time and then for each retry
75+
expect(mock429ResponseSpy).toHaveBeenCalledTimes(DEFAULT_CONFIG.maxRetries + 1);
76+
});
77+
78+
it("should not retry 429 POST responses", async () => {
79+
// Set up a mock POST endpoint that always returns 429
80+
server.use(
81+
http.post("/api/chat", ({ response }) => {
82+
mock429ResponseSpy();
83+
return response("4XX").json({ err_code: 429, err_msg: "Too Many Requests" }, { status: 429 });
84+
}),
85+
);
86+
87+
const { error, response } = await GalaxyApi().POST("/api/chat", {
88+
params: {
89+
query: { job_id: "test" },
90+
},
91+
body: {
92+
query: "test message",
93+
context: "test",
94+
},
95+
});
96+
97+
ensure429AndNoRetries(response, error);
98+
});
99+
100+
it("should not retry 429 DELETE responses", async () => {
101+
// Set up a mock DELETE endpoint that always returns 429
102+
server.use(
103+
http.delete("/api/datasets/{dataset_id}", ({ response }) => {
104+
mock429ResponseSpy();
105+
return response("4XX").json({ err_code: 429, err_msg: "Too Many Requests" }, { status: 429 });
106+
}),
107+
);
108+
109+
const { error, response } = await GalaxyApi().DELETE("/api/datasets/{dataset_id}", {
110+
params: {
111+
path: { dataset_id: "test_id" },
112+
query: { purge: true },
113+
},
114+
});
115+
116+
ensure429AndNoRetries(response, error);
117+
});
118+
119+
it("should not retry 429 PUT responses", async () => {
120+
// Set up a mock PUT endpoint that always returns 429
121+
server.use(
122+
http.put("/api/datasets/{dataset_id}", ({ response }) => {
123+
mock429ResponseSpy();
124+
return response("4XX").json({ err_code: 429, err_msg: "Too Many Requests" }, { status: 429 });
125+
}),
126+
);
127+
128+
const { error, response } = await GalaxyApi().PUT("/api/datasets/{dataset_id}", {
129+
params: {
130+
path: { dataset_id: "test_id" },
131+
},
132+
body: {
133+
deleted: false,
134+
},
135+
});
136+
137+
ensure429AndNoRetries(response, error);
138+
});
139+
});

client/src/api/client/rateLimiter.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ interface RateLimitConfig {
1111
maxRetries?: number;
1212
}
1313

14-
const DEFAULT_CONFIG: Required<RateLimitConfig> = {
14+
export const DEFAULT_CONFIG: Required<RateLimitConfig> = {
1515
maxRequests: 100,
1616
windowMs: 60000,
1717
retryDelay: 1000,
@@ -63,15 +63,27 @@ export function createRateLimiterMiddleware(config: RateLimitConfig = {}): Middl
6363
},
6464

6565
async onResponse({ response: res, request }) {
66-
// TODO: Implement max retries logic
67-
6866
// Handle 429 Too Many Requests from server
6967
if (res.status === 429 && request.method === "GET") {
7068
const retryAfter = res.headers.get("Retry-After");
7169
const delay = retryAfter ? parseInt(retryAfter) * 1000 : cfg.retryDelay;
7270

7371
console.warn(`Received 429 from server, waiting ${delay}ms before retry`);
74-
await new Promise((resolve) => setTimeout(resolve, delay));
72+
73+
let retries = 0;
74+
while (retries < cfg.maxRetries) {
75+
retries++;
76+
await new Promise((resolve) => setTimeout(resolve, delay));
77+
78+
// A tricky thing here is that we will bypass the middleware chain on retry
79+
const retryResponse = await fetch(request);
80+
if (retryResponse.status !== 429) {
81+
return retryResponse;
82+
}
83+
console.warn(`Retry ${retries} also received 429, retrying...`);
84+
}
85+
86+
console.error(`Max retries reached for request to ${request.url}`);
7587
}
7688

7789
return res;

0 commit comments

Comments
 (0)