$npx -y skills add Lonsdale201/wp-agent-skills --skill br-rate-limitingConfigure better-route 1.1 RateLimitMiddleware with atomic fixed-window storage. Use for WpObjectCacheRateLimiter, TransientRateLimiter, persistent external object cache checks, wp_cache_incr, MySQL named locks, identity/native WordPress/IP keys, trusted proxies, Retry-After and
| 1 | # better-route: rate limiting |
| 2 | |
| 3 | Use a fixed-window limiter with an atomic backend. Pick the backend from deployment capabilities; do not silently fall back from an atomic store to a racy read/modify/write. |
| 4 | |
| 5 | ## Persistent object cache |
| 6 | |
| 7 | ```php |
| 8 | use BetterRoute\Middleware\RateLimit\RateLimitMiddleware; |
| 9 | use BetterRoute\Middleware\RateLimit\WpObjectCacheRateLimiter; |
| 10 | |
| 11 | $rateLimit = new RateLimitMiddleware( |
| 12 | limiter: new WpObjectCacheRateLimiter(group: 'myapp_rate_limit'), |
| 13 | limit: 60, |
| 14 | windowSeconds: 60, |
| 15 | ); |
| 16 | |
| 17 | $router->get('/account', $handler) |
| 18 | ->protectedByMiddleware('bearerAuth') |
| 19 | ->middleware([$auth, $rateLimit]); |
| 20 | ``` |
| 21 | |
| 22 | `WpObjectCacheRateLimiter` requires: |
| 23 | |
| 24 | - WordPress cache functions; |
| 25 | - `wp_using_ext_object_cache() === true` when that function exists; |
| 26 | - `wp_cache_incr()`; |
| 27 | - a backend whose increment actually behaves atomically. |
| 28 | |
| 29 | Construction or a failed increment throws. Use it with a verified Redis/Memcached-style persistent backend, not WordPress's request-local default object cache. |
| 30 | |
| 31 | ## Transient backend |
| 32 | |
| 33 | ```php |
| 34 | use BetterRoute\Middleware\RateLimit\TransientRateLimiter; |
| 35 | |
| 36 | $rateLimit = new RateLimitMiddleware( |
| 37 | limiter: new TransientRateLimiter(), |
| 38 | limit: 20, |
| 39 | windowSeconds: 60, |
| 40 | ); |
| 41 | ``` |
| 42 | |
| 43 | In default WordPress mode, `TransientRateLimiter` wraps the transient read/modify/write in a MySQL `GET_LOCK`/`RELEASE_LOCK` critical section. It requires global `$wpdb` and may throw when the lock cannot be acquired or state cannot be persisted. |
| 44 | |
| 45 | If custom `getTransient`/`setTransient` callbacks are injected, also inject a real `synchronize` callback when requests can run concurrently. Without it, the custom mode executes unsynchronized. |
| 46 | |
| 47 | ## Default key in 1.1 |
| 48 | |
| 49 | The default key deeply canonicalizes the route and the first available identity: |
| 50 | |
| 51 | 1. auth middleware user ID; |
| 52 | 2. auth subject; |
| 53 | 3. explicit context/native logged-in WordPress user ID; |
| 54 | 4. HMAC key identity; |
| 55 | 5. resolved client IP; |
| 56 | 6. `guest` only when no identity or IP is available. |
| 57 | |
| 58 | This means cookie/application-password/native WordPress users get per-user buckets even without an `attributes['auth']` entry. Anonymous callers normally get per-IP rather than one global guest bucket. |
| 59 | |
| 60 | Run auth before rate limiting when token identity should win over IP: |
| 61 | |
| 62 | ```php |
| 63 | ->middleware([$auth, $rateLimit]) |
| 64 | ``` |
| 65 | |
| 66 | ## Trusted client IP |
| 67 | |
| 68 | Use `TrustedProxyClientIpResolver` behind proxies: |
| 69 | |
| 70 | ```php |
| 71 | use BetterRoute\Middleware\Network\TrustedProxyClientIpResolver; |
| 72 | |
| 73 | $ipResolver = new TrustedProxyClientIpResolver( |
| 74 | trustedProxyCidrs: ['10.0.0.0/24', '2001:db8:1234::/48'], |
| 75 | forwardedHeaders: ['CF-Connecting-IP', 'X-Forwarded-For'], |
| 76 | ); |
| 77 | |
| 78 | $rateLimit = new RateLimitMiddleware( |
| 79 | limiter: $limiter, |
| 80 | limit: 60, |
| 81 | windowSeconds: 60, |
| 82 | clientIpResolver: $ipResolver, |
| 83 | ); |
| 84 | ``` |
| 85 | |
| 86 | The resolver reads a forwarded header only when immediate `REMOTE_ADDR` is trusted. For hop lists it walks right-to-left and returns the closest untrusted address, avoiding a client-forged leftmost value. Keep provider CIDRs current. |
| 87 | |
| 88 | ## Response contract |
| 89 | |
| 90 | Allowed responses receive: |
| 91 | |
| 92 | ```text |
| 93 | X-RateLimit-Limit |
| 94 | X-RateLimit-Remaining |
| 95 | X-RateLimit-Reset |
| 96 | ``` |
| 97 | |
| 98 | Denied requests return `429 rate_limited`, the same rate-limit headers, and `Retry-After` calculated from reset time. Browser clients must include `Retry-After` in CORS `exposedHeaders` if JavaScript needs it. |
| 99 | |
| 100 | The middleware preserves headers on Better Route responses, `WP_REST_Response`, and raw array/scalar results. |
| 101 | |
| 102 | ## Custom keys |
| 103 | |
| 104 | ```php |
| 105 | $rateLimit = new RateLimitMiddleware( |
| 106 | limiter: $limiter, |
| 107 | limit: 100, |
| 108 | windowSeconds: 60, |
| 109 | keyResolver: static fn ($context): string => hash('sha256', json_encode([ |
| 110 | 'route' => $context->routePath, |
| 111 | 'tenant' => current_tenant_id(), |
| 112 | 'user' => get_current_user_id(), |
| 113 | ], JSON_THROW_ON_ERROR)), |
| 114 | ); |
| 115 | ``` |
| 116 | |
| 117 | Include route/tenant/identity deliberately and use unambiguous structured encoding. A constant global key lets one caller exhaust the bucket for everyone. |
| 118 | |
| 119 | ## Review checklist |
| 120 | |
| 121 | - Verify backend atomicity under concurrency. |
| 122 | - Run auth before the limiter for per-token/user limits. |
| 123 | - Configure trusted proxy CIDRs before trusting forwarded headers. |
| 124 | - Test first, last allowed, and first denied request; assert remaining/reset/retry headers. |
| 125 | - Test an anonymous caller from two IPs and two authenticated users. |
| 126 | - Monitor MySQL na |