Rate limiting is essential for protecting APIs from abuse, ensuring fair resource usage, and maintaining system stability. This comprehensive guide covers testing strategies for API rate limiting, including various algorithms, 429 response handling, retry mechanisms, and distributed rate limiting patterns. Rate limiting is a key component of API security testing and DDoS protection.
Understanding Rate Limiting Algorithms
Different rate limiting algorithms serve different use cases:
Token Bucket Algorithm
Tokens are added at a fixed rate. Each request consumes one token. When bucket is empty, requests are rejected.
// token-bucket.js
class TokenBucket {
constructor(capacity, refillRate) {
this.capacity = capacity;
this.tokens = capacity;
this.refillRate = refillRate; // tokens per second
this.lastRefill = Date.now();
}
refill() {
const now = Date.now();
const timePassed = (now - this.lastRefill) / 1000;
const tokensToAdd = timePassed * this.refillRate;
this.tokens = Math.min(this.capacity, this.tokens + tokensToAdd);
this.lastRefill = now;
}
consume(tokens = 1) {
this.refill();
if (this.tokens >= tokens) {
this.tokens -= tokens;
return true;
}
return false;
}
getAvailableTokens() {
this.refill();
return Math.floor(this.tokens);
}
}
module.exports = TokenBucket;
Testing Token Bucket:
// token-bucket.test.js
const TokenBucket = require('./token-bucket');
describe('Token Bucket Rate Limiting', () => {
test('should allow requests when tokens available', () => {
const bucket = new TokenBucket(10, 1);
for (let i = 0; i < 10; i++) {
expect(bucket.consume()).toBe(true);
}
// 11th request should be rejected
expect(bucket.consume()).toBe(false);
});
test('should refill tokens over time', async () => {
const bucket = new TokenBucket(5, 2); // 2 tokens per second
// Consume all tokens
for (let i = 0; i < 5; i++) {
bucket.consume();
}
expect(bucket.consume()).toBe(false);
// Wait 3 seconds (should add 6 tokens, capped at 5)
await new Promise(resolve => setTimeout(resolve, 3000));
expect(bucket.getAvailableTokens()).toBe(5);
expect(bucket.consume()).toBe(true);
});
test('should handle burst traffic', () => {
const bucket = new TokenBucket(100, 10);
// Burst of 100 requests
let successCount = 0;
for (let i = 0; i < 150; i++) {
if (bucket.consume()) {
successCount++;
}
}
expect(successCount).toBe(100);
});
});
Sliding Window Algorithm
Tracks request count in a sliding time window:
// sliding-window.js
class SlidingWindow {
constructor(limit, windowMs) {
this.limit = limit;
this.windowMs = windowMs;
this.requests = [];
}
removeOldRequests() {
const cutoff = Date.now() - this.windowMs;
this.requests = this.requests.filter(timestamp => timestamp > cutoff);
}
isAllowed() {
this.removeOldRequests();
if (this.requests.length < this.limit) {
this.requests.push(Date.now());
return true;
}
return false;
}
getRemainingRequests() {
this.removeOldRequests();
return Math.max(0, this.limit - this.requests.length);
}
getResetTime() {
this.removeOldRequests();
if (this.requests.length === 0) {
return 0;
}
return this.requests[0] + this.windowMs;
}
}
module.exports = SlidingWindow;
Testing Sliding Window:
// sliding-window.test.js
const SlidingWindow = require('./sliding-window');
describe('Sliding Window Rate Limiting', () => {
test('should allow requests within limit', () => {
const limiter = new SlidingWindow(5, 1000); // 5 requests per second
for (let i = 0; i < 5; i++) {
expect(limiter.isAllowed()).toBe(true);
}
expect(limiter.isAllowed()).toBe(false);
});
test('should reset after window expires', async () => {
const limiter = new SlidingWindow(3, 1000);
// Use all requests
for (let i = 0; i < 3; i++) {
limiter.isAllowed();
}
expect(limiter.isAllowed()).toBe(false);
// Wait for window to expire
await new Promise(resolve => setTimeout(resolve, 1100));
expect(limiter.isAllowed()).toBe(true);
});
test('should track remaining requests accurately', () => {
const limiter = new SlidingWindow(10, 1000);
expect(limiter.getRemainingRequests()).toBe(10);
limiter.isAllowed();
expect(limiter.getRemainingRequests()).toBe(9);
limiter.isAllowed();
limiter.isAllowed();
expect(limiter.getRemainingRequests()).toBe(7);
});
});
Fixed Window Algorithm
Simplest algorithm: count requests per fixed time window:
// fixed-window.js
class FixedWindow {
constructor(limit, windowMs) {
this.limit = limit;
this.windowMs = windowMs;
this.count = 0;
this.windowStart = Date.now();
}
resetIfNeeded() {
const now = Date.now();
if (now - this.windowStart >= this.windowMs) {
this.count = 0;
this.windowStart = now;
}
}
isAllowed() {
this.resetIfNeeded();
if (this.count < this.limit) {
this.count++;
return true;
}
return false;
}
getResetTime() {
return this.windowStart + this.windowMs;
}
}
module.exports = FixedWindow;
Testing 429 Response Handling
Proper 429 response handling is crucial for API testing. Understanding how clients react to rate limits ensures robust API integration.
Express Middleware Implementation
// rate-limit-middleware.js
const express = require('express');
const SlidingWindow = require('./sliding-window');
const rateLimiters = new Map();
function rateLimitMiddleware(options = {}) {
const {
limit = 100,
windowMs = 60000,
keyGenerator = (req) => req.ip
} = options;
return (req, res, next) => {
const key = keyGenerator(req);
if (!rateLimiters.has(key)) {
rateLimiters.set(key, new SlidingWindow(limit, windowMs));
}
const limiter = rateLimiters.get(key);
if (limiter.isAllowed()) {
res.setHeader('X-RateLimit-Limit', limit);
res.setHeader('X-RateLimit-Remaining', limiter.getRemainingRequests());
res.setHeader('X-RateLimit-Reset', Math.ceil(limiter.getResetTime() / 1000));
next();
} else {
const resetTime = Math.ceil((limiter.getResetTime() - Date.now()) / 1000);
res.setHeader('Retry-After', resetTime);
res.setHeader('X-RateLimit-Limit', limit);
res.setHeader('X-RateLimit-Remaining', 0);
res.setHeader('X-RateLimit-Reset', Math.ceil(limiter.getResetTime() / 1000));
res.status(429).json({
error: 'Too Many Requests',
message: `Rate limit exceeded. Try again in ${resetTime} seconds.`,
retryAfter: resetTime
});
}
};
}
module.exports = rateLimitMiddleware;
Testing 429 Responses:
// rate-limit-middleware.test.js
const request = require('supertest');
const express = require('express');
const rateLimitMiddleware = require('./rate-limit-middleware');
describe('Rate Limit Middleware', () => {
let app;
beforeEach(() => {
app = express();
app.use(rateLimitMiddleware({ limit: 5, windowMs: 1000 }));
app.get('/api/test', (req, res) => res.json({ success: true }));
});
test('should allow requests within limit', async () => {
for (let i = 0; i < 5; i++) {
const response = await request(app).get('/api/test');
expect(response.status).toBe(200);
expect(response.headers['x-ratelimit-limit']).toBe('5');
expect(response.headers['x-ratelimit-remaining']).toBeDefined();
}
});
test('should return 429 when limit exceeded', async () => {
// Exhaust rate limit
for (let i = 0; i < 5; i++) {
await request(app).get('/api/test');
}
const response = await request(app).get('/api/test');
expect(response.status).toBe(429);
expect(response.body.error).toBe('Too Many Requests');
expect(response.headers['retry-after']).toBeDefined();
expect(response.headers['x-ratelimit-remaining']).toBe('0');
});
test('should include retry-after header', async () => {
for (let i = 0; i < 5; i++) {
await request(app).get('/api/test');
}
const response = await request(app).get('/api/test');
expect(response.headers['retry-after']).toBeDefined();
expect(parseInt(response.headers['retry-after'])).toBeGreaterThan(0);
});
test('should reset after window expires', async () => {
// Use all requests
for (let i = 0; i < 5; i++) {
await request(app).get('/api/test');
}
// Verify rate limit exceeded
let response = await request(app).get('/api/test');
expect(response.status).toBe(429);
// Wait for window to reset
await new Promise(resolve => setTimeout(resolve, 1100));
// Should allow requests again
response = await request(app).get('/api/test');
expect(response.status).toBe(200);
});
});
Exponential Backoff Testing
// exponential-backoff.js
class ExponentialBackoff {
constructor(options = {}) {
this.initialDelay = options.initialDelay || 1000;
this.maxDelay = options.maxDelay || 60000;
this.factor = options.factor || 2;
this.jitter = options.jitter !== false;
this.maxRetries = options.maxRetries || 5;
}
async execute(fn, retries = 0) {
try {
return await fn();
} catch (error) {
if (retries >= this.maxRetries) {
throw error;
}
if (error.response?.status === 429) {
const retryAfter = error.response.headers['retry-after'];
let delay;
if (retryAfter) {
delay = parseInt(retryAfter) * 1000;
} else {
delay = Math.min(
this.initialDelay * Math.pow(this.factor, retries),
this.maxDelay
);
if (this.jitter) {
delay = delay * (0.5 + Math.random() * 0.5);
}
}
console.log(`Retrying after ${delay}ms (attempt ${retries + 1}/${this.maxRetries})`);
await new Promise(resolve => setTimeout(resolve, delay));
return this.execute(fn, retries + 1);
}
throw error;
}
}
}
module.exports = ExponentialBackoff;
Testing Exponential Backoff:
// exponential-backoff.test.js
const ExponentialBackoff = require('./exponential-backoff');
const axios = require('axios');
describe('Exponential Backoff', () => {
test('should retry with exponential delays', async () => {
const backoff = new ExponentialBackoff({
initialDelay: 100,
factor: 2,
maxRetries: 3
});
let attempts = 0;
const timestamps = [];
const mockFn = jest.fn(async () => {
timestamps.push(Date.now());
attempts++;
if (attempts < 3) {
const error = new Error('Rate limited');
error.response = { status: 429, headers: {} };
throw error;
}
return 'success';
});
const result = await backoff.execute(mockFn);
expect(result).toBe('success');
expect(attempts).toBe(3);
// Verify delays increase exponentially
const delay1 = timestamps[1] - timestamps[0];
const delay2 = timestamps[2] - timestamps[1];
expect(delay1).toBeGreaterThanOrEqual(90);
expect(delay2).toBeGreaterThanOrEqual(180);
});
test('should respect retry-after header', async () => {
const backoff = new ExponentialBackoff({ maxRetries: 2 });
let attempts = 0;
const timestamps = [];
const mockFn = jest.fn(async () => {
timestamps.push(Date.now());
attempts++;
if (attempts === 1) {
const error = new Error('Rate limited');
error.response = {
status: 429,
headers: { 'retry-after': '2' }
};
throw error;
}
return 'success';
});
const result = await backoff.execute(mockFn);
expect(result).toBe('success');
const delay = timestamps[1] - timestamps[0];
expect(delay).toBeGreaterThanOrEqual(1900);
expect(delay).toBeLessThan(2200);
});
test('should fail after max retries', async () => {
const backoff = new ExponentialBackoff({
initialDelay: 10,
maxRetries: 2
});
const mockFn = jest.fn(async () => {
const error = new Error('Rate limited');
error.response = { status: 429, headers: {} };
throw error;
});
await expect(backoff.execute(mockFn)).rejects.toThrow('Rate limited');
expect(mockFn).toHaveBeenCalledTimes(3); // Initial + 2 retries
});
});
Distributed Rate Limiting with Redis
For high-throughput APIs, distributed rate limiting is essential. Learn more about API performance testing strategies.
// redis-rate-limiter.js
const Redis = require('ioredis');
class RedisRateLimiter {
constructor(redisClient, options = {}) {
this.redis = redisClient;
this.limit = options.limit || 100;
this.windowMs = options.windowMs || 60000;
}
async isAllowed(key) {
const now = Date.now();
const windowStart = now - this.windowMs;
const multi = this.redis.multi();
// Remove old entries
multi.zremrangebyscore(key, 0, windowStart);
// Count current requests
multi.zcard(key);
// Add current request
multi.zadd(key, now, `${now}-${Math.random()}`);
// Set expiry
multi.expire(key, Math.ceil(this.windowMs / 1000));
const results = await multi.exec();
const count = results[1][1];
return count < this.limit;
}
async getRemainingRequests(key) {
const now = Date.now();
const windowStart = now - this.windowMs;
await this.redis.zremrangebyscore(key, 0, windowStart);
const count = await this.redis.zcard(key);
return Math.max(0, this.limit - count);
}
}
module.exports = RedisRateLimiter;
Testing Distributed Rate Limiting:
// redis-rate-limiter.test.js
const Redis = require('ioredis');
const RedisRateLimiter = require('./redis-rate-limiter');
describe('Redis Rate Limiter', () => {
let redis;
let limiter;
beforeAll(() => {
redis = new Redis({
host: 'localhost',
port: 6379
});
limiter = new RedisRateLimiter(redis, {
limit: 10,
windowMs: 1000
});
});
beforeEach(async () => {
await redis.flushall();
});
test('should allow requests within limit', async () => {
const key = 'user:123';
for (let i = 0; i < 10; i++) {
const allowed = await limiter.isAllowed(key);
expect(allowed).toBe(true);
}
const allowed = await limiter.isAllowed(key);
expect(allowed).toBe(false);
});
test('should work across multiple clients', async () => {
const limiter1 = new RedisRateLimiter(redis, { limit: 5, windowMs: 1000 });
const limiter2 = new RedisRateLimiter(redis, { limit: 5, windowMs: 1000 });
const key = 'user:456';
// Client 1 makes 3 requests
for (let i = 0; i < 3; i++) {
await limiter1.isAllowed(key);
}
// Client 2 makes 2 requests
for (let i = 0; i < 2; i++) {
await limiter2.isAllowed(key);
}
// Total 5 requests, next should be rejected
const allowed1 = await limiter1.isAllowed(key);
expect(allowed1).toBe(false);
const allowed2 = await limiter2.isAllowed(key);
expect(allowed2).toBe(false);
});
test('should reset after window expires', async () => {
const key = 'user:789';
for (let i = 0; i < 10; i++) {
await limiter.isAllowed(key);
}
expect(await limiter.isAllowed(key)).toBe(false);
await new Promise(resolve => setTimeout(resolve, 1100));
expect(await limiter.isAllowed(key)).toBe(true);
});
afterAll(async () => {
await redis.quit();
});
});
Rate Limiting Testing Best Practices
Testing Checklist
- Test each rate limiting algorithm (token bucket, sliding window, fixed window)
- Verify 429 responses include proper headers
- Test Retry-After header values
- Validate X-RateLimit headers (Limit, Remaining, Reset)
- Test exponential backoff with jitter
- Verify rate limits reset correctly
- Test distributed rate limiting across instances
- Test different rate limits per user/API key
- Validate burst traffic handling
- Test rate limiting under concurrent load
- Monitor rate limiter performance impact
Algorithm Comparison
Algorithm | Pros | Cons | Use Case |
---|---|---|---|
Token Bucket | Allows bursts, smooth rate | Complex implementation | APIs with variable load |
Sliding Window | Accurate, fair | Higher memory usage | Strict rate enforcement |
Fixed Window | Simple, low overhead | Edge case burst issue | High-throughput APIs |
Leaky Bucket | Smooths output rate | Rejects bursts | Queue-based systems |
Conclusion
Effective rate limiting testing ensures APIs can handle abuse, maintain stability, and provide clear feedback to clients. By implementing comprehensive tests for various algorithms, 429 response handling, exponential backoff, and distributed scenarios, you can build robust rate limiting systems.
Key takeaways:
- Choose the right algorithm for your use case
- Always include Retry-After headers in 429 responses
- Implement exponential backoff with jitter on client side
- Use Redis for distributed rate limiting
- Test rate limits under realistic load conditions
- Monitor rate limiting metrics in production
Robust rate limiting protects your APIs while providing a good user experience for legitimate clients. Combine this with API security testing and DDoS protection strategies for comprehensive API protection.