Rate Limiting Guide
Guren provides a flexible rate limiting system to protect your application from abuse. It supports multiple storage backends, custom key generators, and both fixed and sliding window algorithms.
Rate Limiting Guide
Guren provides a flexible rate limiting system to protect your application from abuse. It supports multiple storage backends, custom key generators, and both fixed and sliding window algorithms.
Core Concepts
- RateLimitStore – Interface for storing rate limit data.
- MemoryRateLimitStore – In-memory store for single-process applications.
- SlidingWindowRateLimitStore – More accurate sliding window implementation.
- Rate Limit Headers – Standard headers for client feedback.
Basic Usage
Quick Start
import { Router, createRateLimitMiddleware } from '@guren/core'
// Apply to all routes - 100 requests per minute per IP
const router = new Router()
router.middleware(createRateLimitMiddleware()).group((group) => {
group.get('/api/*', [ApiController, 'handle'])
})
Route-Specific Limits
import { Router, createRateLimitMiddleware } from '@guren/core'
const router = new Router()
// Stricter limit for login endpoint - 5 attempts per 15 minutes
router.post('/login', [AuthController, 'login']).middleware(
createRateLimitMiddleware({
limit: 5,
windowMs: 15 * 60 * 1000, // 15 minutes
})
)
// Higher limit for authenticated API routes
router.middleware('auth').group((group) => {
group.get('/api/*', [ApiController, 'handle']).middleware(
createRateLimitMiddleware({
limit: 1000,
windowMs: 60 * 60 * 1000, // 1 hour
})
)
})
Configuration Options
interface RateLimitOptions {
/** Maximum requests in the time window (default: 100) */
limit?: number
/** Time window in milliseconds (default: 60000 = 1 minute) */
windowMs?: number
/** Function to extract rate limit key from request */
keyGenerator?: (ctx: Context) => string | Promise<string>
/** Rate limit store implementation */
store?: RateLimitStore
/** Skip rate limiting for certain requests */
skip?: (ctx: Context) => boolean | Promise<boolean>
/** Custom handler when rate limit exceeded */
onRateLimited?: (ctx: Context, retryAfter: number) => Response | Promise<Response>
/** Add rate limit headers to responses (default: true) */
headers?: boolean
/** Error message when limited (default: 'Too many requests...') */
message?: string
/** HTTP status code when limited (default: 429) */
statusCode?: number
/** Prefix for rate limit keys (default: 'rl:') */
keyPrefix?: string
/** Trust proxy headers (CF-Connecting-IP, True-Client-IP, X-Real-IP, X-Forwarded-For) for client IPs (default: false) */
trustProxy?: boolean
}
Deployments Behind a Proxy
When your app always sits behind a reverse proxy or CDN (Cloudflare, ALB, Nginx), enable trustProxy for per-client limiting without writing a custom keyGenerator:
createRateLimitMiddleware({
limit: 100,
trustProxy: true, // resolves CF-Connecting-IP → True-Client-IP → X-Real-IP → X-Forwarded-For[0]
})
Warning: Enable
trustProxyonly when every request passes through your proxy. On direct deployments clients can spoof these headers to bypass per-client limits — that is why it defaults tofalse.
Full Configuration Example
import { createRateLimitMiddleware, MemoryRateLimitStore } from '@guren/core'
const store = new MemoryRateLimitStore()
const rateLimiter = createRateLimitMiddleware({
limit: 100,
windowMs: 60 * 1000, // 1 minute
store,
keyPrefix: 'api:',
headers: true,
message: 'Rate limit exceeded. Please slow down.',
statusCode: 429,
// Key based on authenticated user or IP
keyGenerator: async (ctx) => {
const user = ctx.get('user')
if (user?.id) {
return `user:${user.id}`
}
return ctx.req.header('x-forwarded-for')?.split(',')[0] ?? 'unknown'
},
// Skip for admin users
skip: async (ctx) => {
const user = ctx.get('user')
return user?.role === 'admin'
},
// Custom response
onRateLimited: (ctx, retryAfter) => {
return ctx.json({
error: 'Too many requests',
retryAfter,
documentation: 'https://api.example.com/docs/rate-limits',
}, 429)
},
})
Rate Limit Headers
When headers: true (default), the following headers are added to all responses:
| Header | Description |
|---|---|
X-RateLimit-Limit |
Maximum requests allowed in the window |
X-RateLimit-Remaining |
Remaining requests in current window |
X-RateLimit-Reset |
Unix timestamp when the window resets |
Retry-After |
Seconds until retry (only when limited) |
Example Response Headers
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 95
X-RateLimit-Reset: 1705312800
Storage Backends
Memory Store
Suitable for single-process applications:
import { MemoryRateLimitStore } from '@guren/core'
const store = new MemoryRateLimitStore(60000) // Cleanup every 60 seconds
const rateLimiter = createRateLimitMiddleware({
limit: 100,
store,
})
// Cleanup on shutdown
process.on('SIGTERM', () => {
store.destroy()
})
Sliding Window Store
More accurate rate limiting that smooths out traffic:
import { SlidingWindowRateLimitStore } from '@guren/core'
const store = new SlidingWindowRateLimitStore()
const rateLimiter = createRateLimitMiddleware({
limit: 100,
windowMs: 60 * 1000,
store,
})
Fixed Window vs Sliding Window:
- Fixed Window: Resets counter at window boundaries. Allows burst at boundary edges.
- Sliding Window: Tracks each request timestamp. Provides smoother rate limiting.
Redis Store (Distributed)
For distributed applications, use the Redis-backed stores that ship with the framework:
import { createRateLimitMiddleware } from '@guren/core'
import { createRedisClient, RedisRateLimitStore, RedisSlidingWindowRateLimitStore } from '@guren/core/redis'
const redis = createRedisClient({ url: process.env.REDIS_URL })
const limiter = createRateLimitMiddleware({
max: 60,
windowMs: 60_000,
store: new RedisRateLimitStore(redis),
})
// Sliding-window variant for smoother limiting
const sliding = createRateLimitMiddleware({
max: 60,
windowMs: 60_000,
store: new RedisSlidingWindowRateLimitStore(redis),
})
Note
Need custom semantics? Implement the RateLimitStore interface from @guren/core — any object with get, increment, and reset works as a store.
Helper Functions
Get Rate Limit Info
Check rate limit status without incrementing:
import { getRateLimitInfo, MemoryRateLimitStore } from '@guren/core'
const store = new MemoryRateLimitStore()
// Check user's rate limit status
const info = await getRateLimitInfo('user:123', store, { limit: 100 })
console.log(`${info.remaining} requests remaining`)
console.log(`Resets at ${info.resetAt}`)
console.log(`Is limited: ${info.isLimited}`)
Reset Rate Limit
Clear rate limit for a specific key:
import { resetRateLimit, MemoryRateLimitStore } from '@guren/core'
const store = new MemoryRateLimitStore()
// Reset after successful captcha verification
await resetRateLimit('user:123', store)
// With custom key prefix
await resetRateLimit('192.168.1.1', store, { keyPrefix: 'login:' })
Common Patterns
Different Limits by Endpoint
import { Router } from '@guren/core'
// Strict limit for authentication
const authLimiter = createRateLimitMiddleware({
limit: 5,
windowMs: 15 * 60 * 1000, // 15 minutes
keyPrefix: 'auth:',
})
// Standard API limit
const apiLimiter = createRateLimitMiddleware({
limit: 100,
windowMs: 60 * 1000, // 1 minute
keyPrefix: 'api:',
})
// Higher limit for search (expensive operation)
const searchLimiter = createRateLimitMiddleware({
limit: 20,
windowMs: 60 * 1000,
keyPrefix: 'search:',
})
// Apply to routes
const router = new Router()
router.post('/login', [AuthController, 'login']).middleware(authLimiter)
router.post('/register', [AuthController, 'register']).middleware(authLimiter)
router.get('/api/*', [ApiController, 'handle']).middleware(apiLimiter)
router.get('/search', [SearchController, 'search']).middleware(searchLimiter)
User-Based Rate Limiting
const userRateLimiter = createRateLimitMiddleware({
limit: 1000,
windowMs: 60 * 60 * 1000, // 1 hour
keyGenerator: async (ctx) => {
const user = ctx.get('user')
if (!user) {
// Fall back to IP for unauthenticated requests
return `ip:${ctx.req.header('x-forwarded-for') ?? 'unknown'}`
}
// Different limits per plan
const limitMultiplier = user.plan === 'premium' ? 10 : 1
return `user:${user.id}:${limitMultiplier}`
},
})
Skip Trusted Sources
const rateLimiter = createRateLimitMiddleware({
limit: 100,
skip: (ctx) => {
// Skip for internal services
const apiKey = ctx.req.header('x-api-key')
return apiKey === process.env.INTERNAL_API_KEY
// Or skip for certain IPs
const ip = ctx.req.header('x-forwarded-for')
return ['10.0.0.1', '10.0.0.2'].includes(ip ?? '')
},
})
Custom Error Response
const rateLimiter = createRateLimitMiddleware({
limit: 100,
onRateLimited: (ctx, retryAfter) => {
// Log the rate limit hit
console.warn(`Rate limit hit for ${ctx.req.path}`)
// Return custom response
return ctx.json({
status: 'error',
code: 'RATE_LIMIT_EXCEEDED',
message: 'You have exceeded the rate limit.',
retryAfter,
upgrade: 'https://example.com/pricing',
}, 429)
},
})
Testing
import { describe, test, expect, beforeEach, afterEach } from 'bun:test'
import { Hono } from 'hono'
import {
createRateLimitMiddleware,
MemoryRateLimitStore,
getRateLimitInfo,
resetRateLimit,
} from '@guren/core'
describe('Rate Limiting', () => {
let store: MemoryRateLimitStore
let app: Hono
beforeEach(() => {
store = new MemoryRateLimitStore(0) // Disable auto cleanup
app = new Hono()
})
afterEach(() => {
store.destroy()
})
test('allows requests under the limit', async () => {
app.use('*', createRateLimitMiddleware({ limit: 5, store }))
app.get('/', (c) => c.text('OK'))
for (let i = 0; i < 5; i++) {
const res = await app.request('/')
expect(res.status).toBe(200)
}
})
test('blocks requests over the limit', async () => {
app.use('*', createRateLimitMiddleware({ limit: 3, store }))
app.get('/', (c) => c.text('OK'))
// Use up the limit
for (let i = 0; i < 3; i++) {
await app.request('/')
}
// Next request should be blocked
const res = await app.request('/')
expect(res.status).toBe(429)
})
test('returns rate limit headers', async () => {
app.use('*', createRateLimitMiddleware({ limit: 10, store }))
app.get('/', (c) => c.text('OK'))
const res = await app.request('/')
expect(res.headers.get('X-RateLimit-Limit')).toBe('10')
expect(res.headers.get('X-RateLimit-Remaining')).toBe('9')
expect(res.headers.get('X-RateLimit-Reset')).toBeDefined()
})
test('uses custom key generator', async () => {
app.use(
'*',
createRateLimitMiddleware({
limit: 1,
store,
keyGenerator: (ctx) => ctx.req.header('X-API-Key') ?? 'anonymous',
})
)
app.get('/', (c) => c.text('OK'))
// First user hits limit
await app.request('/', { headers: { 'X-API-Key': 'user1' } })
const res1 = await app.request('/', { headers: { 'X-API-Key': 'user1' } })
expect(res1.status).toBe(429)
// Second user can still request
const res2 = await app.request('/', { headers: { 'X-API-Key': 'user2' } })
expect(res2.status).toBe(200)
})
test('resets limit after window expires', async () => {
app.use(
'*',
createRateLimitMiddleware({
limit: 1,
windowMs: 100, // 100ms window
store,
})
)
app.get('/', (c) => c.text('OK'))
await app.request('/')
let res = await app.request('/')
expect(res.status).toBe(429)
// Wait for window to expire
await new Promise((r) => setTimeout(r, 150))
res = await app.request('/')
expect(res.status).toBe(200)
})
})
Best Practices
Use appropriate windows: Short windows (1 min) for general API, longer (15-60 min) for auth.
Different limits per endpoint: Apply stricter limits to expensive or sensitive operations.
Key by user when possible: Authenticated user keys prevent one user from affecting others.
Always include headers: Help clients implement proper backoff strategies.
Use Redis in production: Memory stores don't work with multiple server instances.
Log rate limit hits: Monitor for abuse patterns and adjust limits accordingly.
Provide upgrade path: Let users know how to get higher limits (e.g., premium plan).
Clean up stores: Call
destroy()on memory stores when shutting down.