Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions .ai/wheels/middleware/rate-limiting.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# Rate Limiting Middleware — AI Reference

## Constructor API

```cfm
new wheels.middleware.RateLimiter(
maxRequests = 60, // numeric — max requests per window
windowSeconds = 60, // numeric — window duration in seconds
strategy = "fixedWindow", // string — fixedWindow | slidingWindow | tokenBucket
storage = "memory", // string — memory | database
keyFunction = "", // closure(request) => string, or empty for IP-based
headerPrefix = "X-RateLimit", // string — response header prefix
trustProxy = true // boolean — use X-Forwarded-For
)
```

Throws `Wheels.RateLimiter.InvalidStrategy` or `Wheels.RateLimiter.InvalidStorage` on bad input.

## Strategy Algorithms

### Fixed Window
- Discrete time buckets: `windowId = Int(now / windowSeconds)`
- Store key: `clientKey:windowId`
- Counter incremented per request
- Resets when windowId changes

### Sliding Window
- Timestamp log per client key
- Prune entries older than `now - windowSeconds`
- Count remaining entries vs maxRequests
- More memory per client, more accurate

### Token Bucket
- Bucket with `capacity = maxRequests`
- Refill rate: `maxRequests / windowSeconds` tokens/sec
- Each request consumes 1 token
- Allows bursts up to capacity

## Method Inventory

| Method | Visibility | Purpose |
|--------|-----------|---------|
| `init()` | public | Constructor — validates strategy/storage, initializes store |
| `handle()` | public | MiddlewareInterface — check limit, set headers, pass/block |
| `$resolveKey()` | private | Client identification from keyFunction or IP |
| `$getClientIp()` | private | IP from request struct, X-Forwarded-For, or CGI |
| `$checkFixedWindow()` | private | Fixed window algorithm |
| `$checkSlidingWindow()` | private | Sliding window with timestamp log |
| `$checkTokenBucket()` | private | Token bucket algorithm |
| `$maybeCleanup()` | private | Throttled memory cleanup (1x/min) |
| `$dbIncrement()` | private | DB: fixed window counter |
| `$dbSlidingWindow()` | private | DB: sliding window entries |
| `$dbTokenBucket()` | private | DB: token bucket state |
| `$ensureTable()` | private | Auto-create wheels_rate_limits table |

## Storage

### Memory
- `java.util.concurrent.ConcurrentHashMap`
- Per-key `cflock` (name=`wheels-ratelimit-{key}`, timeout=1, exclusive)
- Fail-open on lock timeout
- Cleanup throttled to 1x/min

### Database
- Table: `wheels_rate_limits` (auto-created)
- Columns: `id`, `store_key`, `counter`, `expires_at`
- Parameterized queries via `QueryExecute()`
- Try-insert/catch-update for portability

## Response Headers

Always set:
- `{prefix}-Limit` — maxRequests
- `{prefix}-Remaining` — requests left
- `{prefix}-Reset` — Unix timestamp of window reset

On 429:
- `Retry-After` — seconds until reset
- Status: 429 Too Many Requests
- Body: "Rate limit exceeded. Try again later."

## Usage Patterns

```cfm
// Global — 60 req/min per IP
set(middleware = [new wheels.middleware.RateLimiter()]);

// Route-scoped — strict auth limit
.scope(path="/auth", middleware=[
new wheels.middleware.RateLimiter(maxRequests=10, windowSeconds=60)
])

// API key-based limiting
new wheels.middleware.RateLimiter(
keyFunction=function(req) { return req.cgi.http_x_api_key; }
)

// Multi-server with DB storage
new wheels.middleware.RateLimiter(storage="database")
```
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ All historical references to "CFWheels" in this changelog have been preserved fo
## [Unreleased]

### Added
- Rate limiting middleware with `wheels.middleware.RateLimiter` supporting fixed window, sliding window, and token bucket strategies with in-memory and database storage
- Composable pagination view helpers: `paginationInfo()`, `previousPageLink()`, `nextPageLink()`, `firstPageLink()`, `lastPageLink()`, `pageNumberLinks()`, and `paginationNav()` for building custom pagination UIs
- Route model binding with `binding=true` on resource routes or `set(routeModelBinding=true)` globally to auto-resolve model instances from route key parameters
- Chainable query builder with `where()`, `orWhere()`, `whereNull()`, `whereBetween()`, `whereIn()`, `orderBy()`, `limit()`, and more for injection-safe fluent queries
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,7 @@ mapper()
.end();
```

Built-in: `wheels.middleware.RequestId`, `wheels.middleware.Cors`, `wheels.middleware.SecurityHeaders`. Custom middleware: implement `wheels.middleware.MiddlewareInterface`, place in `app/middleware/`.
Built-in: `wheels.middleware.RequestId`, `wheels.middleware.Cors`, `wheels.middleware.SecurityHeaders`, `wheels.middleware.RateLimiter`. Custom middleware: implement `wheels.middleware.MiddlewareInterface`, place in `app/middleware/`.

## Routing Quick Reference

Expand Down
1 change: 1 addition & 0 deletions docs/src/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@
* [Responding with Multiple Formats](handling-requests-with-controllers/responding-with-multiple-formats.md)
* [Using the Flash](handling-requests-with-controllers/using-the-flash.md)
* [Middleware](handling-requests-with-controllers/middleware.md)
* [Rate Limiting](handling-requests-with-controllers/rate-limiting.md)
* [Route Model Binding](handling-requests-with-controllers/route-model-binding.md)
* [Using Filters](handling-requests-with-controllers/using-filters.md)
* [Verification](handling-requests-with-controllers/verification.md)
Expand Down
183 changes: 183 additions & 0 deletions docs/src/handling-requests-with-controllers/rate-limiting.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
# Rate Limiting

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.

## Quick Start

Add the rate limiter to your global middleware stack in `config/settings.cfm`:

```cfm
set(middleware = [
new wheels.middleware.RateLimiter(maxRequests=60, windowSeconds=60)
]);
```

This allows 60 requests per minute per client IP, using the fixed window strategy with in-memory storage.

## Configuration Reference

| Parameter | Default | Description |
|-----------|---------|-------------|
| `maxRequests` | `60` | Maximum requests allowed per window |
| `windowSeconds` | `60` | Duration of the rate limit window in seconds |
| `strategy` | `"fixedWindow"` | Algorithm: `"fixedWindow"`, `"slidingWindow"`, or `"tokenBucket"` |
| `storage` | `"memory"` | Backend: `"memory"` or `"database"` |
| `keyFunction` | `""` | Closure `function(request)` returning a string key. Defaults to client IP |
| `headerPrefix` | `"X-RateLimit"` | Prefix for rate limit response headers |
| `trustProxy` | `true` | Use `X-Forwarded-For` for client IP resolution |

## Strategies

### Fixed Window

Divides time into discrete buckets (e.g., 0:00–0:59, 1:00–1:59). Simple and memory-efficient.

```cfm
new wheels.middleware.RateLimiter(strategy="fixedWindow")
```

**Best for**: Most applications. Simple to understand and debug.

**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.

### Sliding Window

Maintains a log of request timestamps per client. More accurate than fixed window — the window slides with each request.

```cfm
new wheels.middleware.RateLimiter(strategy="slidingWindow")
```

**Best for**: Applications requiring precise rate enforcement without boundary spikes.

**Trade-off**: Uses more memory per client (stores individual timestamps).

### Token Bucket

Each client has a bucket that fills with tokens at a steady rate. Each request consumes one token. Allows controlled bursts up to `maxRequests`.

```cfm
new wheels.middleware.RateLimiter(strategy="tokenBucket")
```

**Best for**: APIs where you want to allow occasional bursts while maintaining a long-term average rate.

**Trade-off**: Slightly more complex state per client (token count + last refill time).

## Storage Backends

### In-Memory (Default)

Uses a `ConcurrentHashMap` for thread-safe storage. Stale entries are automatically cleaned up once per minute.

```cfm
new wheels.middleware.RateLimiter(storage="memory")
```

**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.

### Database

Stores rate limit data in a `wheels_rate_limits` table that is auto-created on first use.

```cfm
new wheels.middleware.RateLimiter(storage="database")
```

**Note**: Shared across all servers in a cluster. Adds a database query per request. Suitable for multi-server deployments where consistent limiting is required.

## Route-Scoped Rate Limiting

Apply different limits to different parts of your application using route middleware:

```cfm
// config/routes.cfm
mapper()
// Strict limit on authentication endpoints.
.scope(path="/auth", middleware=[
new wheels.middleware.RateLimiter(maxRequests=10, windowSeconds=60)
])
.post(name="login", to="sessions##create")
.post(name="register", to="users##create")
.end()

// More generous limit on API endpoints.
.scope(path="/api", middleware=[
new wheels.middleware.RateLimiter(maxRequests=100, windowSeconds=60)
])
.resources("users")
.resources("products")
.end()
.end();
```

## Custom Key Functions

By default, the rate limiter identifies clients by IP address. You can customize this with a `keyFunction`:

```cfm
// Rate limit by API key from request header.
new wheels.middleware.RateLimiter(
keyFunction=function(request) {
if (StructKeyExists(request, "cgi") && StructKeyExists(request.cgi, "http_x_api_key")) {
return "api:" & request.cgi.http_x_api_key;
}
return "anon:" & cgi.remote_addr;
}
)

// Rate limit by authenticated user ID.
new wheels.middleware.RateLimiter(
keyFunction=function(request) {
if (StructKeyExists(session, "userId")) {
return "user:" & session.userId;
}
return "anon:" & cgi.remote_addr;
}
)
```

## Response Headers

Every response includes rate limit headers:

| Header | Description |
|--------|-------------|
| `X-RateLimit-Limit` | Maximum requests allowed per window |
| `X-RateLimit-Remaining` | Requests remaining in the current window |
| `X-RateLimit-Reset` | Unix timestamp when the window resets |

When a request is rate limited, the response includes:

| Header | Description |
|--------|-------------|
| `Retry-After` | Seconds until the client can retry |

The status code is `429 Too Many Requests` with a plain text body.

The header prefix can be customized:

```cfm
new wheels.middleware.RateLimiter(headerPrefix="X-MyApp-RateLimit")
// Produces: X-MyApp-RateLimit-Limit, X-MyApp-RateLimit-Remaining, etc.
```

## Multi-Server Deployments

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.

For consistent rate limiting across servers, use database storage:

```cfm
new wheels.middleware.RateLimiter(
storage="database",
maxRequests=60,
windowSeconds=60
)
```

The `wheels_rate_limits` table is auto-created on first use. No migration needed.

## Thread Safety

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.
Loading