- new module cooler: Install, Handle, OpenRedis (no URL in errors), RateLimitStore (atomic fixed-window Lua script with MemoryStore semantics, per-instance fallback) - serve installs cooler and publishes the Redis surf.Store before BuildRouter; BuildRouter looks up a published Store, route:list keeps the memory store - config keys http.rate_limit.store and redis.url; go-redis v9.23.0 - cooler README, root table row, surf README and rate-limiting, configuration and scaling docs
5.9 KiB
title, description, section, order
| title | description | section | order |
|---|---|---|---|
| Rate limiting | Throttle routes with inline limits or named buckets, key them by user or client IP, and trust X-Forwarded-For only from configured proxies. | services | 40 |
Rate limiting
Laravel limits requests with the throttle middleware and named limiters from RateLimiter::for. SummerCMS has both through surf: a throttle middleware that takes an inline limit or the name of a bucket a plugin declares.
Inline limits
throttle:<max>,<minutes> allows max requests per window of minutes minutes. The counter is kept per signed-in user, or per client IP for guests. The plugin on Routing puts throttle:60,1 on its whole /api/blog group, so each client may make 60 requests a minute to it.
Named buckets
A bucket gives a limit its own key, such as the client IP plus the route, or a token ID. A plugin declares buckets by implementing surf.BucketProvider, and routes name them as throttle:<bucket>:
// Buckets declares a named rate limit, used as throttle:blog.comments.
func (p *BlogPlugin) Buckets() map[string]surf.Bucket {
return map[string]surf.Bucket{
"blog.comments": {
Max: 1,
Decay: time.Minute,
Key: func(r *http.Request) string { return "comments|" + surf.ClientIP(r, p.trusted) },
},
}
}
Each surf.Bucket has the maximum number of requests, the window length (Decay) and a Key function that builds the counter key from the request. Prefix the key with the bucket's purpose, as above, so two buckets never share a counter. A route may name several throttles; each counts separately. A throttle naming a bucket that no plugin declares fails the start-up.
A request over the limit gets a 429 response with the body {"message":"Too Many Attempts."} and a Retry-After header. Every throttled response carries X-RateLimit-Limit and X-RateLimit-Remaining, and a rejected one also X-RateLimit-Reset.
The limiter is a fixed window: the counter resets when the window ends, as Laravel's cache limiter does. By default counters live in the process (surf.MemoryStore, behind the surf.Store interface). With this memory store each application instance counts on its own, so behind a load balancer with several instances the effective limit is the configured limit times the number of instances. Sharing limits across instances moves the counters to Redis.
Sharing limits across instances
Laravel shares its limiter counters through the cache store, usually Redis. SummerCMS does the same through cooler: set http.rate_limit.store to redis (the default is memory) and point redis.url at a Redis every instance reaches.
# config/http.yaml
rate_limit:
store: redis
# config/redis.yaml
url: redis://10.0.0.7:6379/0
Keep a URL that carries a password in the environment instead: SUMMER_REDIS__URL=redis://:secret@10.0.0.7:6379/0. rediss:// connects over TLS and unix:// through a socket.
When serve starts, it calls cooler.Install, which builds the Redis client and pings it, and publishes a cooler.RateLimitStore as the surf.Store before the router is built. Routes do not change: throttle:60,1 and throttle:<bucket> count in Redis, with the same fixed-window rules as the memory store. Each attempt is one atomic Lua script, so two instances answering requests from the same client count one window together: with throttle:2,1, the third request in the minute gets a 429 whichever instance answers it.
Keys are <prefix>rl:<key>. The prefix is cache.prefix, or by default app.name lower-cased with other characters than letters and digits replaced by _, followed by :, so several applications can share one Redis.
When Redis does not answer, each attempt waits at most cooler.AttemptTimeout (500 ms) and then counts in a per-instance surf.MemoryStore, as if the memory store were selected, until Redis answers again. A warning is logged at most once a minute. Redis being down at start-up is a warning too, not a failed start; a missing or malformed redis.url fails the start with an error that names the key and never shows the URL.
Only serve connects to Redis. route:list builds the router with the memory store, and so does surf.Assemble in tests, so neither needs a running Redis.
Client IP and trusted proxies
surf.ClientIP is the one place the client IP comes from. It uses the connection's remote address, and reads X-Forwarded-For only when that address is inside a range listed in http.trusted_proxies. It then takes the rightmost address that is not itself a trusted proxy, so a client cannot choose its own IP by sending the header:
cfg, err := compass.Open(compass.Options{Dir: "config", Env: "development", Environ: []string{}})
if err != nil {
fmt.Println(err)
return
}
_ = cfg.Set("http.trusted_proxies", []string{"10.0.0.0/8"})
trusted := surf.TrustedProxies(cfg)
// Through the load balancer at 10.0.0.5: the forwarded client is used.
viaProxy := httptest.NewRequest("GET", "/api/blog/posts", nil)
viaProxy.RemoteAddr = "10.0.0.5:4711"
viaProxy.Header.Set("X-Forwarded-For", "198.51.100.23, 10.0.0.9")
fmt.Println(surf.ClientIP(viaProxy, trusted))
// Straight from the internet: a forged header is ignored.
direct := httptest.NewRequest("GET", "/api/blog/posts", nil)
direct.RemoteAddr = "203.0.113.7:5000"
direct.Header.Set("X-Forwarded-For", "127.0.0.1")
fmt.Println(surf.ClientIP(direct, trusted))
// Output:
// 198.51.100.23
// 203.0.113.7
List only the proxies you run, in config/http.yaml:
trusted_proxies: ["10.0.0.0/8"]
With the list empty (the default), the header is ignored and every request behind a proxy has the proxy's IP. A malformed entry is skipped. A bucket's Key function should use the same trusted list, as the plugin above does by reading surf.TrustedProxies in Register.