detached 8d2ab49

feat(clientlimit): add the sliding window submission limiter

commit
8d2ab49171fb07bf7a2311355e230e921c30ec94
author
brice · · 2026-10-19 06:55 UTC
parent
6a3e65d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +267 -0
M internal/clientlimit/export_test.go +7 -0
@@ -11,3 +11,10 @@ func (l *Limiter) Tracked() int {
1111 defer l.mu.Unlock()
1212 return len(l.failures)
1313 }
14+
15+// Tracked returns how many clients have submissions on record.
16+func (l *SubmissionLimiter) Tracked() int {
17+ l.mu.Lock()
18+ defer l.mu.Unlock()
19+ return len(l.subs)
20+}
@@ -11,3 +11,10 @@ func (l *Limiter) Tracked() int {
11 defer l.mu.Unlock()11 defer l.mu.Unlock()
12 return len(l.failures)12 return len(l.failures)
13 }13 }
14+
15+// Tracked returns how many clients have submissions on record.
16+func (l *SubmissionLimiter) Tracked() int {
17+ l.mu.Lock()
18+ defer l.mu.Unlock()
19+ return len(l.subs)
20+}
A internal/clientlimit/submissions.go +153 -0
@@ -0,0 +1,153 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package clientlimit
4+
5+import (
6+ "context"
7+ "slices"
8+ "sync"
9+ "time"
10+
11+ "bnema.dev/ermite/internal/domain"
12+ "bnema.dev/ermite/internal/ports"
13+)
14+
15+// Submission windows of the public issue form.
16+const (
17+ // HourWindow and DayWindow are the sliding windows of the limits
18+ // domain.SubmitPerHour and domain.SubmitPerDay.
19+ HourWindow = time.Hour
20+ DayWindow = 24 * time.Hour
21+
22+ submissionSweepInterval = HourWindow / 4
23+)
24+
25+// SubmissionLimiter limits the submissions of the public issue form per client
26+// (an IPv4 address or an IPv6 /64, see Key): domain.SubmitPerHour in a sliding
27+// hour and domain.SubmitPerDay in a sliding day. It also counts the
28+// submissions of all clients in the last hour. It lives in memory, is bounded
29+// like Limiter, and is safe for concurrent use.
30+type SubmissionLimiter struct {
31+ now func() time.Time
32+
33+ mu sync.Mutex
34+ subs map[string][]time.Time // oldest first, all within DayWindow
35+ global []time.Time // oldest first, all within HourWindow
36+ lastSweep time.Time
37+}
38+
39+var _ ports.SubmissionLimiter = (*SubmissionLimiter)(nil)
40+
41+// NewSubmissionLimiter builds a limiter. now defaults to time.Now.
42+func NewSubmissionLimiter(now func() time.Time) *SubmissionLimiter {
43+ if now == nil {
44+ now = time.Now
45+ }
46+ return &SubmissionLimiter{now: now, subs: make(map[string][]time.Time)}
47+}
48+
49+// Allow reports whether addr may submit now. When it may not, retryAfter is
50+// how long until the oldest submission that counts leaves its window.
51+func (l *SubmissionLimiter) Allow(_ context.Context, addr string) (bool, time.Duration) {
52+ key := limiterKey(addr)
53+ now := l.now()
54+ l.mu.Lock()
55+ defer l.mu.Unlock()
56+ ts := l.prune(key, now)
57+ var wait time.Duration
58+ if h := withinWindow(ts, now, HourWindow); len(h) >= domain.SubmitPerHour {
59+ wait = h[len(h)-domain.SubmitPerHour].Add(HourWindow).Sub(now)
60+ }
61+ if len(ts) >= domain.SubmitPerDay {
62+ wait = max(wait, ts[len(ts)-domain.SubmitPerDay].Add(DayWindow).Sub(now))
63+ }
64+ if wait <= 0 {
65+ return true, 0
66+ }
67+ return false, max(wait, time.Second)
68+}
69+
70+// Record counts a submission from addr.
71+func (l *SubmissionLimiter) Record(_ context.Context, addr string) {
72+ key := limiterKey(addr)
73+ now := l.now()
74+ l.mu.Lock()
75+ defer l.mu.Unlock()
76+ l.maintain(now)
77+ ts := append(l.prune(key, now), now)
78+ if len(ts) > domain.SubmitPerDay*2 {
79+ ts = ts[len(ts)-domain.SubmitPerDay*2:] // enough to compute the wait
80+ }
81+ l.subs[key] = ts
82+ l.global = append(l.pruneGlobal(now), now)
83+ if len(l.global) > maxTracked {
84+ l.global = l.global[len(l.global)-maxTracked:] // bound the memory
85+ }
86+}
87+
88+// Recent returns the submissions of addr and of all clients in the last hour.
89+func (l *SubmissionLimiter) Recent(_ context.Context, addr string) (perAddr, global int) {
90+ key := limiterKey(addr)
91+ now := l.now()
92+ l.mu.Lock()
93+ defer l.mu.Unlock()
94+ return len(withinWindow(l.prune(key, now), now, HourWindow)), len(l.pruneGlobal(now))
95+}
96+
97+// withinWindow returns the tail of ts (oldest first) that is within window.
98+func withinWindow(ts []time.Time, now time.Time, window time.Duration) []time.Time {
99+ cut := now.Add(-window)
100+ i, _ := slices.BinarySearchFunc(ts, cut, func(t, c time.Time) int {
101+ if t.After(c) {
102+ return 1
103+ }
104+ return -1
105+ })
106+ return ts[i:]
107+}
108+
109+// prune drops the submissions of key older than DayWindow. The caller holds mu.
110+func (l *SubmissionLimiter) prune(key string, now time.Time) []time.Time {
111+ ts := withinWindow(l.subs[key], now, DayWindow)
112+ if len(ts) == 0 {
113+ delete(l.subs, key)
114+ return nil
115+ }
116+ l.subs[key] = ts
117+ return ts
118+}
119+
120+// pruneGlobal drops the global submissions older than HourWindow. The caller
121+// holds mu.
122+func (l *SubmissionLimiter) pruneGlobal(now time.Time) []time.Time {
123+ l.global = withinWindow(l.global, now, HourWindow)
124+ return l.global
125+}
126+
127+// maintain keeps the table small: expired entries go at most once per
128+// submissionSweepInterval, and a table at its bound loses its oldest tenth.
129+// The caller holds mu.
130+func (l *SubmissionLimiter) maintain(now time.Time) {
131+ if now.Sub(l.lastSweep) >= submissionSweepInterval {
132+ l.lastSweep = now
133+ for key := range l.subs {
134+ l.prune(key, now)
135+ }
136+ }
137+ if len(l.subs) < maxTracked {
138+ return
139+ }
140+ drop := len(l.subs) - (maxTracked - maxTracked/evictFraction)
141+ type entry struct {
142+ key string
143+ last time.Time
144+ }
145+ all := make([]entry, 0, len(l.subs))
146+ for key, ts := range l.subs {
147+ all = append(all, entry{key, ts[len(ts)-1]})
148+ }
149+ slices.SortFunc(all, func(a, b entry) int { return a.last.Compare(b.last) })
150+ for _, e := range all[:drop] {
151+ delete(l.subs, e.key)
152+ }
153+}
@@ -0,0 +1,153 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package clientlimit
4+
5+import (
6+ "context"
7+ "slices"
8+ "sync"
9+ "time"
10+
11+ "bnema.dev/ermite/internal/domain"
12+ "bnema.dev/ermite/internal/ports"
13+)
14+
15+// Submission windows of the public issue form.
16+const (
17+ // HourWindow and DayWindow are the sliding windows of the limits
18+ // domain.SubmitPerHour and domain.SubmitPerDay.
19+ HourWindow = time.Hour
20+ DayWindow = 24 * time.Hour
21+
22+ submissionSweepInterval = HourWindow / 4
23+)
24+
25+// SubmissionLimiter limits the submissions of the public issue form per client
26+// (an IPv4 address or an IPv6 /64, see Key): domain.SubmitPerHour in a sliding
27+// hour and domain.SubmitPerDay in a sliding day. It also counts the
28+// submissions of all clients in the last hour. It lives in memory, is bounded
29+// like Limiter, and is safe for concurrent use.
30+type SubmissionLimiter struct {
31+ now func() time.Time
32+
33+ mu sync.Mutex
34+ subs map[string][]time.Time // oldest first, all within DayWindow
35+ global []time.Time // oldest first, all within HourWindow
36+ lastSweep time.Time
37+}
38+
39+var _ ports.SubmissionLimiter = (*SubmissionLimiter)(nil)
40+
41+// NewSubmissionLimiter builds a limiter. now defaults to time.Now.
42+func NewSubmissionLimiter(now func() time.Time) *SubmissionLimiter {
43+ if now == nil {
44+ now = time.Now
45+ }
46+ return &SubmissionLimiter{now: now, subs: make(map[string][]time.Time)}
47+}
48+
49+// Allow reports whether addr may submit now. When it may not, retryAfter is
50+// how long until the oldest submission that counts leaves its window.
51+func (l *SubmissionLimiter) Allow(_ context.Context, addr string) (bool, time.Duration) {
52+ key := limiterKey(addr)
53+ now := l.now()
54+ l.mu.Lock()
55+ defer l.mu.Unlock()
56+ ts := l.prune(key, now)
57+ var wait time.Duration
58+ if h := withinWindow(ts, now, HourWindow); len(h) >= domain.SubmitPerHour {
59+ wait = h[len(h)-domain.SubmitPerHour].Add(HourWindow).Sub(now)
60+ }
61+ if len(ts) >= domain.SubmitPerDay {
62+ wait = max(wait, ts[len(ts)-domain.SubmitPerDay].Add(DayWindow).Sub(now))
63+ }
64+ if wait <= 0 {
65+ return true, 0
66+ }
67+ return false, max(wait, time.Second)
68+}
69+
70+// Record counts a submission from addr.
71+func (l *SubmissionLimiter) Record(_ context.Context, addr string) {
72+ key := limiterKey(addr)
73+ now := l.now()
74+ l.mu.Lock()
75+ defer l.mu.Unlock()
76+ l.maintain(now)
77+ ts := append(l.prune(key, now), now)
78+ if len(ts) > domain.SubmitPerDay*2 {
79+ ts = ts[len(ts)-domain.SubmitPerDay*2:] // enough to compute the wait
80+ }
81+ l.subs[key] = ts
82+ l.global = append(l.pruneGlobal(now), now)
83+ if len(l.global) > maxTracked {
84+ l.global = l.global[len(l.global)-maxTracked:] // bound the memory
85+ }
86+}
87+
88+// Recent returns the submissions of addr and of all clients in the last hour.
89+func (l *SubmissionLimiter) Recent(_ context.Context, addr string) (perAddr, global int) {
90+ key := limiterKey(addr)
91+ now := l.now()
92+ l.mu.Lock()
93+ defer l.mu.Unlock()
94+ return len(withinWindow(l.prune(key, now), now, HourWindow)), len(l.pruneGlobal(now))
95+}
96+
97+// withinWindow returns the tail of ts (oldest first) that is within window.
98+func withinWindow(ts []time.Time, now time.Time, window time.Duration) []time.Time {
99+ cut := now.Add(-window)
100+ i, _ := slices.BinarySearchFunc(ts, cut, func(t, c time.Time) int {
101+ if t.After(c) {
102+ return 1
103+ }
104+ return -1
105+ })
106+ return ts[i:]
107+}
108+
109+// prune drops the submissions of key older than DayWindow. The caller holds mu.
110+func (l *SubmissionLimiter) prune(key string, now time.Time) []time.Time {
111+ ts := withinWindow(l.subs[key], now, DayWindow)
112+ if len(ts) == 0 {
113+ delete(l.subs, key)
114+ return nil
115+ }
116+ l.subs[key] = ts
117+ return ts
118+}
119+
120+// pruneGlobal drops the global submissions older than HourWindow. The caller
121+// holds mu.
122+func (l *SubmissionLimiter) pruneGlobal(now time.Time) []time.Time {
123+ l.global = withinWindow(l.global, now, HourWindow)
124+ return l.global
125+}
126+
127+// maintain keeps the table small: expired entries go at most once per
128+// submissionSweepInterval, and a table at its bound loses its oldest tenth.
129+// The caller holds mu.
130+func (l *SubmissionLimiter) maintain(now time.Time) {
131+ if now.Sub(l.lastSweep) >= submissionSweepInterval {
132+ l.lastSweep = now
133+ for key := range l.subs {
134+ l.prune(key, now)
135+ }
136+ }
137+ if len(l.subs) < maxTracked {
138+ return
139+ }
140+ drop := len(l.subs) - (maxTracked - maxTracked/evictFraction)
141+ type entry struct {
142+ key string
143+ last time.Time
144+ }
145+ all := make([]entry, 0, len(l.subs))
146+ for key, ts := range l.subs {
147+ all = append(all, entry{key, ts[len(ts)-1]})
148+ }
149+ slices.SortFunc(all, func(a, b entry) int { return a.last.Compare(b.last) })
150+ for _, e := range all[:drop] {
151+ delete(l.subs, e.key)
152+ }
153+}
A internal/clientlimit/submissions_test.go +107 -0
@@ -0,0 +1,107 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package clientlimit_test
4+
5+import (
6+ "fmt"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ "bnema.dev/ermite/internal/clientlimit"
14+ "bnema.dev/ermite/internal/domain"
15+)
16+
17+func newSubs() (*clientlimit.SubmissionLimiter, *fakeClock) {
18+ clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)}
19+ return clientlimit.NewSubmissionLimiter(clk.now), clk
20+}
21+
22+func TestSubmissionHourWindow(t *testing.T) {
23+ t.Parallel()
24+ l, clk := newSubs()
25+ for i := range domain.SubmitPerHour {
26+ ok, _ := l.Allow(bg, "1.2.3.4")
27+ require.True(t, ok, "submission %d", i)
28+ l.Record(bg, "1.2.3.4")
29+ clk.t = clk.t.Add(time.Minute)
30+ }
31+ ok, wait := l.Allow(bg, "1.2.3.4")
32+ assert.False(t, ok)
33+ assert.Equal(t, time.Hour-3*time.Minute, wait, "until the first one leaves the hour")
34+
35+ other, _ := l.Allow(bg, "5.6.7.8")
36+ assert.True(t, other, "another client is not affected")
37+
38+ clk.t = clk.t.Add(wait)
39+ ok, _ = l.Allow(bg, "1.2.3.4")
40+ assert.True(t, ok, "the window slides")
41+}
42+
43+func TestSubmissionDayWindow(t *testing.T) {
44+ t.Parallel()
45+ l, clk := newSubs()
46+ start := clk.t
47+ // 3 per hour, spaced so the hourly limit never bites: one every 2 hours.
48+ for range domain.SubmitPerDay {
49+ ok, _ := l.Allow(bg, "1.2.3.4")
50+ require.True(t, ok)
51+ l.Record(bg, "1.2.3.4")
52+ clk.t = clk.t.Add(2 * time.Hour)
53+ }
54+ // 20h after start: hourly window empty, day window full.
55+ ok, wait := l.Allow(bg, "1.2.3.4")
56+ assert.False(t, ok)
57+ assert.Equal(t, start.Add(clientlimit.DayWindow).Sub(clk.t), wait)
58+
59+ clk.t = start.Add(clientlimit.DayWindow)
60+ ok, _ = l.Allow(bg, "1.2.3.4")
61+ assert.True(t, ok)
62+}
63+
64+func TestSubmissionIPv6GroupsBy64(t *testing.T) {
65+ t.Parallel()
66+ l, _ := newSubs()
67+ for i := range domain.SubmitPerHour {
68+ l.Record(bg, fmt.Sprintf("2001:db8:1:2::%x", i+1)) // same /64
69+ }
70+ ok, _ := l.Allow(bg, "2001:db8:1:2:ffff::1")
71+ assert.False(t, ok, "the whole /64 shares the budget")
72+ ok, _ = l.Allow(bg, "2001:db8:1:3::1")
73+ assert.True(t, ok, "another /64 does not")
74+ ok, _ = l.Allow(bg, "::ffff:1.2.3.4")
75+ assert.True(t, ok)
76+ l.Record(bg, "::ffff:1.2.3.4")
77+ perAddr, _ := l.Recent(bg, "1.2.3.4")
78+ assert.Equal(t, 1, perAddr, "a mapped address counts as its IPv4")
79+}
80+
81+func TestSubmissionRecent(t *testing.T) {
82+ t.Parallel()
83+ l, clk := newSubs()
84+ l.Record(bg, "1.1.1.1")
85+ l.Record(bg, "1.1.1.1")
86+ l.Record(bg, "2.2.2.2")
87+ perAddr, global := l.Recent(bg, "1.1.1.1")
88+ assert.Equal(t, 2, perAddr)
89+ assert.Equal(t, 3, global)
90+
91+ clk.t = clk.t.Add(time.Hour)
92+ perAddr, global = l.Recent(bg, "1.1.1.1")
93+ assert.Zero(t, perAddr, "Recent looks at the last hour")
94+ assert.Zero(t, global)
95+}
96+
97+func TestSubmissionEvictsOldest(t *testing.T) {
98+ t.Parallel()
99+ l, clk := newSubs()
100+ for i := range clientlimit.MaxTracked + 10 {
101+ clk.t = clk.t.Add(time.Millisecond)
102+ l.Record(bg, fmt.Sprintf("10.%d.%d.%d", i>>16&255, i>>8&255, i&255))
103+ }
104+ assert.LessOrEqual(t, l.Tracked(), clientlimit.MaxTracked, "bounded")
105+ ok, _ := l.Allow(bg, "10.0.0.0")
106+ assert.True(t, ok, "the oldest were evicted")
107+}
@@ -0,0 +1,107 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package clientlimit_test
4+
5+import (
6+ "fmt"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ "bnema.dev/ermite/internal/clientlimit"
14+ "bnema.dev/ermite/internal/domain"
15+)
16+
17+func newSubs() (*clientlimit.SubmissionLimiter, *fakeClock) {
18+ clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)}
19+ return clientlimit.NewSubmissionLimiter(clk.now), clk
20+}
21+
22+func TestSubmissionHourWindow(t *testing.T) {
23+ t.Parallel()
24+ l, clk := newSubs()
25+ for i := range domain.SubmitPerHour {
26+ ok, _ := l.Allow(bg, "1.2.3.4")
27+ require.True(t, ok, "submission %d", i)
28+ l.Record(bg, "1.2.3.4")
29+ clk.t = clk.t.Add(time.Minute)
30+ }
31+ ok, wait := l.Allow(bg, "1.2.3.4")
32+ assert.False(t, ok)
33+ assert.Equal(t, time.Hour-3*time.Minute, wait, "until the first one leaves the hour")
34+
35+ other, _ := l.Allow(bg, "5.6.7.8")
36+ assert.True(t, other, "another client is not affected")
37+
38+ clk.t = clk.t.Add(wait)
39+ ok, _ = l.Allow(bg, "1.2.3.4")
40+ assert.True(t, ok, "the window slides")
41+}
42+
43+func TestSubmissionDayWindow(t *testing.T) {
44+ t.Parallel()
45+ l, clk := newSubs()
46+ start := clk.t
47+ // 3 per hour, spaced so the hourly limit never bites: one every 2 hours.
48+ for range domain.SubmitPerDay {
49+ ok, _ := l.Allow(bg, "1.2.3.4")
50+ require.True(t, ok)
51+ l.Record(bg, "1.2.3.4")
52+ clk.t = clk.t.Add(2 * time.Hour)
53+ }
54+ // 20h after start: hourly window empty, day window full.
55+ ok, wait := l.Allow(bg, "1.2.3.4")
56+ assert.False(t, ok)
57+ assert.Equal(t, start.Add(clientlimit.DayWindow).Sub(clk.t), wait)
58+
59+ clk.t = start.Add(clientlimit.DayWindow)
60+ ok, _ = l.Allow(bg, "1.2.3.4")
61+ assert.True(t, ok)
62+}
63+
64+func TestSubmissionIPv6GroupsBy64(t *testing.T) {
65+ t.Parallel()
66+ l, _ := newSubs()
67+ for i := range domain.SubmitPerHour {
68+ l.Record(bg, fmt.Sprintf("2001:db8:1:2::%x", i+1)) // same /64
69+ }
70+ ok, _ := l.Allow(bg, "2001:db8:1:2:ffff::1")
71+ assert.False(t, ok, "the whole /64 shares the budget")
72+ ok, _ = l.Allow(bg, "2001:db8:1:3::1")
73+ assert.True(t, ok, "another /64 does not")
74+ ok, _ = l.Allow(bg, "::ffff:1.2.3.4")
75+ assert.True(t, ok)
76+ l.Record(bg, "::ffff:1.2.3.4")
77+ perAddr, _ := l.Recent(bg, "1.2.3.4")
78+ assert.Equal(t, 1, perAddr, "a mapped address counts as its IPv4")
79+}
80+
81+func TestSubmissionRecent(t *testing.T) {
82+ t.Parallel()
83+ l, clk := newSubs()
84+ l.Record(bg, "1.1.1.1")
85+ l.Record(bg, "1.1.1.1")
86+ l.Record(bg, "2.2.2.2")
87+ perAddr, global := l.Recent(bg, "1.1.1.1")
88+ assert.Equal(t, 2, perAddr)
89+ assert.Equal(t, 3, global)
90+
91+ clk.t = clk.t.Add(time.Hour)
92+ perAddr, global = l.Recent(bg, "1.1.1.1")
93+ assert.Zero(t, perAddr, "Recent looks at the last hour")
94+ assert.Zero(t, global)
95+}
96+
97+func TestSubmissionEvictsOldest(t *testing.T) {
98+ t.Parallel()
99+ l, clk := newSubs()
100+ for i := range clientlimit.MaxTracked + 10 {
101+ clk.t = clk.t.Add(time.Millisecond)
102+ l.Record(bg, fmt.Sprintf("10.%d.%d.%d", i>>16&255, i>>8&255, i&255))
103+ }
104+ assert.LessOrEqual(t, l.Tracked(), clientlimit.MaxTracked, "bounded")
105+ ok, _ := l.Allow(bg, "10.0.0.0")
106+ assert.True(t, ok, "the oldest were evicted")
107+}

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 41

↑↓ moveenter openesc close