orchard
mirrorEvery site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.
blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics
1package tools
2
3import (
4 "testing"
5 "time"
6)
7
8type fakeStore struct {
9 saved map[string]int
10 cleared []string
11}
12
13func (f *fakeStore) SavePenalty(host string, till time.Time, trips int) {
14 if f.saved == nil {
15 f.saved = map[string]int{}
16 }
17 f.saved[host] = trips
18}
19func (f *fakeStore) ClearPenalty(host string) { f.cleared = append(f.cleared, host) }
20
21// A host that refuses is left alone for hours and not for ten minutes, because
22// a short box means asking again six times an hour and every one of those is a
23// request to something that already said no.
24func TestGuardLeavesARefusingHostAlone(t *testing.T) {
25 g := NewGuard(10 * time.Minute)
26 g.Trip("example.com")
27 blocked, left := g.Blocked("example.com")
28 if !blocked {
29 t.Fatal("a host that refused is not boxed")
30 }
31 if left < refusalCool-time.Minute {
32 t.Errorf("boxed for only %s, want about %s", left, refusalCool)
33 }
34 // Flat rather than escalating, so nothing has to be poked to find the step.
35 g.Trip("example.com")
36 if _, again := g.Blocked("example.com"); again > refusalCool+time.Minute {
37 t.Errorf("a second refusal stretched the box to %s", again)
38 }
39}
40
41// One bad afternoon must not leave a host on a four hour backoff for the rest
42// of the process, so a call that worked resets the streak.
43func TestGuardOKResetsTheStreak(t *testing.T) {
44 store := &fakeStore{}
45 g := NewGuard(10 * time.Minute)
46 g.Restore(store, nil)
47
48 g.Trip("example.com")
49 g.Trip("example.com")
50 g.OK("example.com")
51 if len(store.cleared) != 1 || store.cleared[0] != "example.com" {
52 t.Errorf("a host that answered was not cleared: %v", store.cleared)
53 }
54
55}
56
57// A deploy used to empty the penalty box, so the next turn asked a host that
58// was still refusing. That is the surest way to keep a rate limit alive.
59func TestGuardSurvivesARestart(t *testing.T) {
60 store := &fakeStore{}
61 g := NewGuard(10 * time.Minute)
62 g.Restore(store, nil)
63 g.Trip(SearchHost)
64 if store.saved[SearchHost] != 1 {
65 t.Fatalf("the trip was not persisted: %v", store.saved)
66 }
67
68 // A new process, handed what the last one wrote.
69 fresh := NewGuard(10 * time.Minute)
70 fresh.Restore(store, map[string][2]int64{
71 SearchHost: {time.Now().Add(30 * time.Minute).UnixMilli(), 3},
72 })
73 blocked, left := fresh.Blocked(SearchHost)
74 if !blocked {
75 t.Fatal("a restart cleared the penalty box")
76 }
77 if left < 25*time.Minute {
78 t.Errorf("restored box has %s left, want about 30 minutes", left)
79 }
80}
81
82// Down is what the page reads to say search is unavailable.
83func TestGuardDownListsOnlyLiveBoxes(t *testing.T) {
84 g := NewGuard(10 * time.Minute)
85 g.Trip(SearchHost)
86 down := g.Down()
87 if _, ok := down[SearchHost]; !ok {
88 t.Errorf("a tripped host is not reported down: %v", down)
89 }
90 if _, ok := down["never-called.example"]; ok {
91 t.Error("a host that was never tripped is reported down")
92 }
93}
94
95// A slow page is not a ban. Before this split, one timeout put a host in the
96// same six hour box DuckDuckGo gets, which on 2026-09-08 took
97// developer.android.com, mirrors.wikimedia.org and hacker-news.firebaseio.com
98// out of reach for the afternoon.
99func TestAStumbleIsBoxedBrieflyAndARefusalIsNot(t *testing.T) {
100 g := NewGuard(time.Minute)
101 g.Stumble("developer.android.com")
102 blocked, left := g.Blocked("developer.android.com")
103 if !blocked {
104 t.Fatal("a host that timed out was not held off at all")
105 }
106 if left > stumbleCool {
107 t.Errorf("a timeout was boxed for %s, want no more than %s", left, stumbleCool)
108 }
109
110 g.Trip("html.duckduckgo.com")
111 _, banned := g.Blocked("html.duckduckgo.com")
112 if banned <= stumbleCool {
113 t.Errorf("an outright refusal was boxed for %s, want the full %s", banned, refusalCool)
114 }
115}
116
117// A page timing out on a host that already refused us must not shorten the box
118// it is in, which is what would happen if the later, smaller deadline won.
119func TestAStumbleDoesNotCutShortARefusal(t *testing.T) {
120 g := NewGuard(time.Minute)
121 g.Trip("html.duckduckgo.com")
122 _, before := g.Blocked("html.duckduckgo.com")
123 g.Stumble("html.duckduckgo.com")
124 _, after := g.Blocked("html.duckduckgo.com")
125 if after < before-time.Minute {
126 t.Errorf("a stumble cut the refusal from %s to %s", before, after)
127 }
128}