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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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) { | |||
| 196 | 196 | ctx := r.Context() | |
| 197 | 197 | log := zerowrap.FromCtx(ctx) | |
| 198 | 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 | 200 | log.Warn().Str("client", addr).Msg("too many failed authentications, request blocked") | |
| 201 | 201 | w.Header().Set("Retry-After", strconv.Itoa(int(math.Ceil(wait.Seconds())))) | |
| 202 | 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 | 239 | p, err := h.cfg.Auth.Authenticate(r.Context(), secret) | |
| 240 | 240 | if err != nil { | |
| 241 | 241 | if errors.Is(err, domain.ErrUnauthenticated) { | |
| 242 | - | h.limiter.Fail(addr) | |
| 242 | + | h.limiter.Fail(r.Context(), addr) | |
| 243 | 243 | log := zerowrap.FromCtx(r.Context()) | |
| 244 | 244 | log.Debug().Str("client", addr).Msg("invalid credentials") | |
| 245 | 245 | } | |
| @@ -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 @@ | |||
| 3 | 3 | package githttp | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | + | "context" | |
| 6 | 7 | "net" | |
| 7 | 8 | "net/http" | |
| 8 | 9 | "net/netip" | |
| 10 | + | "slices" | |
| 9 | 11 | "strings" | |
| 10 | 12 | "sync" | |
| 11 | 13 | "time" | |
| 14 | + | ||
| 15 | + | "github.com/bnema/zerowrap" | |
| 12 | 16 | ) | |
| 13 | 17 | ||
| 14 | 18 | // Limiter defaults: 10 failed authentications in 10 minutes block the client | |
| @@ -19,21 +23,32 @@ const ( | |||
| 19 | 23 | // Window is how long a failure counts. | |
| 20 | 24 | Window = 10 * time.Minute | |
| 21 | 25 | ||
| 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. | |
| 27 | 30 | 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. | |
| 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. | |
| 32 | 46 | type Limiter struct { | |
| 33 | 47 | now func() time.Time | |
| 34 | 48 | ||
| 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 | |
| 37 | 52 | } | |
| 38 | 53 | ||
| 39 | 54 | // NewLimiter builds a limiter. now defaults to time.Now. | |
| @@ -44,13 +59,29 @@ func NewLimiter(now func() time.Time) *Limiter { | |||
| 44 | 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 | 77 | // Blocked reports whether addr has used up its failures, and how long until | |
| 48 | 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 | 81 | now := l.now() | |
| 51 | 82 | l.mu.Lock() | |
| 52 | 83 | defer l.mu.Unlock() | |
| 53 | - | fs := l.prune(addr, now) | |
| 84 | + | fs := l.prune(key, now) | |
| 54 | 85 | if len(fs) < MaxFailures { | |
| 55 | 86 | return false, 0 | |
| 56 | 87 | } | |
| @@ -60,63 +91,84 @@ func (l *Limiter) Blocked(addr string) (bool, time.Duration) { | |||
| 60 | 91 | } | |
| 61 | 92 | ||
| 62 | 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 | 96 | now := l.now() | |
| 65 | 97 | l.mu.Lock() | |
| 66 | 98 | 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) | |
| 71 | 101 | if len(fs) > MaxFailures*2 { | |
| 72 | 102 | fs = fs[len(fs)-MaxFailures*2:] // enough to compute the wait | |
| 73 | 103 | } | |
| 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 | + | } | |
| 75 | 122 | } | |
| 76 | 123 | ||
| 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 | |
| 78 | 125 | // 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] | |
| 81 | 128 | cut := now.Add(-Window) | |
| 82 | 129 | i := 0 | |
| 83 | 130 | for i < len(fs) && !fs[i].After(cut) { | |
| 84 | 131 | i++ | |
| 85 | 132 | } | |
| 86 | 133 | if i == len(fs) { | |
| 87 | - | delete(l.failures, addr) | |
| 134 | + | delete(l.failures, key) | |
| 88 | 135 | return nil | |
| 89 | 136 | } | |
| 90 | 137 | if i > 0 { | |
| 91 | 138 | fs = fs[i:] | |
| 92 | - | l.failures[addr] = fs | |
| 139 | + | l.failures[key] = fs | |
| 93 | 140 | } | |
| 94 | 141 | return fs | |
| 95 | 142 | } | |
| 96 | 143 | ||
| 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. | |
| 99 | 145 | 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) | |
| 112 | 149 | } | |
| 113 | 150 | } | |
| 114 | 151 | ||
| 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 | + | } | |
| 120 | 172 | } | |
| 121 | 173 | ||
| 122 | 174 | // ClientAddr returns the address a request is attributed to: the host of | |
| @@ -3,12 +3,16 @@ | |||||
| 3 | package githttp | 3 | 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 client | 18 | // 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.Minute | 24 | Window = 10 * time.Minute | ||
| 21 | 25 | ||||
| 22 | - | // sweepAbove is the number of tracked addresses above which expired | 26 | + | // 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 = 1024 | 28 | + | // maxTracked bounds memory: when the table reaches it, expired entries |
| 25 | - | // maxTracked bounds memory: past it the entries with the oldest last | 29 | + | // are dropped and then the oldest tenth of the rest, in one pass. |
| 26 | - | // failure are dropped. | |||
| 27 | maxTracked = 100_000 | 30 | 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.Time | 47 | now func() time.Time | ||
| 34 | 48 | ||||
| 35 | - | mu sync.Mutex | 49 | + | mu sync.Mutex |
| 36 | - | failures map[string][]time.Time // oldest first, all within Window | 50 | + | 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 until | 77 | // 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, 0 | 86 | 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 wait | 102 | fs = fs[len(fs)-MaxFailures*2:] // enough to compute the wait | ||
| 73 | } | 103 | } | ||
| 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 | + | } | |||
| 75 | } | 122 | } | ||
| 76 | 123 | ||||
| 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 |
| 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 := 0 | 129 | 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 nil | 135 | return nil | ||
| 89 | } | 136 | } | ||
| 90 | if i > 0 { | 137 | if i > 0 { | ||
| 91 | fs = fs[i:] | 138 | fs = fs[i:] | ||
| 92 | - | l.failures[addr] = fs | 139 | + | l.failures[key] = fs |
| 93 | } | 140 | } | ||
| 94 | return fs | 141 | return fs | ||
| 95 | } | 142 | } | ||
| 96 | 143 | ||||
| 97 | - | // sweep removes every expired address, and the oldest ones when the table is | 144 | + | // 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 of | 174 | // 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 @@ | |||
| 3 | 3 | package githttp_test | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | + | "context" | |
| 7 | + | "fmt" | |
| 6 | 8 | "net/http" | |
| 7 | 9 | "net/netip" | |
| 8 | 10 | "testing" | |
| 9 | 11 | "time" | |
| 10 | 12 | ||
| 11 | 13 | "github.com/stretchr/testify/assert" | |
| 14 | + | "github.com/stretchr/testify/require" | |
| 12 | 15 | ||
| 13 | 16 | "bnema.dev/ermite/internal/adapters/githttp" | |
| 14 | 17 | ) | |
| 15 | 18 | ||
| 19 | + | var bg = context.Background() | |
| 20 | + | ||
| 16 | 21 | type fakeClock struct{ t time.Time } | |
| 17 | 22 | ||
| 18 | 23 | func (c *fakeClock) now() time.Time { return c.t } | |
| @@ -22,26 +27,26 @@ func TestLimiter(t *testing.T) { | |||
| 22 | 27 | l := githttp.NewLimiter(clk.now) | |
| 23 | 28 | ||
| 24 | 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 | 33 | assert.False(t, blocked, "9 failures do not block") | |
| 29 | 34 | ||
| 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") | |
| 32 | 37 | assert.True(t, blocked, "10 failures block") | |
| 33 | 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 | 41 | assert.False(t, other, "another address is not affected") | |
| 37 | 42 | ||
| 38 | 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 | 45 | assert.True(t, blocked) | |
| 41 | 46 | assert.Equal(t, time.Second, wait) | |
| 42 | 47 | ||
| 43 | 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 | 50 | assert.False(t, blocked, "free once the window passed") | |
| 46 | 51 | assert.Zero(t, l.Tracked(), "expired entries are pruned") | |
| 47 | 52 | } | |
| @@ -50,28 +55,117 @@ func TestLimiterSlidingWindow(t *testing.T) { | |||
| 50 | 55 | clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)} | |
| 51 | 56 | l := githttp.NewLimiter(clk.now) | |
| 52 | 57 | for range 5 { | |
| 53 | - | l.Fail("a") | |
| 58 | + | l.Fail(bg, "a") | |
| 54 | 59 | } | |
| 55 | 60 | clk.t = clk.t.Add(githttp.Window - time.Minute) | |
| 56 | 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 | 65 | assert.True(t, blocked) | |
| 61 | 66 | assert.Equal(t, time.Minute, wait, "free when the oldest of the last ten leaves") | |
| 62 | 67 | ||
| 63 | 68 | clk.t = clk.t.Add(time.Minute) | |
| 64 | - | blocked, _ = l.Blocked("a") | |
| 69 | + | blocked, _ = l.Blocked(bg, "a") | |
| 65 | 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 | 74 | clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)} | |
| 70 | 75 | l := githttp.NewLimiter(clk.now) | |
| 71 | 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 | 169 | assert.True(t, blocked) | |
| 76 | 170 | } | |
| 77 | 171 | ||
| @@ -3,16 +3,21 @@ | |||||
| 3 | package githttp_test | 3 | 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 | ||||