Skip to content

Commit 2863ee2

Browse files
authored
Merge pull request #110 from PMDevSolutions/94-redence-verify-api-rate-limiting-return-429-with-retry-after-on-the-chat-route
feat(worker): return 429 with Retry-After and limitType on chat route rate limit
2 parents 0575e10 + aef6fb6 commit 2863ee2

2 files changed

Lines changed: 91 additions & 1 deletion

File tree

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { describe, it, expect } from "vitest";
2+
import app from "../index";
3+
4+
/**
5+
* Route-level tests for the rate-limit (429) path of POST /api/chat.
6+
*
7+
* These exercise the Hono app end-to-end via `app.fetch` (including CORS and
8+
* the rate-limit gate) rather than the `checkRateLimit` helper in isolation,
9+
* proving the route itself returns a well-formed 429.
10+
*/
11+
12+
interface ChatRouteEnv {
13+
ANTHROPIC_API_KEY: string;
14+
ALLOWED_ORIGIN: string;
15+
RATE_LIMIT: KVNamespace;
16+
}
17+
18+
function createMockKV(seed: Record<string, string> = {}): KVNamespace {
19+
const store = new Map<string, string>(Object.entries(seed));
20+
21+
return {
22+
get: async (key: string) => store.get(key) ?? null,
23+
put: async (key: string, value: string) => {
24+
store.set(key, value);
25+
},
26+
} as unknown as KVNamespace;
27+
}
28+
29+
function createMockCtx(): ExecutionContext {
30+
return {
31+
waitUntil: () => {},
32+
passThroughOnException: () => {},
33+
} as unknown as ExecutionContext;
34+
}
35+
36+
function postChat(env: ChatRouteEnv, ip: string): Promise<Response> {
37+
const request = new Request("http://localhost/api/chat", {
38+
method: "POST",
39+
headers: {
40+
"Content-Type": "application/json",
41+
"cf-connecting-ip": ip,
42+
},
43+
body: JSON.stringify({ messages: [{ role: "user", content: "hi" }] }),
44+
});
45+
46+
return app.fetch(request, env, createMockCtx()) as Promise<Response>;
47+
}
48+
49+
describe("POST /api/chat rate limiting", () => {
50+
it("returns 429 with Retry-After and limitType when the per-minute limit is exceeded", async () => {
51+
const env: ChatRouteEnv = {
52+
ANTHROPIC_API_KEY: "test-key",
53+
ALLOWED_ORIGIN: "http://localhost:5173",
54+
// Seed the minute counter at the default limit (10) so the next request
55+
// is rejected by the minute window before reaching the model.
56+
RATE_LIMIT: createMockKV({ "rate:min:1.2.3.4": "10" }),
57+
};
58+
59+
const res = await postChat(env, "1.2.3.4");
60+
61+
expect(res.status).toBe(429);
62+
expect(res.headers.get("Retry-After")).toBe("60");
63+
64+
const body = (await res.json()) as { code?: string; limitType?: string };
65+
expect(body.code).toBe("RATE_LIMITED");
66+
expect(body.limitType).toBe("minute");
67+
});
68+
69+
it("returns 429 with the hourly Retry-After and limitType when the per-hour limit is exceeded", async () => {
70+
const env: ChatRouteEnv = {
71+
ANTHROPIC_API_KEY: "test-key",
72+
ALLOWED_ORIGIN: "http://localhost:5173",
73+
// Minute window has room, but the hour counter is at the default limit (50).
74+
RATE_LIMIT: createMockKV({
75+
"rate:min:5.6.7.8": "0",
76+
"rate:hr:5.6.7.8": "50",
77+
}),
78+
};
79+
80+
const res = await postChat(env, "5.6.7.8");
81+
82+
expect(res.status).toBe(429);
83+
expect(res.headers.get("Retry-After")).toBe("3600");
84+
85+
const body = (await res.json()) as { code?: string; limitType?: string };
86+
expect(body.code).toBe("RATE_LIMITED");
87+
expect(body.limitType).toBe("hour");
88+
});
89+
});

worker/src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ interface Env {
1919
export interface ErrorResponse {
2020
error: string;
2121
code?: string;
22+
limitType?: "minute" | "hour";
2223
}
2324

2425
const app = new Hono<{ Bindings: Env }>();
@@ -76,7 +77,7 @@ app.post("/api/chat", async (c) => {
7677
statusCode = 429;
7778
errorCode = "RATE_LIMITED";
7879
return c.json<ErrorResponse>(
79-
{ error: errorMessage, code: errorCode },
80+
{ error: errorMessage, code: errorCode, limitType: rateLimit.limitType },
8081
{
8182
status: 429,
8283
headers: { "Retry-After": String(rateLimit.retryAfter) },

0 commit comments

Comments
 (0)