- 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
118 lines
6.8 KiB
Markdown
118 lines
6.8 KiB
Markdown
# cooler
|
|
|
|
Cache drivers and shared counters for SummerCMS: memory and Redis caches with a key prefix and TTLs, and a Redis-backed rate-limit store.
|
|
|
|
`import "git.golem15.com/golem15/summercms/modules/cooler"`
|
|
|
|
**Name:** The cool box you bring to the beach; it keeps things fresh and at hand for everyone in the group.
|
|
|
|
## Overview
|
|
|
|
cooler holds the state that several application instances share. Its first piece is a Redis-backed rate-limit store: with `http.rate_limit.store: redis`, the `serve` command of [surf](../surf/README.md) calls `cooler.Install`, which builds a Redis client from `redis.url`, and publishes the store so the `throttle` middleware counts in Redis instead of in the process. Two instances behind one load balancer then enforce one limit together. It is the counterpart of Laravel's Redis cache store behind `RateLimiter` and the `throttle` middleware.
|
|
|
|
## Role in the framework
|
|
|
|
A service module on `backpack` and `compass`, below `surf`: surf imports cooler, never the reverse. `cooler.RateLimitStore` has the method set of `surf.Store` (through `cooler.Attempter`), so surf takes no Redis dependency and cooler imports neither surf nor cabana. The framework wires cooler at boot; applications import nothing.
|
|
|
|
Use cases:
|
|
|
|
- Keep `throttle` limits exact when an application runs as several instances behind a load balancer.
|
|
- Share one Redis connection setting (`redis.url`) between the framework services that need Redis.
|
|
|
|
## Features
|
|
|
|
- `cooler.Install` reads `http.rate_limit.store` (`memory`, the default, or `redis`). With `redis` it builds the client from `redis.url` and pings it within `cooler.PingTimeout` (3 s).
|
|
- Start-up never fails because Redis is down: an unanswered ping is one warning that names the endpoint (host and port, never the URL), and the limiter counts per instance until Redis answers. A missing or malformed `redis.url`, or an unknown store name, fails start-up with an error that names the key and never contains the URL or its password.
|
|
- `cooler.RateLimitStore` runs one atomic Lua script per attempt with the semantics of `surf.MemoryStore`: the first attempt opens the window, a denied attempt does not increment, the window never extends, and the retry time is what is left of the window. Redis keeps the clock, so instance clock skew does not matter.
|
|
- Each attempt is bounded by `cooler.AttemptTimeout` (500 ms). When Redis fails, the attempt is counted in the fallback store surf passes in (a per-instance `surf.MemoryStore`) and a warning is logged at most once per minute.
|
|
- Keys carry a per-application prefix: `cache.prefix`, or by default `app.name` lower-cased with every run of other characters than `a-z` and `0-9` replaced by `_`, then `:` (`summer:` when `app.name` is empty). Rate-limit keys are `<prefix>rl:<key>`.
|
|
- `cooler.OpenRedis` accepts `redis://`, `rediss://` (TLS) and `unix://` URLs, does not dial at construction, and skips the `CLIENT SETINFO` and maintenance-notification handshakes.
|
|
- Only `serve` installs cooler: `route:list` and `surf.Assemble` in tests keep the in-process store and never dial Redis.
|
|
|
|
## Usage
|
|
|
|
Select the shared store in the application's config and keep the URL, which may carry a password, in the environment (`SUMMER_REDIS__URL`):
|
|
|
|
```yaml
|
|
# config/http.yaml
|
|
rate_limit:
|
|
store: redis
|
|
```
|
|
|
|
```yaml
|
|
# config/redis.yaml (or SUMMER_REDIS__URL=redis://:secret@10.0.0.7:6379/0)
|
|
url: redis://10.0.0.7:6379/0
|
|
```
|
|
|
|
Nothing else changes: routes keep `throttle:60,1` or `throttle:<bucket>`, and `serve` publishes the Redis store before it builds the router.
|
|
|
|
A tool outside `serve` can use the store directly:
|
|
|
|
```go
|
|
package blog
|
|
|
|
import (
|
|
"context"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
|
"git.golem15.com/golem15/summercms/modules/cooler"
|
|
)
|
|
|
|
// AllowExport admits five exports a minute per user across all instances.
|
|
func AllowExport(ctx context.Context, app *backpack.App, fallback cooler.Attempter, userKey string) (bool, error) {
|
|
client, err := cooler.OpenRedis(ctx, app.Config.String(cooler.RedisURLKey))
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
defer client.Close()
|
|
store := cooler.NewRateLimitStore(client, "acme:", fallback)
|
|
allowed, _, _ := store.Attempt("export|"+userKey, 5, time.Minute)
|
|
return allowed, nil
|
|
}
|
|
```
|
|
|
|
## API reference
|
|
|
|
| Identifier | Description |
|
|
|------------|-------------|
|
|
| `cooler.Install` | Reads cooler's config, opens and pings Redis when a Redis store is selected, and returns a `cooler.Handle`. Called by surf's `serve`. |
|
|
| `cooler.Handle` | What `cooler.Install` set up. |
|
|
| `cooler.Handle.Close` | Closes the Redis client `cooler.Install` opened, if any. |
|
|
| `cooler.Handle.RateLimitRedis` | Reports whether `http.rate_limit.store` selects the Redis store. |
|
|
| `cooler.Handle.RateLimitStore` | Returns the Redis rate-limit store with the given fallback, or the fallback itself when Redis is not selected. |
|
|
| `cooler.Attempter` | One atomic fixed-window admission; the method set of `surf.Store`. |
|
|
| `cooler.RateLimitStore` | Redis fixed-window store shared by all instances, with a fallback while Redis fails. |
|
|
| `cooler.NewRateLimitStore` | Builds a `cooler.RateLimitStore` from a go-redis client, a key prefix and a fallback. |
|
|
| `cooler.RateLimitStore.Attempt` | Admits and counts one request in one Lua script; falls back on a Redis error. |
|
|
| `cooler.OpenRedis` | Builds a go-redis client from a `redis://`, `rediss://` or `unix://` URL without dialing; errors never contain the URL. |
|
|
| `cooler.RedisURLKey` | `redis.url`. |
|
|
| `cooler.RateLimitStoreKey` | `http.rate_limit.store`. |
|
|
| `cooler.CachePrefixKey` | `cache.prefix`. |
|
|
| `cooler.PingTimeout` | Bound of the start-up ping (3 s). |
|
|
| `cooler.AttemptTimeout` | Bound of one rate-limit attempt on Redis (500 ms). |
|
|
|
|
## Configuration
|
|
|
|
`cooler.Install` reads these keys from the app's [compass](../compass/README.md) config:
|
|
|
|
| Key | Default | Controls |
|
|
|-----|---------|----------|
|
|
| `http.rate_limit.store` | `memory` | Where `throttle` counters live: `memory` (per instance) or `redis` (shared). Any other value fails at boot. Environment: `SUMMER_HTTP__RATE_LIMIT__STORE`. |
|
|
| `redis.url` | none | Redis URL (`redis://`, `rediss://` or `unix://`). Required when a Redis store is selected; keep it in the environment when it carries a password. Environment: `SUMMER_REDIS__URL`. |
|
|
| `cache.prefix` | from `app.name` | Prefix of every cooler key. Environment: `SUMMER_CACHE__PREFIX`. |
|
|
|
|
## Dependencies
|
|
|
|
- SummerCMS modules: [backpack](../backpack/README.md), [compass](../compass/README.md).
|
|
- Third-party: `github.com/redis/go-redis/v9` (Redis client).
|
|
- Standard library: `context`, `errors`, `fmt`, `log/slog`, `strings`, `sync`, `time`.
|
|
|
|
## Testing
|
|
|
|
```sh
|
|
go test ./modules/cooler/...
|
|
```
|
|
|
|
The smoke tests need no external services. The cross-instance rate-limit test lives in surf (`go test ./modules/surf -run TestRateLimitAcrossInstancesSmoke`), starts a Redis container through testcontainers-go and needs Docker; `-short` skips it.
|