detached 71acbb2

perf(githttp): sweep the limiter once per interval and group IPv6 clients by /64

Fail no longer scans the whole table: expired entries are swept at most once
per Window/10, and a table at maxTracked is trimmed by a tenth in one pass.
IPv6 addresses count per /64, IPv4 (and mapped) per address. Blocked and Fail
take a context. Adds a benchmark at maxTracked as a regression guard.
commit
71acbb2eb76c8cd006f290fe5b0688f59aab389f
author
brice · · 2026-10-10 08:11 UTC
parent
58ac19e
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +216 -57
A internal/adapters/githttp/export_test.go +13 -0
@@ -0,0 +1,13 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package githttp
4+
5+// MaxTracked is the bound on the addresses a Limiter remembers.
6+const MaxTracked = maxTracked
7+
8+// Tracked returns how many clients have failures on record.
9+func (l *Limiter) Tracked() int {
10+ l.mu.Lock()
11+ defer l.mu.Unlock()
12+ return len(l.failures)
13+}
@@ -0,0 +1,13 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package githttp
4+
5+// MaxTracked is the bound on the addresses a Limiter remembers.
6+const MaxTracked = maxTracked
7+
8+// Tracked returns how many clients have failures on record.
9+func (l *Limiter) Tracked() int {
10+ l.mu.Lock()
11+ defer l.mu.Unlock()
12+ return len(l.failures)
13+}
M internal/adapters/githttp/handler.go +2 -2
@@ -196,7 +196,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
196196 ctx := r.Context()
197197 log := zerowrap.FromCtx(ctx)
198198 addr := ClientAddr(r, h.cfg.TrustedProxies)
199- if blocked, wait := h.limiter.Blocked(addr); blocked {
199+ if blocked, wait := h.limiter.Blocked(ctx, addr); blocked {
200200 log.Warn().Str("client", addr).Msg("too many failed authentications, request blocked")
201201 w.Header().Set("Retry-After", strconv.Itoa(int(math.Ceil(wait.Seconds()))))
202202 http.Error(w, "too many failed authentications", http.StatusTooManyRequests)
@@ -239,7 +239,7 @@ func (h *Handler) authenticate(r *http.Request, addr string) (domain.Principal,
239239 p, err := h.cfg.Auth.Authenticate(r.Context(), secret)
240240 if err != nil {
241241 if errors.Is(err, domain.ErrUnauthenticated) {
242- h.limiter.Fail(addr)
242+ h.limiter.Fail(r.Context(), addr)
243243 log := zerowrap.FromCtx(r.Context())
244244 log.Debug().Str("client", addr).Msg("invalid credentials")
245245 }
@@ -196,7 +196,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
196 ctx := r.Context()196 ctx := r.Context()
197 log := zerowrap.FromCtx(ctx)197 log := zerowrap.FromCtx(ctx)
198 addr := ClientAddr(r, h.cfg.TrustedProxies)198 addr := ClientAddr(r, h.cfg.TrustedProxies)
199- if blocked, wait := h.limiter.Blocked(addr); blocked {199+ if blocked, wait := h.limiter.Blocked(ctx, addr); blocked {
200 log.Warn().Str("client", addr).Msg("too many failed authentications, request blocked")200 log.Warn().Str("client", addr).Msg("too many failed authentications, request blocked")
201 w.Header().Set("Retry-After", strconv.Itoa(int(math.Ceil(wait.Seconds()))))201 w.Header().Set("Retry-After", strconv.Itoa(int(math.Ceil(wait.Seconds()))))
202 http.Error(w, "too many failed authentications", http.StatusTooManyRequests)202 http.Error(w, "too many failed authentications", http.StatusTooManyRequests)
@@ -239,7 +239,7 @@ func (h *Handler) authenticate(r *http.Request, addr string) (domain.Principal,
239 p, err := h.cfg.Auth.Authenticate(r.Context(), secret)239 p, err := h.cfg.Auth.Authenticate(r.Context(), secret)
240 if err != nil {240 if err != nil {
241 if errors.Is(err, domain.ErrUnauthenticated) {241 if errors.Is(err, domain.ErrUnauthenticated) {
242- h.limiter.Fail(addr)242+ h.limiter.Fail(r.Context(), addr)
243 log := zerowrap.FromCtx(r.Context())243 log := zerowrap.FromCtx(r.Context())
244 log.Debug().Str("client", addr).Msg("invalid credentials")244 log.Debug().Str("client", addr).Msg("invalid credentials")
245 }245 }
M internal/adapters/githttp/limiter.go +93 -41
@@ -3,12 +3,16 @@
33 package githttp
44
55 import (
6+ "context"
67 "net"
78 "net/http"
89 "net/netip"
10+ "slices"
911 "strings"
1012 "sync"
1113 "time"
14+
15+ "github.com/bnema/zerowrap"
1216 )
1317
1418 // Limiter defaults: 10 failed authentications in 10 minutes block the client
@@ -19,21 +23,32 @@ const (
1923 // Window is how long a failure counts.
2024 Window = 10 * time.Minute
2125
22- // sweepAbove is the number of tracked addresses above which expired
23- // entries are swept on the next failure.
24- sweepAbove = 1024
25- // maxTracked bounds memory: past it the entries with the oldest last
26- // failure are dropped.
26+ // sweepInterval is the least time between two sweeps of expired entries.
27+ sweepInterval = Window / 10
28+ // maxTracked bounds memory: when the table reaches it, expired entries
29+ // are dropped and then the oldest tenth of the rest, in one pass.
2730 maxTracked = 100_000
31+ // evictFraction is the share of maxTracked evicted at once, so that the
32+ // cost of an eviction pass is spread over many failures.
33+ evictFraction = 10
34+
35+ // ipv6Prefix is the width at which IPv6 clients are grouped: one network
36+ // (a /64) is what a single subscriber gets, so it counts as one client.
37+ ipv6Prefix = 64
2838 )
2939
30-// Limiter counts failed authentications per client address, in memory.
31-// It is safe for concurrent use.
40+// Limiter counts failed authentications per client, in memory. Clients are
41+// IPv4 addresses and IPv6 /64 networks. It is safe for concurrent use.
42+//
43+// Blocked and Fail are O(1) amortized: they touch one entry, a sweep of the
44+// expired entries runs at most once per sweepInterval, and a full table is
45+// trimmed by a tenth in one pass.
3246 type Limiter struct {
3347 now func() time.Time
3448
35- mu sync.Mutex
36- failures map[string][]time.Time // oldest first, all within Window
49+ mu sync.Mutex
50+ failures map[string][]time.Time // oldest first, all within Window
51+ lastSweep time.Time
3752 }
3853
3954 // NewLimiter builds a limiter. now defaults to time.Now.
@@ -44,13 +59,29 @@ func NewLimiter(now func() time.Time) *Limiter {
4459 return &Limiter{now: now, failures: make(map[string][]time.Time)}
4560 }
4661
62+// limiterKey is the identity the limiter counts: an IPv4 address (also when
63+// mapped into IPv6), or the /64 network of an IPv6 address. Anything that is
64+// not an IP address is used as is.
65+func limiterKey(addr string) string {
66+ a, err := netip.ParseAddr(addr)
67+ if err != nil {
68+ return addr
69+ }
70+ a = a.Unmap()
71+ if a.Is4() {
72+ return a.String()
73+ }
74+ return netip.PrefixFrom(a, ipv6Prefix).Masked().String()
75+}
76+
4777 // Blocked reports whether addr has used up its failures, and how long until
4878 // it may try again.
49-func (l *Limiter) Blocked(addr string) (bool, time.Duration) {
79+func (l *Limiter) Blocked(_ context.Context, addr string) (bool, time.Duration) {
80+ key := limiterKey(addr)
5081 now := l.now()
5182 l.mu.Lock()
5283 defer l.mu.Unlock()
53- fs := l.prune(addr, now)
84+ fs := l.prune(key, now)
5485 if len(fs) < MaxFailures {
5586 return false, 0
5687 }
@@ -60,63 +91,84 @@ func (l *Limiter) Blocked(addr string) (bool, time.Duration) {
6091 }
6192
6293 // Fail records a failed authentication from addr.
63-func (l *Limiter) Fail(addr string) {
94+func (l *Limiter) Fail(ctx context.Context, addr string) {
95+ key := limiterKey(addr)
6496 now := l.now()
6597 l.mu.Lock()
6698 defer l.mu.Unlock()
67- if len(l.failures) > sweepAbove {
68- l.sweep(now)
69- }
70- fs := append(l.prune(addr, now), now)
99+ l.maintain(now)
100+ fs := append(l.prune(key, now), now)
71101 if len(fs) > MaxFailures*2 {
72102 fs = fs[len(fs)-MaxFailures*2:] // enough to compute the wait
73103 }
74- l.failures[addr] = fs
104+ l.failures[key] = fs
105+ if len(fs) == MaxFailures {
106+ log := zerowrap.FromCtx(ctx)
107+ log.Warn().Str("client", key).Msg("client blocked after repeated failed authentications")
108+ }
109+}
110+
111+// maintain keeps the table small without a pass per call: expired entries
112+// go at most once per sweepInterval, and a table at its bound is trimmed.
113+// The caller holds mu.
114+func (l *Limiter) maintain(now time.Time) {
115+ if now.Sub(l.lastSweep) >= sweepInterval {
116+ l.sweep(now)
117+ }
118+ if len(l.failures) >= maxTracked {
119+ l.sweep(now) // a full table may hold expired entries the interval kept
120+ l.evictOldest()
121+ }
75122 }
76123
77-// prune drops the expired failures of addr and returns the rest. The caller
124+// prune drops the expired failures of key and returns the rest. The caller
78125 // holds mu.
79-func (l *Limiter) prune(addr string, now time.Time) []time.Time {
80- fs := l.failures[addr]
126+func (l *Limiter) prune(key string, now time.Time) []time.Time {
127+ fs := l.failures[key]
81128 cut := now.Add(-Window)
82129 i := 0
83130 for i < len(fs) && !fs[i].After(cut) {
84131 i++
85132 }
86133 if i == len(fs) {
87- delete(l.failures, addr)
134+ delete(l.failures, key)
88135 return nil
89136 }
90137 if i > 0 {
91138 fs = fs[i:]
92- l.failures[addr] = fs
139+ l.failures[key] = fs
93140 }
94141 return fs
95142 }
96143
97-// sweep removes every expired address, and the oldest ones when the table is
98-// still too large. The caller holds mu.
144+// sweep drops every expired entry. The caller holds mu.
99145 func (l *Limiter) sweep(now time.Time) {
100- for addr := range l.failures {
101- l.prune(addr, now)
102- }
103- for len(l.failures) >= maxTracked {
104- var oldest string
105- var at time.Time
106- for addr, fs := range l.failures {
107- if last := fs[len(fs)-1]; oldest == "" || last.Before(at) {
108- oldest, at = addr, last
109- }
110- }
111- delete(l.failures, oldest)
146+ l.lastSweep = now
147+ for key := range l.failures {
148+ l.prune(key, now)
112149 }
113150 }
114151
115-// Tracked returns how many addresses have failures on record, for tests.
116-func (l *Limiter) Tracked() int {
117- l.mu.Lock()
118- defer l.mu.Unlock()
119- return len(l.failures)
152+// evictOldest drops the entries whose last failure is the oldest, enough to
153+// leave the table a tenth under maxTracked. It is a single pass plus a sort.
154+// The caller holds mu.
155+func (l *Limiter) evictOldest() {
156+ drop := len(l.failures) - (maxTracked - maxTracked/evictFraction)
157+ if drop <= 0 {
158+ return
159+ }
160+ type entry struct {
161+ key string
162+ last time.Time
163+ }
164+ all := make([]entry, 0, len(l.failures))
165+ for key, fs := range l.failures {
166+ all = append(all, entry{key, fs[len(fs)-1]})
167+ }
168+ slices.SortFunc(all, func(a, b entry) int { return a.last.Compare(b.last) })
169+ for _, e := range all[:drop] {
170+ delete(l.failures, e.key)
171+ }
120172 }
121173
122174 // ClientAddr returns the address a request is attributed to: the host of
@@ -3,12 +3,16 @@
3 package githttp3 package githttp
4 4
5 import (5 import (
6+ "context"
6 "net"7 "net"
7 "net/http"8 "net/http"
8 "net/netip"9 "net/netip"
10+ "slices"
9 "strings"11 "strings"
10 "sync"12 "sync"
11 "time"13 "time"
14+
15+ "github.com/bnema/zerowrap"
12 )16 )
13 17
14 // Limiter defaults: 10 failed authentications in 10 minutes block the client18 // Limiter defaults: 10 failed authentications in 10 minutes block the client
@@ -19,21 +23,32 @@ const (
19 // Window is how long a failure counts.23 // Window is how long a failure counts.
20 Window = 10 * time.Minute24 Window = 10 * time.Minute
21 25
22- // sweepAbove is the number of tracked addresses above which expired26+ // sweepInterval is the least time between two sweeps of expired entries.
23- // entries are swept on the next failure.27+ sweepInterval = Window / 10
24- sweepAbove = 102428+ // maxTracked bounds memory: when the table reaches it, expired entries
25- // maxTracked bounds memory: past it the entries with the oldest last29+ // are dropped and then the oldest tenth of the rest, in one pass.
26- // failure are dropped.
27 maxTracked = 100_00030 maxTracked = 100_000
31+ // evictFraction is the share of maxTracked evicted at once, so that the
32+ // cost of an eviction pass is spread over many failures.
33+ evictFraction = 10
34+
35+ // ipv6Prefix is the width at which IPv6 clients are grouped: one network
36+ // (a /64) is what a single subscriber gets, so it counts as one client.
37+ ipv6Prefix = 64
28 )38 )
29 39
30-// Limiter counts failed authentications per client address, in memory.40+// Limiter counts failed authentications per client, in memory. Clients are
31-// It is safe for concurrent use.41+// IPv4 addresses and IPv6 /64 networks. It is safe for concurrent use.
42+//
43+// Blocked and Fail are O(1) amortized: they touch one entry, a sweep of the
44+// expired entries runs at most once per sweepInterval, and a full table is
45+// trimmed by a tenth in one pass.
32 type Limiter struct {46 type Limiter struct {
33 now func() time.Time47 now func() time.Time
34 48
35- mu sync.Mutex49+ mu sync.Mutex
36- failures map[string][]time.Time // oldest first, all within Window50+ failures map[string][]time.Time // oldest first, all within Window
51+ lastSweep time.Time
37 }52 }
38 53
39 // NewLimiter builds a limiter. now defaults to time.Now.54 // NewLimiter builds a limiter. now defaults to time.Now.
@@ -44,13 +59,29 @@ func NewLimiter(now func() time.Time) *Limiter {
44 return &Limiter{now: now, failures: make(map[string][]time.Time)}59 return &Limiter{now: now, failures: make(map[string][]time.Time)}
45 }60 }
46 61
62+// limiterKey is the identity the limiter counts: an IPv4 address (also when
63+// mapped into IPv6), or the /64 network of an IPv6 address. Anything that is
64+// not an IP address is used as is.
65+func limiterKey(addr string) string {
66+ a, err := netip.ParseAddr(addr)
67+ if err != nil {
68+ return addr
69+ }
70+ a = a.Unmap()
71+ if a.Is4() {
72+ return a.String()
73+ }
74+ return netip.PrefixFrom(a, ipv6Prefix).Masked().String()
75+}
76+
47 // Blocked reports whether addr has used up its failures, and how long until77 // Blocked reports whether addr has used up its failures, and how long until
48 // it may try again.78 // it may try again.
49-func (l *Limiter) Blocked(addr string) (bool, time.Duration) {79+func (l *Limiter) Blocked(_ context.Context, addr string) (bool, time.Duration) {
80+ key := limiterKey(addr)
50 now := l.now()81 now := l.now()
51 l.mu.Lock()82 l.mu.Lock()
52 defer l.mu.Unlock()83 defer l.mu.Unlock()
53- fs := l.prune(addr, now)84+ fs := l.prune(key, now)
54 if len(fs) < MaxFailures {85 if len(fs) < MaxFailures {
55 return false, 086 return false, 0
56 }87 }
@@ -60,63 +91,84 @@ func (l *Limiter) Blocked(addr string) (bool, time.Duration) {
60 }91 }
61 92
62 // Fail records a failed authentication from addr.93 // Fail records a failed authentication from addr.
63-func (l *Limiter) Fail(addr string) {94+func (l *Limiter) Fail(ctx context.Context, addr string) {
95+ key := limiterKey(addr)
64 now := l.now()96 now := l.now()
65 l.mu.Lock()97 l.mu.Lock()
66 defer l.mu.Unlock()98 defer l.mu.Unlock()
67- if len(l.failures) > sweepAbove {99+ l.maintain(now)
68- l.sweep(now)100+ fs := append(l.prune(key, now), now)
69- }
70- fs := append(l.prune(addr, now), now)
71 if len(fs) > MaxFailures*2 {101 if len(fs) > MaxFailures*2 {
72 fs = fs[len(fs)-MaxFailures*2:] // enough to compute the wait102 fs = fs[len(fs)-MaxFailures*2:] // enough to compute the wait
73 }103 }
74- l.failures[addr] = fs104+ l.failures[key] = fs
105+ if len(fs) == MaxFailures {
106+ log := zerowrap.FromCtx(ctx)
107+ log.Warn().Str("client", key).Msg("client blocked after repeated failed authentications")
108+ }
109+}
110+
111+// maintain keeps the table small without a pass per call: expired entries
112+// go at most once per sweepInterval, and a table at its bound is trimmed.
113+// The caller holds mu.
114+func (l *Limiter) maintain(now time.Time) {
115+ if now.Sub(l.lastSweep) >= sweepInterval {
116+ l.sweep(now)
117+ }
118+ if len(l.failures) >= maxTracked {
119+ l.sweep(now) // a full table may hold expired entries the interval kept
120+ l.evictOldest()
121+ }
75 }122 }
76 123
77-// prune drops the expired failures of addr and returns the rest. The caller124+// prune drops the expired failures of key and returns the rest. The caller
78 // holds mu.125 // holds mu.
79-func (l *Limiter) prune(addr string, now time.Time) []time.Time {126+func (l *Limiter) prune(key string, now time.Time) []time.Time {
80- fs := l.failures[addr]127+ fs := l.failures[key]
81 cut := now.Add(-Window)128 cut := now.Add(-Window)
82 i := 0129 i := 0
83 for i < len(fs) && !fs[i].After(cut) {130 for i < len(fs) && !fs[i].After(cut) {
84 i++131 i++
85 }132 }
86 if i == len(fs) {133 if i == len(fs) {
87- delete(l.failures, addr)134+ delete(l.failures, key)
88 return nil135 return nil
89 }136 }
90 if i > 0 {137 if i > 0 {
91 fs = fs[i:]138 fs = fs[i:]
92- l.failures[addr] = fs139+ l.failures[key] = fs
93 }140 }
94 return fs141 return fs
95 }142 }
96 143
97-// sweep removes every expired address, and the oldest ones when the table is144+// sweep drops every expired entry. The caller holds mu.
98-// still too large. The caller holds mu.
99 func (l *Limiter) sweep(now time.Time) {145 func (l *Limiter) sweep(now time.Time) {
100- for addr := range l.failures {146+ l.lastSweep = now
101- l.prune(addr, now)147+ for key := range l.failures {
102- }148+ l.prune(key, now)
103- for len(l.failures) >= maxTracked {
104- var oldest string
105- var at time.Time
106- for addr, fs := range l.failures {
107- if last := fs[len(fs)-1]; oldest == "" || last.Before(at) {
108- oldest, at = addr, last
109- }
110- }
111- delete(l.failures, oldest)
112 }149 }
113 }150 }
114 151
115-// Tracked returns how many addresses have failures on record, for tests.152+// evictOldest drops the entries whose last failure is the oldest, enough to
116-func (l *Limiter) Tracked() int {153+// leave the table a tenth under maxTracked. It is a single pass plus a sort.
117- l.mu.Lock()154+// The caller holds mu.
118- defer l.mu.Unlock()155+func (l *Limiter) evictOldest() {
119- return len(l.failures)156+ drop := len(l.failures) - (maxTracked - maxTracked/evictFraction)
157+ if drop <= 0 {
158+ return
159+ }
160+ type entry struct {
161+ key string
162+ last time.Time
163+ }
164+ all := make([]entry, 0, len(l.failures))
165+ for key, fs := range l.failures {
166+ all = append(all, entry{key, fs[len(fs)-1]})
167+ }
168+ slices.SortFunc(all, func(a, b entry) int { return a.last.Compare(b.last) })
169+ for _, e := range all[:drop] {
170+ delete(l.failures, e.key)
171+ }
120 }172 }
121 173
122 // ClientAddr returns the address a request is attributed to: the host of174 // ClientAddr returns the address a request is attributed to: the host of
M internal/adapters/githttp/limiter_test.go +108 -14
@@ -3,16 +3,21 @@
33 package githttp_test
44
55 import (
6+ "context"
7+ "fmt"
68 "net/http"
79 "net/netip"
810 "testing"
911 "time"
1012
1113 "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/require"
1215
1316 "bnema.dev/ermite/internal/adapters/githttp"
1417 )
1518
19+var bg = context.Background()
20+
1621 type fakeClock struct{ t time.Time }
1722
1823 func (c *fakeClock) now() time.Time { return c.t }
@@ -22,26 +27,26 @@ func TestLimiter(t *testing.T) {
2227 l := githttp.NewLimiter(clk.now)
2328
2429 for range githttp.MaxFailures - 1 {
25- l.Fail("1.2.3.4")
30+ l.Fail(bg, "1.2.3.4")
2631 }
27- blocked, _ := l.Blocked("1.2.3.4")
32+ blocked, _ := l.Blocked(bg, "1.2.3.4")
2833 assert.False(t, blocked, "9 failures do not block")
2934
30- l.Fail("1.2.3.4")
31- blocked, wait := l.Blocked("1.2.3.4")
35+ l.Fail(bg, "1.2.3.4")
36+ blocked, wait := l.Blocked(bg, "1.2.3.4")
3237 assert.True(t, blocked, "10 failures block")
3338 assert.Equal(t, githttp.Window, wait)
3439
35- other, _ := l.Blocked("5.6.7.8")
40+ other, _ := l.Blocked(bg, "5.6.7.8")
3641 assert.False(t, other, "another address is not affected")
3742
3843 clk.t = clk.t.Add(githttp.Window - time.Second)
39- blocked, wait = l.Blocked("1.2.3.4")
44+ blocked, wait = l.Blocked(bg, "1.2.3.4")
4045 assert.True(t, blocked)
4146 assert.Equal(t, time.Second, wait)
4247
4348 clk.t = clk.t.Add(time.Second)
44- blocked, _ = l.Blocked("1.2.3.4")
49+ blocked, _ = l.Blocked(bg, "1.2.3.4")
4550 assert.False(t, blocked, "free once the window passed")
4651 assert.Zero(t, l.Tracked(), "expired entries are pruned")
4752 }
@@ -50,28 +55,117 @@ func TestLimiterSlidingWindow(t *testing.T) {
5055 clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)}
5156 l := githttp.NewLimiter(clk.now)
5257 for range 5 {
53- l.Fail("a")
58+ l.Fail(bg, "a")
5459 }
5560 clk.t = clk.t.Add(githttp.Window - time.Minute)
5661 for range 5 {
57- l.Fail("a")
62+ l.Fail(bg, "a")
5863 }
59- blocked, wait := l.Blocked("a")
64+ blocked, wait := l.Blocked(bg, "a")
6065 assert.True(t, blocked)
6166 assert.Equal(t, time.Minute, wait, "free when the oldest of the last ten leaves")
6267
6368 clk.t = clk.t.Add(time.Minute)
64- blocked, _ = l.Blocked("a")
69+ blocked, _ = l.Blocked(bg, "a")
6570 assert.False(t, blocked, "five old failures expired, five remain")
6671 }
6772
68-func TestLimiterBoundsMemory(t *testing.T) {
73+func TestLimiterOneAddressStaysSmall(t *testing.T) {
6974 clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)}
7075 l := githttp.NewLimiter(clk.now)
7176 for range 5000 {
72- l.Fail("x")
77+ l.Fail(bg, "x")
78+ }
79+ blocked, _ := l.Blocked(bg, "x")
80+ assert.True(t, blocked)
81+ assert.Equal(t, 1, l.Tracked())
82+}
83+
84+func TestLimiterBoundsMemory(t *testing.T) {
85+ clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)}
86+ l := githttp.NewLimiter(clk.now)
87+ for i := range githttp.MaxTracked + 5000 {
88+ clk.t = clk.t.Add(time.Microsecond) // distinct, increasing last failures
89+ l.Fail(bg, fmt.Sprintf("10.%d.%d.%d", i>>16&255, i>>8&255, i&255))
90+ }
91+ assert.LessOrEqual(t, l.Tracked(), githttp.MaxTracked)
92+
93+ // The oldest were evicted, the newest kept.
94+ last := githttp.MaxTracked + 5000 - 1
95+ for range githttp.MaxFailures - 1 {
96+ l.Fail(bg, fmt.Sprintf("10.%d.%d.%d", last>>16&255, last>>8&255, last&255))
97+ }
98+ blocked, _ := l.Blocked(bg, fmt.Sprintf("10.%d.%d.%d", last>>16&255, last>>8&255, last&255))
99+ assert.True(t, blocked, "a recent address keeps its failures")
100+ l.Fail(bg, "10.0.0.0")
101+ blocked, _ = l.Blocked(bg, "10.0.0.0")
102+ assert.False(t, blocked)
103+}
104+
105+func TestLimiterSweepsExpiredEntriesOncePerInterval(t *testing.T) {
106+ clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)}
107+ l := githttp.NewLimiter(clk.now)
108+ for i := range 100 {
109+ l.Fail(bg, fmt.Sprintf("192.0.2.%d", i))
110+ }
111+ require.Equal(t, 100, l.Tracked())
112+
113+ clk.t = clk.t.Add(githttp.Window + time.Second)
114+ l.Fail(bg, "198.51.100.1") // first call past the interval: sweeps
115+ assert.Equal(t, 1, l.Tracked(), "expired entries are gone")
116+}
117+
118+// The per-failure cost stays flat with a full table: the guard against an
119+// O(n) pass per Fail.
120+func BenchmarkLimiterFailAtMaxTracked(b *testing.B) {
121+ clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)}
122+ l := githttp.NewLimiter(clk.now)
123+ keys := make([]string, githttp.MaxTracked)
124+ for i := range keys {
125+ keys[i] = fmt.Sprintf("10.%d.%d.%d", i>>16&255, i>>8&255, i&255)
126+ clk.t = clk.t.Add(time.Microsecond)
127+ l.Fail(bg, keys[i])
128+ }
129+ b.ResetTimer()
130+ for i := 0; b.Loop(); i++ {
131+ clk.t = clk.t.Add(time.Microsecond)
132+ l.Fail(bg, fmt.Sprintf("172.%d.%d.%d", i>>16&255, i>>8&255, i&255))
133+ l.Blocked(bg, keys[i%len(keys)])
73134 }
74- blocked, _ := l.Blocked("x")
135+}
136+
137+func TestLimiterGroupsIPv6By64(t *testing.T) {
138+ clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)}
139+ l := githttp.NewLimiter(clk.now)
140+
141+ // Ten different addresses of one /64 count together.
142+ for i := range githttp.MaxFailures {
143+ l.Fail(bg, fmt.Sprintf("2001:db8:1:2::%x", i+1))
144+ }
145+ assert.Equal(t, 1, l.Tracked())
146+ for _, addr := range []string{"2001:db8:1:2::ffff", "2001:db8:1:2:aaaa:bbbb:cccc:dddd"} {
147+ blocked, _ := l.Blocked(bg, addr)
148+ assert.True(t, blocked, addr)
149+ }
150+ // Another /64, and another family, are not affected.
151+ for _, addr := range []string{"2001:db8:1:3::1", "198.51.100.1"} {
152+ blocked, _ := l.Blocked(bg, addr)
153+ assert.False(t, blocked, addr)
154+ }
155+}
156+
157+func TestLimiterIPv4StaysPerAddress(t *testing.T) {
158+ clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)}
159+ l := githttp.NewLimiter(clk.now)
160+ for range githttp.MaxFailures {
161+ l.Fail(bg, "203.0.113.5")
162+ }
163+ blocked, _ := l.Blocked(bg, "203.0.113.5")
164+ assert.True(t, blocked)
165+ blocked, _ = l.Blocked(bg, "203.0.113.6")
166+ assert.False(t, blocked, "a neighbouring IPv4 address is a different client")
167+ // IPv4-mapped IPv6 is the IPv4 address.
168+ blocked, _ = l.Blocked(bg, "::ffff:203.0.113.5")
75169 assert.True(t, blocked)
76170 }
77171
@@ -3,16 +3,21 @@
3 package githttp_test3 package githttp_test
4 4
5 import (5 import (
6+ "context"
7+ "fmt"
6 "net/http"8 "net/http"
7 "net/netip"9 "net/netip"
8 "testing"10 "testing"
9 "time"11 "time"
10 12
11 "github.com/stretchr/testify/assert"13 "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/require"
12 15
13 "bnema.dev/ermite/internal/adapters/githttp"16 "bnema.dev/ermite/internal/adapters/githttp"
14 )17 )
15 18
19+var bg = context.Background()
20+
16 type fakeClock struct{ t time.Time }21 type fakeClock struct{ t time.Time }
17 22
18 func (c *fakeClock) now() time.Time { return c.t }23 func (c *fakeClock) now() time.Time { return c.t }
@@ -22,26 +27,26 @@ func TestLimiter(t *testing.T) {
22 l := githttp.NewLimiter(clk.now)27 l := githttp.NewLimiter(clk.now)
23 28
24 for range githttp.MaxFailures - 1 {29 for range githttp.MaxFailures - 1 {
25- l.Fail("1.2.3.4")30+ l.Fail(bg, "1.2.3.4")
26 }31 }
27- blocked, _ := l.Blocked("1.2.3.4")32+ blocked, _ := l.Blocked(bg, "1.2.3.4")
28 assert.False(t, blocked, "9 failures do not block")33 assert.False(t, blocked, "9 failures do not block")
29 34
30- l.Fail("1.2.3.4")35+ l.Fail(bg, "1.2.3.4")
31- blocked, wait := l.Blocked("1.2.3.4")36+ blocked, wait := l.Blocked(bg, "1.2.3.4")
32 assert.True(t, blocked, "10 failures block")37 assert.True(t, blocked, "10 failures block")
33 assert.Equal(t, githttp.Window, wait)38 assert.Equal(t, githttp.Window, wait)
34 39
35- other, _ := l.Blocked("5.6.7.8")40+ other, _ := l.Blocked(bg, "5.6.7.8")
36 assert.False(t, other, "another address is not affected")41 assert.False(t, other, "another address is not affected")
37 42
38 clk.t = clk.t.Add(githttp.Window - time.Second)43 clk.t = clk.t.Add(githttp.Window - time.Second)
39- blocked, wait = l.Blocked("1.2.3.4")44+ blocked, wait = l.Blocked(bg, "1.2.3.4")
40 assert.True(t, blocked)45 assert.True(t, blocked)
41 assert.Equal(t, time.Second, wait)46 assert.Equal(t, time.Second, wait)
42 47
43 clk.t = clk.t.Add(time.Second)48 clk.t = clk.t.Add(time.Second)
44- blocked, _ = l.Blocked("1.2.3.4")49+ blocked, _ = l.Blocked(bg, "1.2.3.4")
45 assert.False(t, blocked, "free once the window passed")50 assert.False(t, blocked, "free once the window passed")
46 assert.Zero(t, l.Tracked(), "expired entries are pruned")51 assert.Zero(t, l.Tracked(), "expired entries are pruned")
47 }52 }
@@ -50,28 +55,117 @@ func TestLimiterSlidingWindow(t *testing.T) {
50 clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)}55 clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)}
51 l := githttp.NewLimiter(clk.now)56 l := githttp.NewLimiter(clk.now)
52 for range 5 {57 for range 5 {
53- l.Fail("a")58+ l.Fail(bg, "a")
54 }59 }
55 clk.t = clk.t.Add(githttp.Window - time.Minute)60 clk.t = clk.t.Add(githttp.Window - time.Minute)
56 for range 5 {61 for range 5 {
57- l.Fail("a")62+ l.Fail(bg, "a")
58 }63 }
59- blocked, wait := l.Blocked("a")64+ blocked, wait := l.Blocked(bg, "a")
60 assert.True(t, blocked)65 assert.True(t, blocked)
61 assert.Equal(t, time.Minute, wait, "free when the oldest of the last ten leaves")66 assert.Equal(t, time.Minute, wait, "free when the oldest of the last ten leaves")
62 67
63 clk.t = clk.t.Add(time.Minute)68 clk.t = clk.t.Add(time.Minute)
64- blocked, _ = l.Blocked("a")69+ blocked, _ = l.Blocked(bg, "a")
65 assert.False(t, blocked, "five old failures expired, five remain")70 assert.False(t, blocked, "five old failures expired, five remain")
66 }71 }
67 72
68-func TestLimiterBoundsMemory(t *testing.T) {73+func TestLimiterOneAddressStaysSmall(t *testing.T) {
69 clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)}74 clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)}
70 l := githttp.NewLimiter(clk.now)75 l := githttp.NewLimiter(clk.now)
71 for range 5000 {76 for range 5000 {
72- l.Fail("x")77+ l.Fail(bg, "x")
78+ }
79+ blocked, _ := l.Blocked(bg, "x")
80+ assert.True(t, blocked)
81+ assert.Equal(t, 1, l.Tracked())
82+}
83+
84+func TestLimiterBoundsMemory(t *testing.T) {
85+ clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)}
86+ l := githttp.NewLimiter(clk.now)
87+ for i := range githttp.MaxTracked + 5000 {
88+ clk.t = clk.t.Add(time.Microsecond) // distinct, increasing last failures
89+ l.Fail(bg, fmt.Sprintf("10.%d.%d.%d", i>>16&255, i>>8&255, i&255))
90+ }
91+ assert.LessOrEqual(t, l.Tracked(), githttp.MaxTracked)
92+
93+ // The oldest were evicted, the newest kept.
94+ last := githttp.MaxTracked + 5000 - 1
95+ for range githttp.MaxFailures - 1 {
96+ l.Fail(bg, fmt.Sprintf("10.%d.%d.%d", last>>16&255, last>>8&255, last&255))
97+ }
98+ blocked, _ := l.Blocked(bg, fmt.Sprintf("10.%d.%d.%d", last>>16&255, last>>8&255, last&255))
99+ assert.True(t, blocked, "a recent address keeps its failures")
100+ l.Fail(bg, "10.0.0.0")
101+ blocked, _ = l.Blocked(bg, "10.0.0.0")
102+ assert.False(t, blocked)
103+}
104+
105+func TestLimiterSweepsExpiredEntriesOncePerInterval(t *testing.T) {
106+ clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)}
107+ l := githttp.NewLimiter(clk.now)
108+ for i := range 100 {
109+ l.Fail(bg, fmt.Sprintf("192.0.2.%d", i))
110+ }
111+ require.Equal(t, 100, l.Tracked())
112+
113+ clk.t = clk.t.Add(githttp.Window + time.Second)
114+ l.Fail(bg, "198.51.100.1") // first call past the interval: sweeps
115+ assert.Equal(t, 1, l.Tracked(), "expired entries are gone")
116+}
117+
118+// The per-failure cost stays flat with a full table: the guard against an
119+// O(n) pass per Fail.
120+func BenchmarkLimiterFailAtMaxTracked(b *testing.B) {
121+ clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)}
122+ l := githttp.NewLimiter(clk.now)
123+ keys := make([]string, githttp.MaxTracked)
124+ for i := range keys {
125+ keys[i] = fmt.Sprintf("10.%d.%d.%d", i>>16&255, i>>8&255, i&255)
126+ clk.t = clk.t.Add(time.Microsecond)
127+ l.Fail(bg, keys[i])
128+ }
129+ b.ResetTimer()
130+ for i := 0; b.Loop(); i++ {
131+ clk.t = clk.t.Add(time.Microsecond)
132+ l.Fail(bg, fmt.Sprintf("172.%d.%d.%d", i>>16&255, i>>8&255, i&255))
133+ l.Blocked(bg, keys[i%len(keys)])
73 }134 }
74- blocked, _ := l.Blocked("x")135+}
136+
137+func TestLimiterGroupsIPv6By64(t *testing.T) {
138+ clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)}
139+ l := githttp.NewLimiter(clk.now)
140+
141+ // Ten different addresses of one /64 count together.
142+ for i := range githttp.MaxFailures {
143+ l.Fail(bg, fmt.Sprintf("2001:db8:1:2::%x", i+1))
144+ }
145+ assert.Equal(t, 1, l.Tracked())
146+ for _, addr := range []string{"2001:db8:1:2::ffff", "2001:db8:1:2:aaaa:bbbb:cccc:dddd"} {
147+ blocked, _ := l.Blocked(bg, addr)
148+ assert.True(t, blocked, addr)
149+ }
150+ // Another /64, and another family, are not affected.
151+ for _, addr := range []string{"2001:db8:1:3::1", "198.51.100.1"} {
152+ blocked, _ := l.Blocked(bg, addr)
153+ assert.False(t, blocked, addr)
154+ }
155+}
156+
157+func TestLimiterIPv4StaysPerAddress(t *testing.T) {
158+ clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)}
159+ l := githttp.NewLimiter(clk.now)
160+ for range githttp.MaxFailures {
161+ l.Fail(bg, "203.0.113.5")
162+ }
163+ blocked, _ := l.Blocked(bg, "203.0.113.5")
164+ assert.True(t, blocked)
165+ blocked, _ = l.Blocked(bg, "203.0.113.6")
166+ assert.False(t, blocked, "a neighbouring IPv4 address is a different client")
167+ // IPv4-mapped IPv6 is the IPv4 address.
168+ blocked, _ = l.Blocked(bg, "::ffff:203.0.113.5")
75 assert.True(t, blocked)169 assert.True(t, blocked)
76 }170 }
77 171

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close