Skip to content

Commit c33b2a3

Browse files
feat(api): implement centralized rate limiting middleware for public endpoints (JhaSourav07#581)
## Description Fixes JhaSourav07#270 Implemented centralized middleware-based API rate limiting for public endpoints to improve backend stability and protect shared GitHub API quota usage. ### Protected Routes - `/api/streak` - `/api/github` - `/api/track-user` --- ## What Changed ### Centralized Middleware Protection - added reusable middleware-based rate limiting - implemented IP-based request throttling - added graceful `429 Too Many Requests` responses - configured route-specific middleware matchers ### Rate Limiting Infrastructure - introduced lightweight in-memory request tracking using `Map` - added automatic cleanup/eviction handling for large tracking sets - preserved low-latency edge-compatible behavior - avoided external infrastructure dependencies ### Response Improvements Added standard rate-limit headers: - `X-RateLimit-Limit` - `X-RateLimit-Remaining` - `X-RateLimit-Reset` ### Stability & Safety - preserved existing API response behavior - preserved cache and refresh flow compatibility - prevented unnecessary GitHub API request flooding - ensured middleware execution only applies to protected routes --- ## Verification Validated successfully with: - `npm run format` - `npm run lint` - `npm run typecheck` - `npm run test` - `npm run build` ### Test Results - all existing tests passing - added dedicated rate limiting tests - verified edge/runtime compatibility --- ## Pillar - [ ] 🎨 Pillar 1 — New Theme Design - [ ] 📐 Pillar 2 — Geometric SVG Improvement - [ ] 🕐 Pillar 3 — Timezone Logic Optimization - [x] 🛠️ Other (Bug fix, refactoring, docs) --- ## Visual Preview ### Example 429 Response ```json { "error": "Too many requests" } ``` --- ## Checklist before requesting a review: - [x] I have read the `CONTRIBUTING.md` file. - [x] I have tested these changes locally (`localhost:3000/api/streak?user=YOUR_USERNAME`). - [x] I have run `npm run format` and `npm run lint` locally and resolved all errors (CI will fail otherwise). - [x] My commits follow the Conventional Commits format (e.g., `feat(themes): ...`, `fix(calculate): ...`). - [ ] I have updated `README.md` if I added a new theme or URL parameter. - [x] I have starred the repo. - [x] I have made sure that I have only one commit to merge in this PR. - [x] The SVG output matches the CommitPulse "premium quality" aesthetic standard (no raw elements, smooth animations, correct fonts). - [x] (Recommended) I joined the CommitPulse Discord community for contributor discussions, mentorship, and faster PR support.
1 parent 8fcc741 commit c33b2a3

3 files changed

Lines changed: 194 additions & 0 deletions

File tree

lib/rate-limit.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { describe, it, expect, beforeEach, vi } from 'vitest';
2+
import { rateLimit } from './rate-limit';
3+
4+
describe('rateLimit', () => {
5+
beforeEach(() => {
6+
vi.useFakeTimers();
7+
});
8+
9+
it('allows requests within the limit', () => {
10+
const ip = '1.2.3.4';
11+
for (let i = 0; i < 60; i++) {
12+
const result = rateLimit(ip, 60, 60000);
13+
expect(result.success).toBe(true);
14+
expect(result.remaining).toBe(60 - (i + 1));
15+
}
16+
});
17+
18+
it('blocks requests exceeding the limit', () => {
19+
const ip = '2.3.4.5';
20+
// Consume 60 requests
21+
for (let i = 0; i < 60; i++) {
22+
rateLimit(ip, 60, 60000);
23+
}
24+
25+
// 61st request should fail
26+
const result = rateLimit(ip, 60, 60000);
27+
expect(result.success).toBe(false);
28+
expect(result.remaining).toBe(0);
29+
});
30+
31+
it('resets after the window expires', () => {
32+
const ip = '3.4.5.6';
33+
const windowMs = 60000;
34+
35+
// Consume all requests
36+
for (let i = 0; i < 60; i++) {
37+
rateLimit(ip, 60, windowMs);
38+
}
39+
40+
expect(rateLimit(ip, 60, windowMs).success).toBe(false);
41+
42+
// Fast-forward time
43+
vi.advanceTimersByTime(windowMs + 1);
44+
45+
const result = rateLimit(ip, 60, windowMs);
46+
expect(result.success).toBe(true);
47+
expect(result.remaining).toBe(59);
48+
});
49+
50+
it('tracks different IPs separately', () => {
51+
const ip1 = '11.11.11.11';
52+
const ip2 = '22.22.22.22';
53+
54+
// Consume all requests for ip1
55+
for (let i = 0; i < 60; i++) {
56+
rateLimit(ip1, 60, 60000);
57+
}
58+
59+
expect(rateLimit(ip1, 60, 60000).success).toBe(false);
60+
expect(rateLimit(ip2, 60, 60000).success).toBe(true);
61+
});
62+
});

lib/rate-limit.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,79 @@ export class RateLimiter {
3030

3131
// Global instance for track-user endpoint (5 requests per IP per minute)
3232
export const trackUserRateLimiter = new RateLimiter(5, 60000);
33+
34+
/**
35+
* Lightweight in-memory rate limiter for Next.js Edge Middleware.
36+
*
37+
* Note: In a distributed edge environment, this is per-instance.
38+
* For global rate limiting, a distributed store like Redis would be required.
39+
*/
40+
41+
interface RateLimitResult {
42+
success: boolean;
43+
limit: number;
44+
remaining: number;
45+
reset: number;
46+
}
47+
48+
const trackers = new Map<string, { count: number; expires: number }>();
49+
50+
/**
51+
* Checks if a request from a given IP should be rate limited.
52+
*
53+
* @param ip The IP address to track
54+
* @param limit Maximum number of requests allowed in the window
55+
* @param windowMs Time window in milliseconds
56+
* @returns RateLimitResult
57+
*/
58+
export function rateLimit(
59+
ip: string,
60+
limit: number = 60,
61+
windowMs: number = 60000
62+
): RateLimitResult {
63+
const now = Date.now();
64+
const tracker = trackers.get(ip);
65+
66+
// Periodic cleanup of the map to prevent memory leaks.
67+
// We perform a partial cleanup if the map grows too large.
68+
if (trackers.size > 2000) {
69+
let cleaned = 0;
70+
for (const [key, value] of trackers.entries()) {
71+
if (now > value.expires) {
72+
trackers.delete(key);
73+
cleaned++;
74+
}
75+
// Stop cleaning after some work to avoid blocking the request for too long
76+
if (cleaned > 500) break;
77+
}
78+
}
79+
80+
if (!tracker || now > tracker.expires) {
81+
const expires = now + windowMs;
82+
trackers.set(ip, { count: 1, expires });
83+
return {
84+
success: true,
85+
limit,
86+
remaining: limit - 1,
87+
reset: expires,
88+
};
89+
}
90+
91+
tracker.count++;
92+
93+
if (tracker.count > limit) {
94+
return {
95+
success: false,
96+
limit,
97+
remaining: 0,
98+
reset: tracker.expires,
99+
};
100+
}
101+
102+
return {
103+
success: true,
104+
limit,
105+
remaining: limit - tracker.count,
106+
reset: tracker.expires,
107+
};
108+
}

middleware.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { NextResponse } from 'next/server';
2+
import type { NextRequest } from 'next/server';
3+
import { rateLimit } from './lib/rate-limit';
4+
5+
/**
6+
* Middleware to enforce rate limiting on specific API routes.
7+
*
8+
* Protected Routes:
9+
* - /api/streak
10+
* - /api/github
11+
* - /api/track-user
12+
*
13+
* Limit: 60 requests per minute per IP.
14+
*/
15+
export function middleware(request: NextRequest) {
16+
// Use Vercel's ip property if available, fallback to headers, then localhost
17+
const ip =
18+
request.headers.get('x-forwarded-for')?.split(',')[0] ??
19+
request.headers.get('x-real-ip') ??
20+
'127.0.0.1';
21+
22+
// Apply rate limiting
23+
// 60 requests per 60,000ms (1 minute)
24+
const result = rateLimit(ip, 60, 60000);
25+
26+
if (!result.success) {
27+
return NextResponse.json(
28+
{ error: 'Too many requests' },
29+
{
30+
status: 429,
31+
headers: {
32+
'Content-Type': 'application/json',
33+
'X-RateLimit-Limit': result.limit.toString(),
34+
'X-RateLimit-Remaining': result.remaining.toString(),
35+
'X-RateLimit-Reset': result.reset.toString(),
36+
},
37+
}
38+
);
39+
}
40+
41+
// Add rate limit headers to the response for successful requests
42+
const response = NextResponse.next();
43+
response.headers.set('X-RateLimit-Limit', result.limit.toString());
44+
response.headers.set('X-RateLimit-Remaining', result.remaining.toString());
45+
response.headers.set('X-RateLimit-Reset', result.reset.toString());
46+
47+
return response;
48+
}
49+
50+
/**
51+
* Configure which routes should trigger this middleware.
52+
* Using a matcher is more efficient than checking pathnames inside the middleware.
53+
*/
54+
export const config = {
55+
matcher: ['/api/streak/:path*', '/api/github/:path*', '/api/track-user/:path*'],
56+
};

0 commit comments

Comments
 (0)