Skip to content

Commit 110022c

Browse files
authored
Merge pull request #78 from systemli/feat/redis-rate-limiter
✨ Persist rate-limit state in Redis
2 parents 1bf4f35 + 5126e14 commit 110022c

16 files changed

Lines changed: 390 additions & 321 deletions

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ MAILBOX_LISTEN_ADDR=":10003"
77
SENDERS_LISTEN_ADDR=":10004"
88
METRICS_LISTEN_ADDR=":10005"
99
RATE_LIMIT_MESSAGE="Rate limit exceeded, please try again later"
10+
REDIS_URL="redis://redis:6379/0"
1011

1112
LOG_LEVEL=debug
1213
LOG_FORMAT=text

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ Only process at `protocol_state=END-OF-MESSAGE` for accurate counting.
3333
## Pitfalls
3434

3535
- `USERLI_TOKEN` is required — app exits immediately if missing
36-
- Rate limiter cleanup runs every 5 minutes in a background goroutine
36+
- `REDIS_URL` is required — rate-limit state lives in Redis (sliding-window sorted sets, key prefix `userli:ratelimit:sender:`, ~25h TTL); Redis errors fail open
3737
- Socketmap names must match exactly: `alias`, `domain`, `mailbox`, `senders`
3838

3939
## Docker Development

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ The adapter is configured via environment variables:
1616
- `POLICY_LISTEN_ADDR`: The address to listen on for policy requests (rate limiting). Default: `:10003`.
1717
- `METRICS_LISTEN_ADDR`: The address to listen on for metrics. Default: `:10002`.
1818
- `RATE_LIMIT_MESSAGE`: The rejection message returned when a sender exceeds their quota. Default: `Rate limit exceeded, please try again later`.
19+
- `REDIS_URL`: Connection URL for Redis (required). Format follows [`redis.ParseURL`](https://pkg.go.dev/github.com/redis/go-redis/v9#ParseURL), e.g. `redis://[user:password@]host:port/db`. Rate-limit state is stored in Redis so it survives restarts.
1920

2021
In Postfix, you can configure the adapter using the socketmap protocol like this:
2122

@@ -48,6 +49,8 @@ The Userli API endpoint `/api/postfix/smtp_quota/{email}` returns:
4849

4950
Where `0` means unlimited. If the API is unreachable, messages are allowed (fail-open).
5051

52+
Rate-limit state is persisted to Redis (`REDIS_URL`) so it survives restarts. If Redis is unreachable, messages are also allowed (fail-open) and the `userli_postfix_adapter_ratelimit_backend_errors_total` counter is incremented.
53+
5154
## Docker
5255

5356
You can run the adapter using Docker.
@@ -172,7 +175,7 @@ The adapter exposes Prometheus metrics on `/metrics` (port 10002) and provides h
172175
- `userli_postfix_adapter_policy_request_duration_seconds` - Policy request duration histogram
173176
- `userli_postfix_adapter_quota_exceeded_total` - Total messages rejected due to quota
174177
- `userli_postfix_adapter_quota_checks_total` - Total quota checks performed
175-
- `userli_postfix_adapter_tracked_senders` - Number of senders tracked by rate limiter
178+
- `userli_postfix_adapter_ratelimit_backend_errors_total` - Total Redis errors hit by the rate limiter, labelled by `operation`
176179

177180
All metrics include relevant labels (handler, status, endpoint, etc.).
178181

config.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ type Config struct {
2727

2828
// RateLimitMessage is the message returned when rate limit is exceeded.
2929
RateLimitMessage string
30+
31+
// RedisURL is the connection URL for Redis (used to persist rate-limit state).
32+
RedisURL string
3033
}
3134

3235
// NewConfig creates a new Config with default values.
@@ -63,6 +66,11 @@ func NewConfig() (*Config, error) {
6366
rateLimitMessage = "Rate limit exceeded, please try again later"
6467
}
6568

69+
redisURL := os.Getenv("REDIS_URL")
70+
if redisURL == "" {
71+
return nil, fmt.Errorf("REDIS_URL is required")
72+
}
73+
6674
return &Config{
6775
UserliBaseURL: userliBaseURL,
6876
UserliToken: userliToken,
@@ -71,5 +79,6 @@ func NewConfig() (*Config, error) {
7179
PolicyListenAddr: policyListenAddr,
7280
MetricsListenAddr: metricsListenAddr,
7381
RateLimitMessage: rateLimitMessage,
82+
RedisURL: redisURL,
7483
}, nil
7584
}

config_test.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ func (s *ConfigTestSuite) SetupTest() {
2020
func (s *ConfigTestSuite) TestNewConfig() {
2121
s.Run("fail when userli token not set", func() {
2222
os.Unsetenv("USERLI_TOKEN")
23+
os.Unsetenv("REDIS_URL")
2324

2425
config, err := NewConfig()
2526

@@ -28,8 +29,20 @@ func (s *ConfigTestSuite) TestNewConfig() {
2829
s.Contains(err.Error(), "USERLI_TOKEN is required")
2930
})
3031

32+
s.Run("fail when redis url not set", func() {
33+
os.Setenv("USERLI_TOKEN", "token")
34+
os.Unsetenv("REDIS_URL")
35+
36+
config, err := NewConfig()
37+
38+
s.Nil(config)
39+
s.Error(err)
40+
s.Contains(err.Error(), "REDIS_URL is required")
41+
})
42+
3143
s.Run("default config", func() {
3244
os.Setenv("USERLI_TOKEN", "token")
45+
os.Setenv("REDIS_URL", "redis://localhost:6379/0")
3346

3447
config, err := NewConfig()
3548

@@ -40,6 +53,7 @@ func (s *ConfigTestSuite) TestNewConfig() {
4053
s.Equal(":10001", config.SocketmapListenAddr)
4154
s.Equal(":10002", config.MetricsListenAddr)
4255
s.Equal("Rate limit exceeded, please try again later", config.RateLimitMessage)
56+
s.Equal("redis://localhost:6379/0", config.RedisURL)
4357
})
4458

4559
s.Run("custom config", func() {
@@ -49,6 +63,7 @@ func (s *ConfigTestSuite) TestNewConfig() {
4963
os.Setenv("SOCKETMAP_LISTEN_ADDR", ":20001")
5064
os.Setenv("METRICS_LISTEN_ADDR", ":20002")
5165
os.Setenv("RATE_LIMIT_MESSAGE", "Too many emails")
66+
os.Setenv("REDIS_URL", "redis://redis:6379/1")
5267

5368
config, err := NewConfig()
5469

@@ -59,6 +74,7 @@ func (s *ConfigTestSuite) TestNewConfig() {
5974
s.Equal(":20001", config.SocketmapListenAddr)
6075
s.Equal(":20002", config.MetricsListenAddr)
6176
s.Equal("Too many emails", config.RateLimitMessage)
77+
s.Equal("redis://redis:6379/1", config.RedisURL)
6278
})
6379
}
6480

docker-compose.yml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,20 @@ services:
1010
- "10003:10003"
1111
env_file:
1212
- .env
13+
depends_on:
14+
redis:
15+
condition: service_healthy
16+
networks:
17+
- userli
18+
19+
redis:
20+
hostname: redis
21+
image: redis:7-alpine
22+
healthcheck:
23+
test: ["CMD", "redis-cli", "ping"]
24+
interval: 5s
25+
timeout: 3s
26+
retries: 5
1327
networks:
1428
- userli
1529

go.mod

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@ module github.com/systemli/userli-postfix-adapter
33
go 1.23.1
44

55
require (
6+
github.com/alicebob/miniredis/v2 v2.37.0
67
github.com/h2non/gock v1.2.0
78
github.com/markdingo/netstring v1.0.2
89
github.com/prometheus/client_golang v1.23.2
10+
github.com/redis/go-redis/v9 v9.18.0
911
github.com/stretchr/testify v1.11.1
1012
go.uber.org/zap v1.27.1
1113
)
@@ -14,13 +16,16 @@ require (
1416
github.com/beorn7/perks v1.0.1 // indirect
1517
github.com/cespare/xxhash/v2 v2.3.0 // indirect
1618
github.com/davecgh/go-spew v1.1.1 // indirect
19+
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
1720
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 // indirect
1821
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
1922
github.com/pmezard/go-difflib v1.0.0 // indirect
2023
github.com/prometheus/client_model v0.6.2 // indirect
2124
github.com/prometheus/common v0.66.1 // indirect
2225
github.com/prometheus/procfs v0.16.1 // indirect
2326
github.com/stretchr/objx v0.5.2 // indirect
27+
github.com/yuin/gopher-lua v1.1.1 // indirect
28+
go.uber.org/atomic v1.11.0 // indirect
2429
go.uber.org/multierr v1.10.0 // indirect
2530
go.yaml.in/yaml/v2 v2.4.2 // indirect
2631
golang.org/x/sys v0.35.0 // indirect

go.sum

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,17 @@
1+
github.com/alicebob/miniredis/v2 v2.37.0 h1:RheObYW32G1aiJIj81XVt78ZHJpHonHLHW7OLIshq68=
2+
github.com/alicebob/miniredis/v2 v2.37.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
13
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
24
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
5+
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
6+
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
7+
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
8+
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
39
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
410
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
511
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
612
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
13+
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
14+
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
715
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
816
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
917
github.com/h2non/gock v1.2.0 h1:K6ol8rfrRkUOefooBC8elXoaNGYkpp7y2qcxGG6BzUE=
@@ -12,6 +20,8 @@ github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslC
1220
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI=
1321
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
1422
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
23+
github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
24+
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
1525
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
1626
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
1727
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@@ -34,12 +44,20 @@ github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9Z
3444
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
3545
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
3646
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
47+
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
48+
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
3749
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
3850
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
3951
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
4052
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
4153
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
4254
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
55+
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
56+
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
57+
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
58+
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
59+
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
60+
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
4361
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
4462
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
4563
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=

main.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,15 +47,19 @@ func main() {
4747
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
4848
defer stop()
4949

50-
rateLimiter := NewRateLimiter(ctx)
50+
rateLimiter, err := NewRateLimiter(ctx, config.RedisURL, logger.Named("ratelimit"))
51+
if err != nil {
52+
logger.Fatal("Failed to initialize rate limiter", zap.Error(err))
53+
}
54+
defer func() { _ = rateLimiter.Close() }()
5155
policyServer := NewPolicyServer(userli, rateLimiter, config.RateLimitMessage, logger.Named("policy"))
5256

5357
var wg sync.WaitGroup
5458

5559
wg.Add(1)
5660
go func() {
5761
defer wg.Done()
58-
StartMetricsServer(ctx, config.MetricsListenAddr, userli, rateLimiter)
62+
StartMetricsServer(ctx, config.MetricsListenAddr, userli)
5963
}()
6064

6165
wg.Add(1)

policy.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,7 @@ func (p *PolicyServer) handleRequest(ctx context.Context, req *PolicyRequest) st
219219
}
220220

221221
// Check rate limit
222-
allowed, hourCount, dayCount := p.rateLimiter.CheckAndIncrement(sender, quota)
222+
allowed, hourCount, dayCount := p.rateLimiter.CheckAndIncrement(quotaCtx, sender, quota)
223223

224224
// Update metrics
225225
quotaChecksTotal.WithLabelValues("checked").Inc()

0 commit comments

Comments
 (0)