|
| 1 | +# Rate Limiting |
| 2 | + |
| 3 | +Rate limiting protects your application from abuse by restricting how many requests a client can make within a time window. Wheels provides a built-in `RateLimiter` middleware with multiple strategies and storage backends. |
| 4 | + |
| 5 | +## Quick Start |
| 6 | + |
| 7 | +Add the rate limiter to your global middleware stack in `config/settings.cfm`: |
| 8 | + |
| 9 | +```cfm |
| 10 | +set(middleware = [ |
| 11 | + new wheels.middleware.RateLimiter(maxRequests=60, windowSeconds=60) |
| 12 | +]); |
| 13 | +``` |
| 14 | + |
| 15 | +This allows 60 requests per minute per client IP, using the fixed window strategy with in-memory storage. |
| 16 | + |
| 17 | +## Configuration Reference |
| 18 | + |
| 19 | +| Parameter | Default | Description | |
| 20 | +|-----------|---------|-------------| |
| 21 | +| `maxRequests` | `60` | Maximum requests allowed per window | |
| 22 | +| `windowSeconds` | `60` | Duration of the rate limit window in seconds | |
| 23 | +| `strategy` | `"fixedWindow"` | Algorithm: `"fixedWindow"`, `"slidingWindow"`, or `"tokenBucket"` | |
| 24 | +| `storage` | `"memory"` | Backend: `"memory"` or `"database"` | |
| 25 | +| `keyFunction` | `""` | Closure `function(request)` returning a string key. Defaults to client IP | |
| 26 | +| `headerPrefix` | `"X-RateLimit"` | Prefix for rate limit response headers | |
| 27 | +| `trustProxy` | `true` | Use `X-Forwarded-For` for client IP resolution | |
| 28 | + |
| 29 | +## Strategies |
| 30 | + |
| 31 | +### Fixed Window |
| 32 | + |
| 33 | +Divides time into discrete buckets (e.g., 0:00–0:59, 1:00–1:59). Simple and memory-efficient. |
| 34 | + |
| 35 | +```cfm |
| 36 | +new wheels.middleware.RateLimiter(strategy="fixedWindow") |
| 37 | +``` |
| 38 | + |
| 39 | +**Best for**: Most applications. Simple to understand and debug. |
| 40 | + |
| 41 | +**Trade-off**: A client could send `maxRequests` at the end of one window and `maxRequests` at the start of the next, effectively doubling throughput at the boundary. |
| 42 | + |
| 43 | +### Sliding Window |
| 44 | + |
| 45 | +Maintains a log of request timestamps per client. More accurate than fixed window — the window slides with each request. |
| 46 | + |
| 47 | +```cfm |
| 48 | +new wheels.middleware.RateLimiter(strategy="slidingWindow") |
| 49 | +``` |
| 50 | + |
| 51 | +**Best for**: Applications requiring precise rate enforcement without boundary spikes. |
| 52 | + |
| 53 | +**Trade-off**: Uses more memory per client (stores individual timestamps). |
| 54 | + |
| 55 | +### Token Bucket |
| 56 | + |
| 57 | +Each client has a bucket that fills with tokens at a steady rate. Each request consumes one token. Allows controlled bursts up to `maxRequests`. |
| 58 | + |
| 59 | +```cfm |
| 60 | +new wheels.middleware.RateLimiter(strategy="tokenBucket") |
| 61 | +``` |
| 62 | + |
| 63 | +**Best for**: APIs where you want to allow occasional bursts while maintaining a long-term average rate. |
| 64 | + |
| 65 | +**Trade-off**: Slightly more complex state per client (token count + last refill time). |
| 66 | + |
| 67 | +## Storage Backends |
| 68 | + |
| 69 | +### In-Memory (Default) |
| 70 | + |
| 71 | +Uses a `ConcurrentHashMap` for thread-safe storage. Stale entries are automatically cleaned up once per minute. |
| 72 | + |
| 73 | +```cfm |
| 74 | +new wheels.middleware.RateLimiter(storage="memory") |
| 75 | +``` |
| 76 | + |
| 77 | +**Note**: Counters are lost on server restart and are not shared across multiple application servers. Suitable for single-server deployments or when approximate limiting is acceptable. |
| 78 | + |
| 79 | +### Database |
| 80 | + |
| 81 | +Stores rate limit data in a `wheels_rate_limits` table that is auto-created on first use. |
| 82 | + |
| 83 | +```cfm |
| 84 | +new wheels.middleware.RateLimiter(storage="database") |
| 85 | +``` |
| 86 | + |
| 87 | +**Note**: Shared across all servers in a cluster. Adds a database query per request. Suitable for multi-server deployments where consistent limiting is required. |
| 88 | + |
| 89 | +## Route-Scoped Rate Limiting |
| 90 | + |
| 91 | +Apply different limits to different parts of your application using route middleware: |
| 92 | + |
| 93 | +```cfm |
| 94 | +// config/routes.cfm |
| 95 | +mapper() |
| 96 | + // Strict limit on authentication endpoints. |
| 97 | + .scope(path="/auth", middleware=[ |
| 98 | + new wheels.middleware.RateLimiter(maxRequests=10, windowSeconds=60) |
| 99 | + ]) |
| 100 | + .post(name="login", to="sessions##create") |
| 101 | + .post(name="register", to="users##create") |
| 102 | + .end() |
| 103 | +
|
| 104 | + // More generous limit on API endpoints. |
| 105 | + .scope(path="/api", middleware=[ |
| 106 | + new wheels.middleware.RateLimiter(maxRequests=100, windowSeconds=60) |
| 107 | + ]) |
| 108 | + .resources("users") |
| 109 | + .resources("products") |
| 110 | + .end() |
| 111 | +.end(); |
| 112 | +``` |
| 113 | + |
| 114 | +## Custom Key Functions |
| 115 | + |
| 116 | +By default, the rate limiter identifies clients by IP address. You can customize this with a `keyFunction`: |
| 117 | + |
| 118 | +```cfm |
| 119 | +// Rate limit by API key from request header. |
| 120 | +new wheels.middleware.RateLimiter( |
| 121 | + keyFunction=function(request) { |
| 122 | + if (StructKeyExists(request, "cgi") && StructKeyExists(request.cgi, "http_x_api_key")) { |
| 123 | + return "api:" & request.cgi.http_x_api_key; |
| 124 | + } |
| 125 | + return "anon:" & cgi.remote_addr; |
| 126 | + } |
| 127 | +) |
| 128 | +
|
| 129 | +// Rate limit by authenticated user ID. |
| 130 | +new wheels.middleware.RateLimiter( |
| 131 | + keyFunction=function(request) { |
| 132 | + if (StructKeyExists(session, "userId")) { |
| 133 | + return "user:" & session.userId; |
| 134 | + } |
| 135 | + return "anon:" & cgi.remote_addr; |
| 136 | + } |
| 137 | +) |
| 138 | +``` |
| 139 | + |
| 140 | +## Response Headers |
| 141 | + |
| 142 | +Every response includes rate limit headers: |
| 143 | + |
| 144 | +| Header | Description | |
| 145 | +|--------|-------------| |
| 146 | +| `X-RateLimit-Limit` | Maximum requests allowed per window | |
| 147 | +| `X-RateLimit-Remaining` | Requests remaining in the current window | |
| 148 | +| `X-RateLimit-Reset` | Unix timestamp when the window resets | |
| 149 | + |
| 150 | +When a request is rate limited, the response includes: |
| 151 | + |
| 152 | +| Header | Description | |
| 153 | +|--------|-------------| |
| 154 | +| `Retry-After` | Seconds until the client can retry | |
| 155 | + |
| 156 | +The status code is `429 Too Many Requests` with a plain text body. |
| 157 | + |
| 158 | +The header prefix can be customized: |
| 159 | + |
| 160 | +```cfm |
| 161 | +new wheels.middleware.RateLimiter(headerPrefix="X-MyApp-RateLimit") |
| 162 | +// Produces: X-MyApp-RateLimit-Limit, X-MyApp-RateLimit-Remaining, etc. |
| 163 | +``` |
| 164 | + |
| 165 | +## Multi-Server Deployments |
| 166 | + |
| 167 | +With in-memory storage, each server maintains its own counters. If you have 3 servers behind a load balancer, a client could theoretically make `maxRequests × 3` total requests. |
| 168 | + |
| 169 | +For consistent rate limiting across servers, use database storage: |
| 170 | + |
| 171 | +```cfm |
| 172 | +new wheels.middleware.RateLimiter( |
| 173 | + storage="database", |
| 174 | + maxRequests=60, |
| 175 | + windowSeconds=60 |
| 176 | +) |
| 177 | +``` |
| 178 | + |
| 179 | +The `wheels_rate_limits` table is auto-created on first use. No migration needed. |
| 180 | + |
| 181 | +## Thread Safety |
| 182 | + |
| 183 | +The in-memory implementation uses per-client locking (`cflock`) to ensure accurate counting under concurrent requests. If a lock cannot be acquired within 1 second (indicating extreme contention), the request is allowed through (fail-open). This prevents the rate limiter itself from becoming a bottleneck. |
0 commit comments