Skip to content

Commit f5c63a6

Browse files
chapterjasonclaude
andcommitted
feat(api): add per-API-key rate limiting
Fixed-window in-memory limiter keyed by project slug. Returns HTTP 429 with Retry-After header when exceeded. Configurable via env vars: BACKLOG_RATE_LIMIT_MAX (default: 200 requests) BACKLOG_RATE_LIMIT_WINDOW (default: 60 seconds) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 4ed544b commit f5c63a6

2 files changed

Lines changed: 44 additions & 0 deletions

File tree

packages/api/src/api-server.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { NotFoundError, VersionConflictError } from "@sourecode/agent-backlog-co
44
import { logger } from "@sourecode/agent-backlog-core/logger.js";
55
import { json } from "@sourecode/agent-backlog-core/http/helpers.js";
66
import { authenticate } from "./auth/auth.js";
7+
import { checkRateLimit } from "./auth/rate-limit.js";
78
import { getStoreForSlug } from "./store/store.js";
89
import { sse } from "./sse/broadcaster.js";
910
import { Router } from "./router.js";
@@ -67,6 +68,13 @@ async function handleRequest(req, res) {
6768
return;
6869
}
6970

71+
const limited = checkRateLimit(projectSlug);
72+
if (limited) {
73+
res.setHeader("Retry-After", String(limited.retryAfter));
74+
json(res, 429, { error: "Too many requests. Try again later.", retryAfter: limited.retryAfter });
75+
return;
76+
}
77+
7078
const store = getStoreForSlug(projectSlug);
7179
if (await apiRouter.dispatch(req, res, url.pathname, { store, projectSlug, url })) return;
7280

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/**
2+
* Simple in-memory fixed-window rate limiter keyed by project slug.
3+
*
4+
* Configurable via env vars:
5+
* BACKLOG_RATE_LIMIT_MAX — max requests per window (default: 200)
6+
* BACKLOG_RATE_LIMIT_WINDOW — window size in seconds (default: 60)
7+
*/
8+
9+
const MAX = parseInt(process.env.BACKLOG_RATE_LIMIT_MAX ?? "200", 10);
10+
const WINDOW_MS = parseInt(process.env.BACKLOG_RATE_LIMIT_WINDOW ?? "60", 10) * 1000;
11+
12+
// Map<slug, { count, windowStart }>
13+
const counters = new Map();
14+
15+
/**
16+
* Check whether the given slug is within the rate limit.
17+
* Returns null if allowed, or { retryAfter } (seconds) if exceeded.
18+
*/
19+
export function checkRateLimit(slug) {
20+
const now = Date.now();
21+
let entry = counters.get(slug);
22+
23+
if (!entry || now - entry.windowStart >= WINDOW_MS) {
24+
entry = { count: 0, windowStart: now };
25+
counters.set(slug, entry);
26+
}
27+
28+
entry.count++;
29+
30+
if (entry.count > MAX) {
31+
const retryAfter = Math.ceil((entry.windowStart + WINDOW_MS - now) / 1000);
32+
return { retryAfter };
33+
}
34+
35+
return null;
36+
}

0 commit comments

Comments
 (0)