Rate Limiting
Seed implements distributed rate limiting using Redis-backed sliding window algorithm to protect against abuse and ensure fair resource usage across multiple endpoints.
Overview
The rate limiting system provides:
- Distributed Rate Limiting: Redis-backed storage works across multiple server instances
- Sliding Window Algorithm: More accurate than fixed windows, prevents burst traffic at window boundaries
- Per-IP Limiting: Prevents individual IPs from overwhelming the service
- Global Limiting: Protects against distributed attacks across many IPs
- Graceful Degradation: Falls back to allowing requests if Redis is unavailable
- Configurable Windows: Different limits for different endpoint types
Architecture
Components
Redis Storage
Rate limiting uses Redis sorted sets for efficient sliding window implementation:
Key: ratelimit:mcp:192.168.1.100
Value: Sorted Set
- Member: request_timestamp_1
- Score: timestamp_1
- Member: request_timestamp_2
- Score: timestamp_2
...TTL Management:
- Keys expire after the window duration
- Old entries are removed before counting
- Atomic operations ensure accuracy
Implementation
File: src/middleware/distributed-rate-limit.ts
The rate limiter is implemented as Express middleware using the factory pattern:
interface RateLimitConfig {
windowMs: number; // Time window in milliseconds
maxRequests: number; // Max requests per IP per window
globalMax: number; // Max requests globally per window
keyPrefix: string; // Redis key prefix
endpointType: string; // For logging/metrics (e.g., "mcp", "dcr")
}
function createDistributedRateLimiter(config: RateLimitConfig) {
return async (req: Request, res: Response, next: NextFunction) => {
// Implementation
};
}Sliding Window Algorithm
Step 1: Remove Old Entries
const now = Date.now();
const windowStart = now - config.windowMs;
// Remove entries outside the current window
await redis.zremrangebyscore(key, '-inf', windowStart);Step 2: Count Current Requests
// Count requests in current window
const count = await redis.zcard(key);Step 3: Check Limits
// Per-IP limit check
if (count >= config.maxRequests) {
return sendRateLimitError(res, config.windowMs);
}
// Global limit check
const globalKey = `${config.keyPrefix}global`;
const globalCount = await redis.zcard(globalKey);
if (globalCount >= config.globalMax) {
return sendRateLimitError(res, config.windowMs);
}Step 4: Add Current Request
// Add current request to sorted set
await redis.zadd(key, now, `${now}-${Math.random()}`);
// Set expiry on key
await redis.expire(key, Math.ceil(config.windowMs / 1000));IP Address Extraction
function getClientIp(req: Request): string {
// Check X-Forwarded-For header (for proxied requests)
const forwarded = req.headers['x-forwarded-for'];
if (forwarded) {
return Array.isArray(forwarded)
? forwarded[0].split(',')[0].trim()
: forwarded.split(',')[0].trim();
}
// Check X-Real-IP header
const realIp = req.headers['x-real-ip'];
if (realIp) {
return Array.isArray(realIp) ? realIp[0] : realIp;
}
// Fallback to socket IP
return req.socket.remoteAddress || 'unknown';
}Endpoint Configuration
MCP Endpoint Rate Limiting
Applied to: POST /mcp, DELETE /mcp
Configuration:
MCP_RATE_LIMIT_WINDOW_MS=60000 # 1 minute
MCP_RATE_LIMIT_MAX=100 # 100 requests per IP per minute
MCP_GLOBAL_RATE_LIMIT_MAX=10000 # 10,000 requests globally per minuteRationale:
- MCP requests are high-value protocol operations
- 100 req/min per IP allows ~1.6 req/sec for normal usage
- Global limit protects against distributed attacks
- 1-minute window balances protection and usability
Usage:
// src/routes/mcp.ts
import { createDistributedRateLimiter } from '../middleware/distributed-rate-limit.js';
import { config } from '../config/index.js';
const mcpRateLimiter = createDistributedRateLimiter({
windowMs: config.rateLimit.mcp.windowMs,
maxRequests: config.rateLimit.mcp.maxRequests,
globalMax: config.rateLimit.mcp.globalMax,
keyPrefix: 'ratelimit:mcp:',
endpointType: 'mcp',
});
router.post('/mcp', mcpRateLimiter, handleMcpRequest);
router.delete('/mcp', mcpRateLimiter, handleMcpDelete);DCR Endpoint Rate Limiting
Applied to: POST /oauth/register
Configuration:
DCR_RATE_LIMIT_WINDOW_MS=3600000 # 1 hour
DCR_RATE_LIMIT_MAX=10 # 10 registrations per IP per hour
DCR_GLOBAL_RATE_LIMIT_MAX=1000 # 1,000 registrations globally per hourRationale:
- Client registration is infrequent (once per client)
- Strict limits prevent registration abuse
- 10 reg/hour per IP is generous for legitimate use
- Global limit protects against distributed registration attacks
Usage:
// src/routes/oauth-register.ts
const dcrRateLimiter = createDistributedRateLimiter({
windowMs: config.rateLimit.dcr.windowMs,
maxRequests: config.rateLimit.dcr.maxRequests,
globalMax: config.rateLimit.dcr.globalMax,
keyPrefix: 'ratelimit:dcr:',
endpointType: 'dcr',
});
router.post('/oauth/register', dcrRateLimiter, handleRegistration);OAuth Authorize Endpoint Rate Limiting
Applied to: GET /oauth/authorize
Configuration:
OAUTH_AUTHORIZE_RATE_LIMIT_WINDOW_MS=60000 # 1 minute
OAUTH_AUTHORIZE_RATE_LIMIT_MAX=10 # 10 authorizations per IP per minute
OAUTH_AUTHORIZE_GLOBAL_RATE_LIMIT_MAX=1000 # 1,000 authorizations globally per minuteRationale:
- OAuth authorization requests are user-facing and initiated during login flows
- 10 req/min per IP prevents automated authorization request spam
- Protects against DoS attacks via redirect loops
- Global limit protects against distributed authorization flooding
- 1-minute window provides quick recovery for legitimate retries
Usage:
// src/routes/oauth-authorize.ts
const authorizeRateLimiter = createDistributedRateLimiter({
windowMs: config.rateLimit.authorize.windowMs,
maxRequests: config.rateLimit.authorize.maxRequests,
globalMax: config.rateLimit.authorize.globalMax,
keyPrefix: 'ratelimit:authorize:',
endpointType: 'authorize',
});
router.get('/oauth/authorize', authorizeRateLimiter, handleAuthorization);Response Format
Success Response
When rate limit is not exceeded, the middleware adds headers to the response:
HTTP/1.1 200 OK
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 73
X-RateLimit-Reset: 1704643200Headers:
X-RateLimit-Limit: Maximum requests allowed in windowX-RateLimit-Remaining: Requests remaining in current windowX-RateLimit-Reset: Unix timestamp when the limit resets
Rate Limit Exceeded
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1704643200
Retry-After: 45
Content-Type: application/json
{
"jsonrpc": "2.0",
"error": {
"code": -32000,
"message": "Too Many Requests",
"data": {
"reason": "rate_limit_exceeded",
"details": "Rate limit exceeded. Please try again later.",
"retryAfter": 45
}
},
"id": null
}Additional Headers:
Retry-After: Seconds until the client can retry
Global Toggle
Rate limiting can be disabled globally via configuration:
RATE_LIMIT_ENABLED=falseWhen disabled:
- All rate limit middleware is bypassed
- No Redis operations performed
- All requests allowed through
- Useful for development/testing
Implementation:
export function createDistributedRateLimiter(config: RateLimitConfig) {
return async (req: Request, res: Response, next: NextFunction) => {
// Check global toggle
if (!rateLimitConfig.enabled) {
return next();
}
// ... rate limiting logic
};
}Graceful Degradation
If Redis is unavailable, the rate limiter gracefully degrades:
try {
// Rate limiting logic using Redis
} catch (error) {
logger.error('Rate limiting Redis error', {
error: error.message,
endpoint: config.endpointType,
ip: clientIp,
});
// Allow request through (fail open)
return next();
}Behavior:
- Errors logged but don't block requests
- Prevents rate limiting from causing downtime
- Monitors should alert on Redis errors
- Consider fail-closed behavior for critical endpoints
Observability
Prometheus Metrics
Request Counters:
const rateLimitRequests = new promClient.Counter({
name: 'http_request_rate_limit_requests_total',
help: 'Total HTTP requests evaluated by rate limiter',
labelNames: ['endpoint', 'limited'],
});
// Usage
rateLimitRequests.inc({ endpoint: 'mcp', limited: 'false' });
rateLimitRequests.inc({ endpoint: 'dcr', limited: 'true' });Example Queries:
# Rate limit success rate
rate(http_request_rate_limit_requests_total{limited="false"}[5m])
/ rate(http_request_rate_limit_requests_total[5m])
# Rejection rate by endpoint
rate(http_request_rate_limit_requests_total{limited="true"}[5m])
# MCP endpoint load
sum(rate(http_request_rate_limit_requests_total{endpoint="mcp"}[5m]))
# DCR abuse detection
rate(http_request_rate_limit_requests_total{endpoint="dcr",limited="true"}[5m]) > 0.1Structured Logging
Request Allowed:
logger.debug('Rate limit check passed', {
endpoint: config.endpointType,
ip: clientIp,
count: currentCount,
limit: config.maxRequests,
});Request Blocked:
logger.warn('Rate limit exceeded', {
endpoint: config.endpointType,
ip: clientIp,
count: currentCount,
limit: config.maxRequests,
retryAfter: Math.ceil(config.windowMs / 1000),
});Redis Errors:
logger.error('Rate limiting Redis error', {
error: error.message,
endpoint: config.endpointType,
ip: clientIp,
action: 'failing_open',
});Configuration Reference
Environment Variables
# Global toggle
RATE_LIMIT_ENABLED=true # Enable/disable rate limiting
# MCP endpoint
MCP_RATE_LIMIT_WINDOW_MS=60000 # 1 minute window
MCP_RATE_LIMIT_MAX=100 # Per-IP limit
MCP_GLOBAL_RATE_LIMIT_MAX=10000 # Global limit
# DCR endpoint
DCR_RATE_LIMIT_WINDOW_MS=3600000 # 1 hour window
DCR_RATE_LIMIT_MAX=10 # Per-IP limit
DCR_GLOBAL_RATE_LIMIT_MAX=1000 # Global limit
# Redis (required for rate limiting)
REDIS_URL=redis://redis:6379Code Configuration
// src/config/rate-limit.ts
export const rateLimitConfig = {
enabled: process.env.RATE_LIMIT_ENABLED !== "false",
mcp: {
windowMs: parseInt(process.env.MCP_RATE_LIMIT_WINDOW_MS ?? "60000", 10),
maxRequests: parseInt(process.env.MCP_RATE_LIMIT_MAX ?? "100", 10),
globalMax: parseInt(process.env.MCP_GLOBAL_RATE_LIMIT_MAX ?? "10000", 10),
},
dcr: {
windowMs: parseInt(process.env.DCR_RATE_LIMIT_WINDOW_MS ?? "3600000", 10),
maxRequests: parseInt(process.env.DCR_RATE_LIMIT_MAX ?? "10", 10),
globalMax: parseInt(process.env.DCR_GLOBAL_RATE_LIMIT_MAX ?? "1000", 10),
},
};Testing
Unit Testing
describe('Rate Limiting', () => {
it('should allow requests under limit', async () => {
const req = createMockRequest({ ip: '192.168.1.100' });
const res = createMockResponse();
const next = vi.fn();
await rateLimiter(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
it('should block requests over limit', async () => {
// Make maxRequests + 1 requests
for (let i = 0; i <= maxRequests; i++) {
await rateLimiter(req, res, next);
}
expect(res.status).toHaveBeenCalledWith(429);
});
it('should reset after window expires', async () => {
// Make maxRequests requests
// Wait for window to expire
// Make another request - should succeed
});
});Best Practices
Choosing Limits
Considerations:
- Normal Usage Patterns: What's typical request frequency?
- Burst Allowance: Should short bursts be allowed?
- Attack Mitigation: What prevents abuse?
- User Experience: Does limit impact legitimate users?
Guidelines:
- Start conservative, increase if needed
- Monitor rejection rates
- Different limits for different endpoints
- Consider authenticated vs anonymous differently
Window Duration
Short Windows (1 minute):
- ✅ Quick recovery from limits
- ✅ Better for high-frequency operations
- ❌ Less effective against slow attacks
Long Windows (1 hour):
- ✅ Effective against sustained attacks
- ✅ Good for infrequent operations
- ❌ Long cooldown period
Global vs Per-IP
Per-IP Limits:
- Protect against single source attacks
- Fair resource distribution
- Can be bypassed with IP rotation
Global Limits:
- Protect against distributed attacks
- Prevent service degradation
- May impact all users during attack
Best Practice: Use both per-IP and global limits
Troubleshooting
High Rejection Rate
Symptoms:
- Many 429 responses
- Users reporting access issues
Diagnosis:
# Check rejection rate
rate(http_request_rate_limit_requests_total{limited="true"}[5m])
# Check per-IP distribution
topk(10, sum by (ip) (rate(http_request_rate_limit_requests_total[5m])))Solutions:
- Increase rate limits if legitimate traffic
- Investigate IPs with high request rates
- Add IP to blocklist if malicious
Redis Connection Issues
Symptoms:
- Rate limiting not working
- All requests allowed through
- Redis error logs
Diagnosis:
# Check Redis connectivity
redis-cli -h redis ping
# Check Seed logs
grep "Rate limiting Redis error" logs.jsonSolutions:
- Verify Redis is running
- Check REDIS_URL configuration
- Review Redis resource limits
- Consider fail-closed behavior
Uneven Distribution
Symptoms:
- Some users blocked, others not
- Inconsistent rate limit behavior
Diagnosis:
- Check IP extraction logic
- Verify proxy headers (X-Forwarded-For)
- Review Redis key consistency
Implementation Files
- Middleware:
src/middleware/distributed-rate-limit.ts- Rate limiting implementation - Config:
src/config/rate-limit.ts- Rate limit configuration - MCP Routes:
src/routes/mcp.ts- MCP endpoint rate limiting - OAuth Routes:
src/routes/oauth-register.ts- DCR endpoint rate limiting - Metrics:
src/services/metrics.ts- Prometheus metrics definitions - Tests:
src/middleware/distributed-rate-limit.test.ts
Related Documentation
- Configuration System - Environment configuration
- OAuth 2.1 Implementation - DCR endpoint details
- MCP Server Design - MCP endpoint details
- Observability - Metrics and monitoring
- Security - Security architecture