Skip to content

Commit 095a509

Browse files
authored
Merge pull request #21286 from ahmedhamidawan/add_rate_limiter_to_client_api_requests
[25.1] Add a rate limiter to the API client factory
2 parents 4016c1b + 9103a34 commit 095a509

File tree

3 files changed

+247
-1
lines changed

3 files changed

+247
-1
lines changed

client/src/api/client/index.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import createClient from "openapi-fetch";
22

3+
import { createRateLimiterMiddleware } from "@/api/client/rateLimiter";
34
import type { GalaxyApiPaths } from "@/api/schema";
45
import { getAppRoot } from "@/onload/loadConfig";
56

@@ -9,7 +10,19 @@ function getBaseUrl() {
910
}
1011

1112
function apiClientFactory() {
12-
return createClient<GalaxyApiPaths>({ baseUrl: getBaseUrl() });
13+
const client = createClient<GalaxyApiPaths>({ baseUrl: getBaseUrl() });
14+
15+
// TODO: Adjust based on server limits (maybe this goes in Galaxy config?)
16+
client.use(
17+
createRateLimiterMiddleware({
18+
maxRequests: 100,
19+
windowMs: 3000,
20+
retryDelay: 1000,
21+
maxRetries: 3,
22+
}),
23+
);
24+
25+
return client;
1326
}
1427

1528
export type GalaxyApiClient = ReturnType<typeof apiClientFactory>;
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+
});
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import type { Middleware } from "openapi-fetch";
2+
3+
interface RateLimitConfig {
4+
/** Maximum requests per window */
5+
maxRequests?: number;
6+
/** Time the requestwindow lasts in milliseconds */
7+
windowMs?: number;
8+
/** Delay between retries on 429 */
9+
retryDelay?: number;
10+
/** Maximum retry attempts */
11+
maxRetries?: number;
12+
}
13+
14+
export const DEFAULT_CONFIG: Required<RateLimitConfig> = {
15+
maxRequests: 100,
16+
windowMs: 60000,
17+
retryDelay: 1000,
18+
maxRetries: 3,
19+
};
20+
21+
/**
22+
* Rate limiting middleware to control the rate of API requests.
23+
*
24+
* Uses a timed window and only allows a maximum number of requests per that window.
25+
*/
26+
export function createRateLimiterMiddleware(config: RateLimitConfig = {}): Middleware {
27+
const cfg = { ...DEFAULT_CONFIG, ...config };
28+
29+
let requestCount = 0;
30+
let windowStart = Date.now();
31+
32+
/** Resets the request window if the time has elapsed */
33+
function resetWindowIfNeeded() {
34+
const now = Date.now();
35+
if (now - windowStart >= cfg.windowMs) {
36+
requestCount = 0;
37+
windowStart = now;
38+
}
39+
}
40+
41+
/** Places a request in the rate limiter queue */
42+
async function placeRequestInQueue(): Promise<void> {
43+
resetWindowIfNeeded();
44+
45+
if (requestCount < cfg.maxRequests) {
46+
requestCount++;
47+
return;
48+
}
49+
50+
// Rate limit exceeded, wait for next window
51+
const waitTime = cfg.windowMs - (Date.now() - windowStart);
52+
53+
await new Promise((resolve) => setTimeout(resolve, waitTime));
54+
55+
// After waiting, try again (this will reset the window)
56+
return placeRequestInQueue();
57+
}
58+
59+
const middleware: Middleware = {
60+
async onRequest({ request }) {
61+
await placeRequestInQueue();
62+
return request;
63+
},
64+
65+
async onResponse({ response: res, request }) {
66+
// Handle 429 Too Many Requests from server
67+
if (res.status === 429 && request.method === "GET") {
68+
const retryAfter = res.headers.get("Retry-After");
69+
const delay = retryAfter ? parseInt(retryAfter) * 1000 : cfg.retryDelay;
70+
71+
console.warn(`Received 429 from server, waiting ${delay}ms before retry`);
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}`);
87+
}
88+
89+
return res;
90+
},
91+
};
92+
93+
return middleware;
94+
}

0 commit comments

Comments
 (0)