Skip to content

Commit ad6a8da

Browse files
committed
fix: exclude /api/status from rate limiter, fix Docker healthcheck URL
Root cause: /api/status polling consumed all 100 rate-limit tokens in <60 seconds. Subsequent /api/compile calls returned 429, showing "Compilation Failed" to the user. Only a Docker restart reset the counters. Changes: - Add /api/status to rate limiter skip list (lightweight polling endpoint) - Increase production max from 100 to 300 requests per 15 minutes - Fix Docker healthcheck: /health -> /api/health (correct endpoint) - Fix healthcheck tool: wget -> curl (available in node:slim image) - Add rate-limit skip list test (2 test cases)
1 parent 2d9fe91 commit ad6a8da

3 files changed

Lines changed: 155 additions & 3 deletions

File tree

docker-compose.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ services:
3333
- ${PWD}/temp:${PWD}/temp
3434
- ./storage:/app/storage
3535
healthcheck:
36-
test: ["CMD", "wget", "-qO-", "http://localhost:3000/health"]
36+
test: ["CMD", "curl", "-sf", "http://localhost:3000/api/health"]
3737
interval: 30s
3838
timeout: 10s
3939
retries: 3

server/index.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,14 +117,15 @@ const isTestMode =
117117
process.env.NODE_ENV === "test" || process.env.DISABLE_RATE_LIMIT === "true";
118118
const apiLimiter = rateLimit({
119119
windowMs: 15 * 60 * 1000, // 15 Minuten
120-
max: isTestMode ? 10000 : 100, // 10000 in Test-Modus, 100 in Produktion
120+
max: isTestMode ? 10000 : 300, // 10000 in Test-Modus, 300 in Produktion
121121
message: { error: "Too many requests, please try again later." },
122122
standardHeaders: true,
123123
legacyHeaders: false,
124124
skip: (req) =>
125125
isTestMode ||
126126
req.originalUrl === "/api/examples" ||
127-
req.originalUrl === "/api/health", // Skip for lightweight endpoints
127+
req.originalUrl === "/api/status" ||
128+
req.originalUrl === "/api/health", // Skip for lightweight/polling endpoints
128129
});
129130

130131
// Apply rate limiting to API routes
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
/**
2+
* Verifies /api/status is excluded from the global rate limiter.
3+
*
4+
* Regression: in production mode (max=300/15 min), rapid /api/status polling
5+
* consumed all tokens and blocked /api/compile → "Compilation Failed".
6+
*/
7+
import { describe, it, expect, afterAll } from "vitest";
8+
import express from "express";
9+
import rateLimit from "express-rate-limit";
10+
import http from "node:http";
11+
12+
// ── helpers ──────────────────────────────────────────────────────────────
13+
14+
function listen(app: express.Express): Promise<{ baseUrl: string; server: http.Server }> {
15+
return new Promise((resolve) => {
16+
const server = app.listen(0, () => {
17+
const addr = server.address() as { port: number };
18+
resolve({ baseUrl: `http://127.0.0.1:${addr.port}`, server });
19+
});
20+
});
21+
}
22+
23+
async function httpGet(baseUrl: string, path: string): Promise<{ status: number; body: string }> {
24+
return new Promise((resolve, reject) => {
25+
const url = new URL(path, baseUrl);
26+
http.get(url, (res) => {
27+
let data = "";
28+
res.on("data", (chunk: string) => { data += chunk; });
29+
res.on("end", () => resolve({ status: res.statusCode ?? 0, body: data }));
30+
}).on("error", reject);
31+
});
32+
}
33+
34+
// ── tests ────────────────────────────────────────────────────────────────
35+
36+
describe("Rate limiter skip list", () => {
37+
let baseUrl: string;
38+
let server: http.Server;
39+
40+
afterAll(() =>
41+
new Promise<void>((resolve, reject) => {
42+
server?.close((err) => (err ? reject(err) : resolve()));
43+
}),
44+
);
45+
46+
it("/api/status is excluded — 150 status polls do not block /api/compile", async () => {
47+
// Build a minimal Express app with the SAME rate-limiter config as index.ts
48+
const app = express();
49+
50+
const apiLimiter = rateLimit({
51+
windowMs: 15 * 60 * 1000,
52+
max: 10, // very low to prove status is skipped
53+
message: { error: "Too many requests, please try again later." },
54+
standardHeaders: true,
55+
legacyHeaders: false,
56+
skip: (req) =>
57+
req.originalUrl === "/api/examples" ||
58+
req.originalUrl === "/api/status" ||
59+
req.originalUrl === "/api/health",
60+
});
61+
62+
app.use("/api/", apiLimiter);
63+
app.use(express.json());
64+
65+
app.get("/api/status", (_req, res) => res.json({ status: "ok" }));
66+
app.get("/api/health", (_req, res) => res.json({ status: "ok" }));
67+
app.post("/api/compile", (_req, res) => res.json({ success: true }));
68+
69+
({ baseUrl, server } = await listen(app));
70+
71+
// Hammer /api/status 150 times — must NOT exhaust tokens
72+
for (let i = 0; i < 150; i++) {
73+
const { status } = await httpGet(baseUrl, "/api/status");
74+
expect(status).toBe(200);
75+
}
76+
77+
// /api/compile must still succeed
78+
const compileRes = await new Promise<{ status: number; body: string }>((resolve, reject) => {
79+
const url = new URL("/api/compile", baseUrl);
80+
const req = http.request(
81+
{ hostname: url.hostname, port: url.port, path: url.pathname, method: "POST", headers: { "Content-Type": "application/json" } },
82+
(res) => {
83+
let data = "";
84+
res.on("data", (chunk: string) => { data += chunk; });
85+
res.on("end", () => resolve({ status: res.statusCode ?? 0, body: data }));
86+
},
87+
);
88+
req.on("error", reject);
89+
req.write(JSON.stringify({ code: "void setup(){}" }));
90+
req.end();
91+
});
92+
93+
expect(compileRes.status).toBe(200);
94+
expect(JSON.parse(compileRes.body).success).toBe(true);
95+
});
96+
97+
it("/api/compile IS rate-limited after exceeding max", async () => {
98+
const app = express();
99+
100+
const apiLimiter = rateLimit({
101+
windowMs: 15 * 60 * 1000,
102+
max: 3, // low limit to trigger quickly
103+
message: { error: "Too many requests, please try again later." },
104+
standardHeaders: true,
105+
legacyHeaders: false,
106+
skip: (req) =>
107+
req.originalUrl === "/api/examples" ||
108+
req.originalUrl === "/api/status" ||
109+
req.originalUrl === "/api/health",
110+
});
111+
112+
app.use("/api/", apiLimiter);
113+
app.use(express.json());
114+
app.post("/api/compile", (_req, res) => res.json({ success: true }));
115+
116+
const { baseUrl: url2, server: srv2 } = await listen(app);
117+
118+
// Consume all 3 tokens
119+
for (let i = 0; i < 3; i++) {
120+
const resp = await new Promise<{ status: number }>((resolve, reject) => {
121+
const u = new URL("/api/compile", url2);
122+
const req = http.request(
123+
{ hostname: u.hostname, port: u.port, path: u.pathname, method: "POST", headers: { "Content-Type": "application/json" } },
124+
(res) => { res.resume(); res.on("end", () => resolve({ status: res.statusCode ?? 0 })); },
125+
);
126+
req.on("error", reject);
127+
req.write(JSON.stringify({ code: "x" }));
128+
req.end();
129+
});
130+
expect(resp.status).toBe(200);
131+
}
132+
133+
// 4th compile must be rate-limited (429)
134+
const resp = await new Promise<{ status: number }>((resolve, reject) => {
135+
const u = new URL("/api/compile", url2);
136+
const req = http.request(
137+
{ hostname: u.hostname, port: u.port, path: u.pathname, method: "POST", headers: { "Content-Type": "application/json" } },
138+
(res) => { res.resume(); res.on("end", () => resolve({ status: res.statusCode ?? 0 })); },
139+
);
140+
req.on("error", reject);
141+
req.write(JSON.stringify({ code: "x" }));
142+
req.end();
143+
});
144+
145+
expect(resp.status).toBe(429);
146+
147+
await new Promise<void>((resolve, reject) => {
148+
srv2.close((err) => (err ? reject(err) : resolve()));
149+
});
150+
});
151+
});

0 commit comments

Comments
 (0)