Skip to content

Commit ea7dc23

Browse files
Merge pull request #1301 from Wiseman52/fix/issue-1168-persist-ratelimit-counters
fix(ratelimit): persist rate-limit counters across process restarts #1168
2 parents 8c42c4b + 26af20a commit ea7dc23

4 files changed

Lines changed: 136 additions & 3 deletions

File tree

src/app/api/tutorials/__tests__/ratelimit.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ import {
99
resetIdentityRateLimit,
1010
} from '@/lib/ratelimit';
1111

12+
// Mock the DB pool to prevent real database calls during tests
13+
vi.mock('@/lib/db/pool', () => ({
14+
query: vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }),
15+
}));
16+
1217
// ---------------------------------------------------------------------------
1318
// Helpers
1419
// ---------------------------------------------------------------------------
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
-- Migration: Create rate_limits table
2+
-- Description: Persists rate-limit counters across process restarts so that
3+
-- in-memory counters no longer reset on every deploy.
4+
5+
CREATE TABLE IF NOT EXISTS rate_limits (
6+
identifier VARCHAR(512) PRIMARY KEY,
7+
count INTEGER NOT NULL DEFAULT 0,
8+
reset_at BIGINT NOT NULL,
9+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
10+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
11+
);
12+
13+
-- Index for cleaning up expired entries
14+
CREATE INDEX IF NOT EXISTS idx_rate_limits_reset_at ON rate_limits(reset_at);

src/lib/ratelimit.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
1212
import { parseTrustedProxyIPs } from '@/config/environment';
1313
import { slidingWindowRateLimit } from './ratelimit';
1414

15+
// Mock the DB pool to prevent real database calls during tests
16+
vi.mock('@/lib/db/pool', () => ({
17+
query: vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }),
18+
}));
19+
1520
// ---------------------------------------------------------------------------
1621
// Helpers
1722
// ---------------------------------------------------------------------------

src/lib/ratelimit.ts

Lines changed: 112 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,38 @@
11
import { NextResponse } from 'next/server';
22
import { getTrustedProxyConfig } from '@/config/environment';
33

4+
type DbQueryResult = { rows: Array<Record<string, unknown>> };
5+
type DbQueryFn = (text: string, params?: unknown[]) => Promise<DbQueryResult>;
6+
7+
let dbQueryPromise: Promise<DbQueryFn | null> | null = null;
8+
9+
/**
10+
* Lazily resolves the pg-backed `query` helper.
11+
*
12+
* `pg` can only run in a Node.js runtime and importing it from a module shared
13+
* with Edge routes would break their bundling, so the database module is
14+
* loaded dynamically and only outside the Edge runtime. When the DB is
15+
* unavailable the in-memory cache remains authoritative.
16+
*/
17+
async function loadDbQuery(): Promise<DbQueryFn | null> {
18+
if (process.env.NEXT_RUNTIME === 'edge') return null;
19+
if (!dbQueryPromise) {
20+
dbQueryPromise = import(/* webpackIgnore: true */ '@/lib/db/pool')
21+
.then((mod) => mod.query as DbQueryFn)
22+
.catch(() => null);
23+
}
24+
return dbQueryPromise;
25+
}
26+
427
/**
5-
* In-memory sliding window rate limiter for API routes.
28+
* Database-backed sliding window rate limiter for API routes.
629
* Provides IP-based rate limiting with configurable limits and windows.
730
*
31+
* Counters are persisted to PostgreSQL so they survive process restarts.
32+
* An in-memory Map serves as a fast synchronous cache; every write is also
33+
* persisted to the database asynchronously (fire-and-forget) so that
34+
* subsequent processes can pick up the state after a deploy.
35+
*
836
* Security: getClientIP() only trusts x-forwarded-for / x-real-ip headers when
937
* the request arrives from a proxy listed in TRUSTED_PROXY_IPS (see
1038
* src/config/environment.ts). When no proxies are configured the headers are
@@ -32,8 +60,85 @@ interface RateLimitEntry {
3260
resetAt: number;
3361
}
3462

63+
/**
64+
* Fast in-memory cache used as the synchronous hot path.
65+
* Writes are also persisted to the database asynchronously.
66+
*/
3567
const stores = new Map<string, RateLimitEntry>();
3668

69+
/**
70+
* Persists a single rate-limit entry to the database.
71+
* Errors are silently swallowed so that a DB outage never blocks request
72+
* processing — the in-memory cache still provides best-effort limiting.
73+
*/
74+
async function persistToDb(identifier: string, entry: RateLimitEntry): Promise<void> {
75+
const query = await loadDbQuery();
76+
if (!query) return;
77+
try {
78+
await query(
79+
`INSERT INTO rate_limits (identifier, count, reset_at, updated_at)
80+
VALUES ($1, $2, $3, NOW())
81+
ON CONFLICT (identifier) DO UPDATE
82+
SET count = EXCLUDED.count,
83+
reset_at = EXCLUDED.reset_at,
84+
updated_at = NOW()`,
85+
[identifier, entry.count, entry.resetAt],
86+
);
87+
} catch {
88+
// Silently ignore — the in-memory store is still authoritative for
89+
// the current process, and DB unavailability should not break requests.
90+
}
91+
}
92+
93+
/**
94+
* Removes an expired entry from the database.
95+
*/
96+
async function removeFromDb(identifier: string): Promise<void> {
97+
const query = await loadDbQuery();
98+
if (!query) return;
99+
try {
100+
await query('DELETE FROM rate_limits WHERE identifier = $1', [identifier]);
101+
} catch {
102+
// Silently ignore.
103+
}
104+
}
105+
106+
/**
107+
* Loads all non-expired rate-limit entries from the database into the
108+
* in-memory cache on process startup. Called once at module load time.
109+
*/
110+
async function loadFromDb(): Promise<void> {
111+
const query = await loadDbQuery();
112+
if (!query) return;
113+
try {
114+
const now = Date.now();
115+
const result = await query(
116+
'SELECT identifier, count, reset_at FROM rate_limits WHERE reset_at > $1',
117+
[now],
118+
);
119+
for (const row of result.rows) {
120+
stores.set(row.identifier as string, {
121+
count: row.count as number,
122+
resetAt: row.reset_at as number,
123+
});
124+
}
125+
} catch {
126+
// DB may not be available yet (e.g. during build or early startup).
127+
// The in-memory store will be used as a fallback.
128+
}
129+
}
130+
131+
// Kick off the load-on-startup — non-blocking.
132+
void loadFromDb();
133+
134+
/**
135+
* Synchronous sliding-window rate limiter backed by an in-memory cache
136+
* with asynchronous database persistence.
137+
*
138+
* The function signature is intentionally synchronous so that the 30+
139+
* existing call-sites (withRateLimit, certificate routes, etc.) do not
140+
* need to become async.
141+
*/
37142
export function slidingWindowRateLimit(
38143
identifier: string,
39144
config: RateLimitConfig,
@@ -44,9 +149,12 @@ export function slidingWindowRateLimit(
44149
if (!entry || entry.resetAt <= now) {
45150
if (entry) {
46151
stores.delete(identifier);
152+
void removeFromDb(identifier);
47153
}
48154
const resetAt = now + config.windowMs;
49-
stores.set(identifier, { count: 1, resetAt });
155+
const newEntry: RateLimitEntry = { count: 1, resetAt };
156+
stores.set(identifier, newEntry);
157+
void persistToDb(identifier, newEntry);
50158
return {
51159
success: true,
52160
remaining: config.limit - 1,
@@ -68,6 +176,7 @@ export function slidingWindowRateLimit(
68176

69177
entry.count += 1;
70178
stores.set(identifier, entry);
179+
void persistToDb(identifier, entry);
71180

72181
return {
73182
success: true,
@@ -232,4 +341,4 @@ export function withRateLimit<T extends Request>(
232341
addHeaders,
233342
rateLimitResponse: createRateLimitResponse(result),
234343
};
235-
}
344+
}

0 commit comments

Comments
 (0)