detached 6e542e5

refactor(clientlimit): move the failed-auth limiter out of githttp

The API adapter shares the limiter and ClientAddr with smart HTTP, and
adapters must not import each other.
commit
6e542e5e8c37b8edddf9151307a9db996d929874
author
brice · · 2026-10-10 09:41 UTC
parent
638a507
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +37 -32
M internal/adapters/githttp/handler.go +5 -4
@@ -20,6 +20,7 @@ import (
2020
2121 "github.com/bnema/zerowrap"
2222
23+ "bnema.dev/ermite/internal/clientlimit"
2324 "bnema.dev/ermite/internal/domain"
2425 "bnema.dev/ermite/internal/ports"
2526 )
@@ -97,7 +98,7 @@ type Config struct {
9798 // TrustedProxies are the networks allowed to set X-Forwarded-For.
9899 TrustedProxies []netip.Prefix
99100 // Limiter counts failed authentications; nil builds a default one.
100- Limiter *Limiter
101+ Limiter *clientlimit.Limiter
101102 // MaxBodyBytes bounds the body of a push; 0 means DefaultMaxBodyBytes.
102103 // The body of a fetch is bounded by 64 MiB at most.
103104 MaxBodyBytes int64
@@ -106,7 +107,7 @@ type Config struct {
106107 // Handler serves the smart HTTP routes of Patterns.
107108 type Handler struct {
108109 cfg Config
109- limiter *Limiter
110+ limiter *clientlimit.Limiter
110111 maxBody int64
111112 }
112113
@@ -114,7 +115,7 @@ type Handler struct {
114115 func New(cfg Config) *Handler {
115116 h := &Handler{cfg: cfg, limiter: cfg.Limiter, maxBody: cfg.MaxBodyBytes}
116117 if h.limiter == nil {
117- h.limiter = NewLimiter(nil)
118+ h.limiter = clientlimit.NewLimiter(nil)
118119 }
119120 if h.maxBody <= 0 {
120121 h.maxBody = DefaultMaxBodyBytes
@@ -194,7 +195,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
194195
195196 ctx := r.Context()
196197 log := zerowrap.FromCtx(ctx)
197- addr := ClientAddr(r, h.cfg.TrustedProxies)
198+ addr := clientlimit.ClientAddr(r, h.cfg.TrustedProxies)
198199 if blocked, wait := h.limiter.Blocked(ctx, addr); blocked {
199200 log.Warn().Str("client", addr).Msg("too many failed authentications, request blocked")
200201 w.Header().Set("Retry-After", strconv.Itoa(int(math.Ceil(wait.Seconds()))))
@@ -20,6 +20,7 @@ import (
20 20
21 "github.com/bnema/zerowrap"21 "github.com/bnema/zerowrap"
22 22
23+ "bnema.dev/ermite/internal/clientlimit"
23 "bnema.dev/ermite/internal/domain"24 "bnema.dev/ermite/internal/domain"
24 "bnema.dev/ermite/internal/ports"25 "bnema.dev/ermite/internal/ports"
25 )26 )
@@ -97,7 +98,7 @@ type Config struct {
97 // TrustedProxies are the networks allowed to set X-Forwarded-For.98 // TrustedProxies are the networks allowed to set X-Forwarded-For.
98 TrustedProxies []netip.Prefix99 TrustedProxies []netip.Prefix
99 // Limiter counts failed authentications; nil builds a default one.100 // Limiter counts failed authentications; nil builds a default one.
100- Limiter *Limiter101+ Limiter *clientlimit.Limiter
101 // MaxBodyBytes bounds the body of a push; 0 means DefaultMaxBodyBytes.102 // MaxBodyBytes bounds the body of a push; 0 means DefaultMaxBodyBytes.
102 // The body of a fetch is bounded by 64 MiB at most.103 // The body of a fetch is bounded by 64 MiB at most.
103 MaxBodyBytes int64104 MaxBodyBytes int64
@@ -106,7 +107,7 @@ type Config struct {
106 // Handler serves the smart HTTP routes of Patterns.107 // Handler serves the smart HTTP routes of Patterns.
107 type Handler struct {108 type Handler struct {
108 cfg Config109 cfg Config
109- limiter *Limiter110+ limiter *clientlimit.Limiter
110 maxBody int64111 maxBody int64
111 }112 }
112 113
@@ -114,7 +115,7 @@ type Handler struct {
114 func New(cfg Config) *Handler {115 func New(cfg Config) *Handler {
115 h := &Handler{cfg: cfg, limiter: cfg.Limiter, maxBody: cfg.MaxBodyBytes}116 h := &Handler{cfg: cfg, limiter: cfg.Limiter, maxBody: cfg.MaxBodyBytes}
116 if h.limiter == nil {117 if h.limiter == nil {
117- h.limiter = NewLimiter(nil)118+ h.limiter = clientlimit.NewLimiter(nil)
118 }119 }
119 if h.maxBody <= 0 {120 if h.maxBody <= 0 {
120 h.maxBody = DefaultMaxBodyBytes121 h.maxBody = DefaultMaxBodyBytes
@@ -194,7 +195,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
194 195
195 ctx := r.Context()196 ctx := r.Context()
196 log := zerowrap.FromCtx(ctx)197 log := zerowrap.FromCtx(ctx)
197- addr := ClientAddr(r, h.cfg.TrustedProxies)198+ addr := clientlimit.ClientAddr(r, h.cfg.TrustedProxies)
198 if blocked, wait := h.limiter.Blocked(ctx, addr); blocked {199 if blocked, wait := h.limiter.Blocked(ctx, addr); blocked {
199 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")
200 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()))))
M internal/adapters/githttp/handler_test.go +4 -3
@@ -20,6 +20,7 @@ import (
2020 "github.com/stretchr/testify/require"
2121
2222 "bnema.dev/ermite/internal/adapters/githttp"
23+ "bnema.dev/ermite/internal/clientlimit"
2324 "bnema.dev/ermite/internal/domain"
2425 "bnema.dev/ermite/internal/ports"
2526 "bnema.dev/ermite/internal/ports/mocks"
@@ -410,8 +411,8 @@ func TestHandlerBackendFailure(t *testing.T) {
410411 }
411412
412413 func TestHandlerLimiter(t *testing.T) {
413- r := newRig(t, func(c *githttp.Config) { c.Limiter = githttp.NewLimiter(nil) })
414- for i := range githttp.MaxFailures {
414+ r := newRig(t, func(c *githttp.Config) { c.Limiter = clientlimit.NewLimiter(nil) })
415+ for i := range clientlimit.MaxFailures {
415416 code, _, _ := r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "u", "wrong", nil, nil)
416417 require.Equal(t, 401, code, i)
417418 }
@@ -432,7 +433,7 @@ func TestHandlerLimiterTrustedProxyViaConfig(t *testing.T) {
432433 r := newRig(t, func(c *githttp.Config) {
433434 c.TrustedProxies = []netip.Prefix{netip.MustParsePrefix("127.0.0.0/8")}
434435 })
435- for range githttp.MaxFailures {
436+ for range clientlimit.MaxFailures {
436437 r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "u", "wrong", nil, map[string]string{"X-Forwarded-For": "198.51.100.1"})
437438 }
438439 code, _, _ := r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "", "", nil, map[string]string{"X-Forwarded-For": "198.51.100.1"})
@@ -20,6 +20,7 @@ import (
20 "github.com/stretchr/testify/require"20 "github.com/stretchr/testify/require"
21 21
22 "bnema.dev/ermite/internal/adapters/githttp"22 "bnema.dev/ermite/internal/adapters/githttp"
23+ "bnema.dev/ermite/internal/clientlimit"
23 "bnema.dev/ermite/internal/domain"24 "bnema.dev/ermite/internal/domain"
24 "bnema.dev/ermite/internal/ports"25 "bnema.dev/ermite/internal/ports"
25 "bnema.dev/ermite/internal/ports/mocks"26 "bnema.dev/ermite/internal/ports/mocks"
@@ -410,8 +411,8 @@ func TestHandlerBackendFailure(t *testing.T) {
410 }411 }
411 412
412 func TestHandlerLimiter(t *testing.T) {413 func TestHandlerLimiter(t *testing.T) {
413- r := newRig(t, func(c *githttp.Config) { c.Limiter = githttp.NewLimiter(nil) })414+ r := newRig(t, func(c *githttp.Config) { c.Limiter = clientlimit.NewLimiter(nil) })
414- for i := range githttp.MaxFailures {415+ for i := range clientlimit.MaxFailures {
415 code, _, _ := r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "u", "wrong", nil, nil)416 code, _, _ := r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "u", "wrong", nil, nil)
416 require.Equal(t, 401, code, i)417 require.Equal(t, 401, code, i)
417 }418 }
@@ -432,7 +433,7 @@ func TestHandlerLimiterTrustedProxyViaConfig(t *testing.T) {
432 r := newRig(t, func(c *githttp.Config) {433 r := newRig(t, func(c *githttp.Config) {
433 c.TrustedProxies = []netip.Prefix{netip.MustParsePrefix("127.0.0.0/8")}434 c.TrustedProxies = []netip.Prefix{netip.MustParsePrefix("127.0.0.0/8")}
434 })435 })
435- for range githttp.MaxFailures {436+ for range clientlimit.MaxFailures {
436 r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "u", "wrong", nil, map[string]string{"X-Forwarded-For": "198.51.100.1"})437 r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "u", "wrong", nil, map[string]string{"X-Forwarded-For": "198.51.100.1"})
437 }438 }
438 code, _, _ := r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "", "", nil, map[string]string{"X-Forwarded-For": "198.51.100.1"})439 code, _, _ := r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "", "", nil, map[string]string{"X-Forwarded-For": "198.51.100.1"})
R internal/adapters/githttp/export_test.go → internal/clientlimit/export_test.go +1 -1 renamed, 93% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package githttp
3+package clientlimit
44
55 // MaxTracked is the bound on the addresses a Limiter remembers.
66 const MaxTracked = maxTracked
@@ -1,6 +1,6 @@
1 // SPDX-License-Identifier: AGPL-3.0-or-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package githttp3+package clientlimit
4 4
5 // MaxTracked is the bound on the addresses a Limiter remembers.5 // MaxTracked is the bound on the addresses a Limiter remembers.
6 const MaxTracked = maxTracked6 const MaxTracked = maxTracked
R internal/adapters/githttp/limiter.go → internal/clientlimit/limiter.go +4 -1 renamed, 96% similar
@@ -1,6 +1,9 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package githttp
3+// Package clientlimit counts failed authentications per client address and
4+// finds the address of a request behind trusted proxies. The HTTP adapters
5+// that authenticate callers (smart HTTP, the API) share it.
6+package clientlimit
47
58 import (
69 "context"
@@ -1,6 +1,9 @@
1 // SPDX-License-Identifier: AGPL-3.0-or-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package githttp3+// Package clientlimit counts failed authentications per client address and
4+// finds the address of a request behind trusted proxies. The HTTP adapters
5+// that authenticate callers (smart HTTP, the API) share it.
6+package clientlimit
4 7
5 import (8 import (
6 "context"9 "context"
R internal/adapters/githttp/limiter_test.go → internal/clientlimit/limiter_test.go +23 -23 renamed, 86% similar
@@ -1,6 +1,6 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-package githttp_test
3+package clientlimit_test
44
55 import (
66 "context"
@@ -13,7 +13,7 @@ import (
1313 "github.com/stretchr/testify/assert"
1414 "github.com/stretchr/testify/require"
1515
16- "bnema.dev/ermite/internal/adapters/githttp"
16+ "bnema.dev/ermite/internal/clientlimit"
1717 )
1818
1919 var bg = context.Background()
@@ -24,9 +24,9 @@ func (c *fakeClock) now() time.Time { return c.t }
2424
2525 func TestLimiter(t *testing.T) {
2626 clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)}
27- l := githttp.NewLimiter(clk.now)
27+ l := clientlimit.NewLimiter(clk.now)
2828
29- for range githttp.MaxFailures - 1 {
29+ for range clientlimit.MaxFailures - 1 {
3030 l.Fail(bg, "1.2.3.4")
3131 }
3232 blocked, _ := l.Blocked(bg, "1.2.3.4")
@@ -35,12 +35,12 @@ func TestLimiter(t *testing.T) {
3535 l.Fail(bg, "1.2.3.4")
3636 blocked, wait := l.Blocked(bg, "1.2.3.4")
3737 assert.True(t, blocked, "10 failures block")
38- assert.Equal(t, githttp.Window, wait)
38+ assert.Equal(t, clientlimit.Window, wait)
3939
4040 other, _ := l.Blocked(bg, "5.6.7.8")
4141 assert.False(t, other, "another address is not affected")
4242
43- clk.t = clk.t.Add(githttp.Window - time.Second)
43+ clk.t = clk.t.Add(clientlimit.Window - time.Second)
4444 blocked, wait = l.Blocked(bg, "1.2.3.4")
4545 assert.True(t, blocked)
4646 assert.Equal(t, time.Second, wait)
@@ -53,11 +53,11 @@ func TestLimiter(t *testing.T) {
5353
5454 func TestLimiterSlidingWindow(t *testing.T) {
5555 clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)}
56- l := githttp.NewLimiter(clk.now)
56+ l := clientlimit.NewLimiter(clk.now)
5757 for range 5 {
5858 l.Fail(bg, "a")
5959 }
60- clk.t = clk.t.Add(githttp.Window - time.Minute)
60+ clk.t = clk.t.Add(clientlimit.Window - time.Minute)
6161 for range 5 {
6262 l.Fail(bg, "a")
6363 }
@@ -72,7 +72,7 @@ func TestLimiterSlidingWindow(t *testing.T) {
7272
7373 func TestLimiterOneAddressStaysSmall(t *testing.T) {
7474 clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)}
75- l := githttp.NewLimiter(clk.now)
75+ l := clientlimit.NewLimiter(clk.now)
7676 for range 5000 {
7777 l.Fail(bg, "x")
7878 }
@@ -83,16 +83,16 @@ func TestLimiterOneAddressStaysSmall(t *testing.T) {
8383
8484 func TestLimiterBoundsMemory(t *testing.T) {
8585 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 {
86+ l := clientlimit.NewLimiter(clk.now)
87+ for i := range clientlimit.MaxTracked + 5000 {
8888 clk.t = clk.t.Add(time.Microsecond) // distinct, increasing last failures
8989 l.Fail(bg, fmt.Sprintf("10.%d.%d.%d", i>>16&255, i>>8&255, i&255))
9090 }
91- assert.LessOrEqual(t, l.Tracked(), githttp.MaxTracked)
91+ assert.LessOrEqual(t, l.Tracked(), clientlimit.MaxTracked)
9292
9393 // The oldest were evicted, the newest kept.
94- last := githttp.MaxTracked + 5000 - 1
95- for range githttp.MaxFailures - 1 {
94+ last := clientlimit.MaxTracked + 5000 - 1
95+ for range clientlimit.MaxFailures - 1 {
9696 l.Fail(bg, fmt.Sprintf("10.%d.%d.%d", last>>16&255, last>>8&255, last&255))
9797 }
9898 blocked, _ := l.Blocked(bg, fmt.Sprintf("10.%d.%d.%d", last>>16&255, last>>8&255, last&255))
@@ -104,13 +104,13 @@ func TestLimiterBoundsMemory(t *testing.T) {
104104
105105 func TestLimiterSweepsExpiredEntriesOncePerInterval(t *testing.T) {
106106 clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)}
107- l := githttp.NewLimiter(clk.now)
107+ l := clientlimit.NewLimiter(clk.now)
108108 for i := range 100 {
109109 l.Fail(bg, fmt.Sprintf("192.0.2.%d", i))
110110 }
111111 require.Equal(t, 100, l.Tracked())
112112
113- clk.t = clk.t.Add(githttp.Window + time.Second)
113+ clk.t = clk.t.Add(clientlimit.Window + time.Second)
114114 l.Fail(bg, "198.51.100.1") // first call past the interval: sweeps
115115 assert.Equal(t, 1, l.Tracked(), "expired entries are gone")
116116 }
@@ -119,8 +119,8 @@ func TestLimiterSweepsExpiredEntriesOncePerInterval(t *testing.T) {
119119 // O(n) pass per Fail.
120120 func BenchmarkLimiterFailAtMaxTracked(b *testing.B) {
121121 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)
122+ l := clientlimit.NewLimiter(clk.now)
123+ keys := make([]string, clientlimit.MaxTracked)
124124 for i := range keys {
125125 keys[i] = fmt.Sprintf("10.%d.%d.%d", i>>16&255, i>>8&255, i&255)
126126 clk.t = clk.t.Add(time.Microsecond)
@@ -136,10 +136,10 @@ func BenchmarkLimiterFailAtMaxTracked(b *testing.B) {
136136
137137 func TestLimiterGroupsIPv6By64(t *testing.T) {
138138 clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)}
139- l := githttp.NewLimiter(clk.now)
139+ l := clientlimit.NewLimiter(clk.now)
140140
141141 // Ten different addresses of one /64 count together.
142- for i := range githttp.MaxFailures {
142+ for i := range clientlimit.MaxFailures {
143143 l.Fail(bg, fmt.Sprintf("2001:db8:1:2::%x", i+1))
144144 }
145145 assert.Equal(t, 1, l.Tracked())
@@ -156,8 +156,8 @@ func TestLimiterGroupsIPv6By64(t *testing.T) {
156156
157157 func TestLimiterIPv4StaysPerAddress(t *testing.T) {
158158 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 {
159+ l := clientlimit.NewLimiter(clk.now)
160+ for range clientlimit.MaxFailures {
161161 l.Fail(bg, "203.0.113.5")
162162 }
163163 blocked, _ := l.Blocked(bg, "203.0.113.5")
@@ -198,7 +198,7 @@ func TestClientAddr(t *testing.T) {
198198 for _, v := range tt.xff {
199199 r.Header.Add("X-Forwarded-For", v)
200200 }
201- assert.Equal(t, tt.want, githttp.ClientAddr(r, tt.trusted))
201+ assert.Equal(t, tt.want, clientlimit.ClientAddr(r, tt.trusted))
202202 })
203203 }
204204 }
@@ -1,6 +1,6 @@
1 // SPDX-License-Identifier: AGPL-3.0-or-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 2
3-package githttp_test3+package clientlimit_test
4 4
5 import (5 import (
6 "context"6 "context"
@@ -13,7 +13,7 @@ import (
13 "github.com/stretchr/testify/assert"13 "github.com/stretchr/testify/assert"
14 "github.com/stretchr/testify/require"14 "github.com/stretchr/testify/require"
15 15
16- "bnema.dev/ermite/internal/adapters/githttp"16+ "bnema.dev/ermite/internal/clientlimit"
17 )17 )
18 18
19 var bg = context.Background()19 var bg = context.Background()
@@ -24,9 +24,9 @@ func (c *fakeClock) now() time.Time { return c.t }
24 24
25 func TestLimiter(t *testing.T) {25 func TestLimiter(t *testing.T) {
26 clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)}26 clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)}
27- l := githttp.NewLimiter(clk.now)27+ l := clientlimit.NewLimiter(clk.now)
28 28
29- for range githttp.MaxFailures - 1 {29+ for range clientlimit.MaxFailures - 1 {
30 l.Fail(bg, "1.2.3.4")30 l.Fail(bg, "1.2.3.4")
31 }31 }
32 blocked, _ := l.Blocked(bg, "1.2.3.4")32 blocked, _ := l.Blocked(bg, "1.2.3.4")
@@ -35,12 +35,12 @@ func TestLimiter(t *testing.T) {
35 l.Fail(bg, "1.2.3.4")35 l.Fail(bg, "1.2.3.4")
36 blocked, wait := l.Blocked(bg, "1.2.3.4")36 blocked, wait := l.Blocked(bg, "1.2.3.4")
37 assert.True(t, blocked, "10 failures block")37 assert.True(t, blocked, "10 failures block")
38- assert.Equal(t, githttp.Window, wait)38+ assert.Equal(t, clientlimit.Window, wait)
39 39
40 other, _ := l.Blocked(bg, "5.6.7.8")40 other, _ := l.Blocked(bg, "5.6.7.8")
41 assert.False(t, other, "another address is not affected")41 assert.False(t, other, "another address is not affected")
42 42
43- clk.t = clk.t.Add(githttp.Window - time.Second)43+ clk.t = clk.t.Add(clientlimit.Window - time.Second)
44 blocked, wait = l.Blocked(bg, "1.2.3.4")44 blocked, wait = l.Blocked(bg, "1.2.3.4")
45 assert.True(t, blocked)45 assert.True(t, blocked)
46 assert.Equal(t, time.Second, wait)46 assert.Equal(t, time.Second, wait)
@@ -53,11 +53,11 @@ func TestLimiter(t *testing.T) {
53 53
54 func TestLimiterSlidingWindow(t *testing.T) {54 func TestLimiterSlidingWindow(t *testing.T) {
55 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)}
56- l := githttp.NewLimiter(clk.now)56+ l := clientlimit.NewLimiter(clk.now)
57 for range 5 {57 for range 5 {
58 l.Fail(bg, "a")58 l.Fail(bg, "a")
59 }59 }
60- clk.t = clk.t.Add(githttp.Window - time.Minute)60+ clk.t = clk.t.Add(clientlimit.Window - time.Minute)
61 for range 5 {61 for range 5 {
62 l.Fail(bg, "a")62 l.Fail(bg, "a")
63 }63 }
@@ -72,7 +72,7 @@ func TestLimiterSlidingWindow(t *testing.T) {
72 72
73 func TestLimiterOneAddressStaysSmall(t *testing.T) {73 func TestLimiterOneAddressStaysSmall(t *testing.T) {
74 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)}
75- l := githttp.NewLimiter(clk.now)75+ l := clientlimit.NewLimiter(clk.now)
76 for range 5000 {76 for range 5000 {
77 l.Fail(bg, "x")77 l.Fail(bg, "x")
78 }78 }
@@ -83,16 +83,16 @@ func TestLimiterOneAddressStaysSmall(t *testing.T) {
83 83
84 func TestLimiterBoundsMemory(t *testing.T) {84 func TestLimiterBoundsMemory(t *testing.T) {
85 clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)}85 clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)}
86- l := githttp.NewLimiter(clk.now)86+ l := clientlimit.NewLimiter(clk.now)
87- for i := range githttp.MaxTracked + 5000 {87+ for i := range clientlimit.MaxTracked + 5000 {
88 clk.t = clk.t.Add(time.Microsecond) // distinct, increasing last failures88 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))89 l.Fail(bg, fmt.Sprintf("10.%d.%d.%d", i>>16&255, i>>8&255, i&255))
90 }90 }
91- assert.LessOrEqual(t, l.Tracked(), githttp.MaxTracked)91+ assert.LessOrEqual(t, l.Tracked(), clientlimit.MaxTracked)
92 92
93 // The oldest were evicted, the newest kept.93 // The oldest were evicted, the newest kept.
94- last := githttp.MaxTracked + 5000 - 194+ last := clientlimit.MaxTracked + 5000 - 1
95- for range githttp.MaxFailures - 1 {95+ for range clientlimit.MaxFailures - 1 {
96 l.Fail(bg, fmt.Sprintf("10.%d.%d.%d", last>>16&255, last>>8&255, last&255))96 l.Fail(bg, fmt.Sprintf("10.%d.%d.%d", last>>16&255, last>>8&255, last&255))
97 }97 }
98 blocked, _ := l.Blocked(bg, fmt.Sprintf("10.%d.%d.%d", last>>16&255, last>>8&255, last&255))98 blocked, _ := l.Blocked(bg, fmt.Sprintf("10.%d.%d.%d", last>>16&255, last>>8&255, last&255))
@@ -104,13 +104,13 @@ func TestLimiterBoundsMemory(t *testing.T) {
104 104
105 func TestLimiterSweepsExpiredEntriesOncePerInterval(t *testing.T) {105 func TestLimiterSweepsExpiredEntriesOncePerInterval(t *testing.T) {
106 clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)}106 clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)}
107- l := githttp.NewLimiter(clk.now)107+ l := clientlimit.NewLimiter(clk.now)
108 for i := range 100 {108 for i := range 100 {
109 l.Fail(bg, fmt.Sprintf("192.0.2.%d", i))109 l.Fail(bg, fmt.Sprintf("192.0.2.%d", i))
110 }110 }
111 require.Equal(t, 100, l.Tracked())111 require.Equal(t, 100, l.Tracked())
112 112
113- clk.t = clk.t.Add(githttp.Window + time.Second)113+ clk.t = clk.t.Add(clientlimit.Window + time.Second)
114 l.Fail(bg, "198.51.100.1") // first call past the interval: sweeps114 l.Fail(bg, "198.51.100.1") // first call past the interval: sweeps
115 assert.Equal(t, 1, l.Tracked(), "expired entries are gone")115 assert.Equal(t, 1, l.Tracked(), "expired entries are gone")
116 }116 }
@@ -119,8 +119,8 @@ func TestLimiterSweepsExpiredEntriesOncePerInterval(t *testing.T) {
119 // O(n) pass per Fail.119 // O(n) pass per Fail.
120 func BenchmarkLimiterFailAtMaxTracked(b *testing.B) {120 func BenchmarkLimiterFailAtMaxTracked(b *testing.B) {
121 clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)}121 clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)}
122- l := githttp.NewLimiter(clk.now)122+ l := clientlimit.NewLimiter(clk.now)
123- keys := make([]string, githttp.MaxTracked)123+ keys := make([]string, clientlimit.MaxTracked)
124 for i := range keys {124 for i := range keys {
125 keys[i] = fmt.Sprintf("10.%d.%d.%d", i>>16&255, i>>8&255, i&255)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)126 clk.t = clk.t.Add(time.Microsecond)
@@ -136,10 +136,10 @@ func BenchmarkLimiterFailAtMaxTracked(b *testing.B) {
136 136
137 func TestLimiterGroupsIPv6By64(t *testing.T) {137 func TestLimiterGroupsIPv6By64(t *testing.T) {
138 clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)}138 clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)}
139- l := githttp.NewLimiter(clk.now)139+ l := clientlimit.NewLimiter(clk.now)
140 140
141 // Ten different addresses of one /64 count together.141 // Ten different addresses of one /64 count together.
142- for i := range githttp.MaxFailures {142+ for i := range clientlimit.MaxFailures {
143 l.Fail(bg, fmt.Sprintf("2001:db8:1:2::%x", i+1))143 l.Fail(bg, fmt.Sprintf("2001:db8:1:2::%x", i+1))
144 }144 }
145 assert.Equal(t, 1, l.Tracked())145 assert.Equal(t, 1, l.Tracked())
@@ -156,8 +156,8 @@ func TestLimiterGroupsIPv6By64(t *testing.T) {
156 156
157 func TestLimiterIPv4StaysPerAddress(t *testing.T) {157 func TestLimiterIPv4StaysPerAddress(t *testing.T) {
158 clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)}158 clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)}
159- l := githttp.NewLimiter(clk.now)159+ l := clientlimit.NewLimiter(clk.now)
160- for range githttp.MaxFailures {160+ for range clientlimit.MaxFailures {
161 l.Fail(bg, "203.0.113.5")161 l.Fail(bg, "203.0.113.5")
162 }162 }
163 blocked, _ := l.Blocked(bg, "203.0.113.5")163 blocked, _ := l.Blocked(bg, "203.0.113.5")
@@ -198,7 +198,7 @@ func TestClientAddr(t *testing.T) {
198 for _, v := range tt.xff {198 for _, v := range tt.xff {
199 r.Header.Add("X-Forwarded-For", v)199 r.Header.Add("X-Forwarded-For", v)
200 }200 }
201- assert.Equal(t, tt.want, githttp.ClientAddr(r, tt.trusted))201+ assert.Equal(t, tt.want, clientlimit.ClientAddr(r, tt.trusted))
202 })202 })
203 }203 }
204 }204 }

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 3

Tags 45

↑↓ moveenter openesc close