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 main
2
3import (
4 "context"
5 "net/http"
6 "os"
7 "path/filepath"
8 "testing"
9 "time"
10)
11
12func TestGuardSpendsItsBudgetAndThenRefuses(t *testing.T) {
13 g := NewGuard(t.TempDir())
14
15 // yahoo is paced, so the pace has to be stepped over to reach the budget.
16 budget := budgets["uptime"].perHour
17 for i := 0; i < budget; i++ {
18 if err := g.Allow("uptime"); err != nil {
19 t.Fatalf("refused at call %d of %d: %v", i+1, budget, err)
20 }
21 }
22 if err := g.Allow("uptime"); err == nil {
23 t.Error("the call past the hourly budget was allowed")
24 }
25}
26
27func TestGuardPacesConsecutiveCalls(t *testing.T) {
28 g := NewGuard(t.TempDir())
29
30 if err := g.Allow("yahoo"); err != nil {
31 t.Fatalf("first call refused: %v", err)
32 }
33 if err := g.Allow("yahoo"); err == nil {
34 t.Error("a second call inside the pace window was allowed")
35 }
36}
37
38// 429 and 503 are the endpoint saying stop, so they open the breaker at once
39// rather than after a failure streak.
40func TestGuardOpensImmediatelyOnRateLimit(t *testing.T) {
41 for _, status := range []int{http.StatusTooManyRequests, http.StatusServiceUnavailable} {
42 g := NewGuard(t.TempDir())
43 if err := g.Allow("yahoo"); err != nil {
44 t.Fatalf("status %d: first call refused: %v", status, err)
45 }
46 g.Fail("yahoo", status, 0)
47
48 if err := g.Allow("yahoo"); err == nil {
49 t.Errorf("status %d did not open the breaker", status)
50 }
51 if open := g.Status(); len(open) != 1 || open[0] != "yahoo" {
52 t.Errorf("status %d reports %v open, want [yahoo]", status, open)
53 }
54 }
55}
56
57func TestGuardToleratesASingleTransportFailure(t *testing.T) {
58 g := NewGuard(t.TempDir())
59 g.Fail("uptime", 0, 0)
60
61 if err := g.Allow("uptime"); err != nil {
62 t.Errorf("one timeout closed the breaker: %v", err)
63 }
64 for i := 1; i < failsToTrip; i++ {
65 g.Fail("uptime", 0, 0)
66 }
67 if err := g.Allow("uptime"); err == nil {
68 t.Errorf("%d consecutive failures did not open the breaker", failsToTrip)
69 }
70}
71
72func TestGuardTakesTheLongerOfBackoffAndRetryAfter(t *testing.T) {
73 g := NewGuard(t.TempDir())
74 g.Fail("yahoo", http.StatusTooManyRequests, 20*time.Minute)
75
76 g.mu.Lock()
77 open := g.entries["yahoo"].OpenUntil
78 g.mu.Unlock()
79
80 if wait := time.Until(open); wait < 19*time.Minute {
81 t.Errorf("breaker opens for %s, want the 20 minute Retry-After", wait)
82 }
83}
84
85// A breaker that forgets on restart is not a breaker, and a restart loop
86// against an endpoint that just said 429 is the case it exists for.
87func TestGuardStateSurvivesARestart(t *testing.T) {
88 dir := t.TempDir()
89
90 g := NewGuard(dir)
91 g.Fail("yahoo", http.StatusTooManyRequests, 0)
92 g.Flush()
93
94 if _, err := os.Stat(filepath.Join(dir, "guard.json")); err != nil {
95 t.Fatalf("nothing was written: %v", err)
96 }
97
98 if err := NewGuard(dir).Allow("yahoo"); err == nil {
99 t.Error("a fresh guard reading the state on disk allowed the call anyway")
100 }
101}
102
103func TestGuardIgnoresUnparseableState(t *testing.T) {
104 dir := t.TempDir()
105 if err := os.WriteFile(filepath.Join(dir, "guard.json"), []byte("{not json"), 0o644); err != nil {
106 t.Fatal(err)
107 }
108
109 // Starting closed is the safe direction: it costs one request to find out
110 // the endpoint is still angry, where starting open would refuse forever.
111 if err := NewGuard(dir).Allow("yahoo"); err != nil {
112 t.Errorf("a corrupt state file blocked every call: %v", err)
113 }
114}
115
116func TestGuardRefusesAnEndpointWithNoBudget(t *testing.T) {
117 if err := NewGuard(t.TempDir()).Allow("nobody"); err == nil {
118 t.Error("an endpoint with no budget was allowed")
119 }
120}
121
122func TestParseRetryAfter(t *testing.T) {
123 if got := parseRetryAfter("120"); got != 2*time.Minute {
124 t.Errorf("seconds form gave %s, want 2m", got)
125 }
126 if got := parseRetryAfter(""); got != 0 {
127 t.Errorf("empty gave %s, want 0", got)
128 }
129 if got := parseRetryAfter("later"); got != 0 {
130 t.Errorf("unparseable gave %s, want 0", got)
131 }
132 if got := parseRetryAfter("-5"); got != 0 {
133 t.Errorf("negative gave %s, want 0", got)
134 }
135
136 // The HTTP date form, which RFC 9110 allows alongside a delay.
137 future := time.Now().Add(10 * time.Minute).UTC().Format(http.TimeFormat)
138 if got := parseRetryAfter(future); got < 9*time.Minute {
139 t.Errorf("date form gave %s, want about 10m", got)
140 }
141 past := time.Now().Add(-time.Hour).UTC().Format(http.TimeFormat)
142 if got := parseRetryAfter(past); got != 0 {
143 t.Errorf("a date in the past gave %s, want 0", got)
144 }
145}
146
147// Pacing is a timing concern, not a breaker, so a poller waits it out. Three
148// pollers share the Yahoo endpoint and fire together at boot; refusing two of
149// them left their panels empty until their next tick, an hour and six hours
150// later.
151func TestGuardReserveWaitsOutThePace(t *testing.T) {
152 g := NewGuard(t.TempDir())
153
154 if err := g.Reserve(t.Context(), "yahoo"); err != nil {
155 t.Fatalf("first reserve refused: %v", err)
156 }
157
158 start := time.Now()
159 if err := g.Reserve(t.Context(), "yahoo"); err != nil {
160 t.Fatalf("second reserve refused instead of waiting: %v", err)
161 }
162 if waited := time.Since(start); waited < budgets["yahoo"].pace/2 {
163 t.Errorf("second reserve returned after %s, so it did not wait for the pace", waited)
164 }
165}
166
167// Waiting does not help an open breaker or a spent budget, and a caller that
168// queued on either would pile up behind a dead endpoint.
169func TestGuardReserveStillRefusesTheBreaker(t *testing.T) {
170 g := NewGuard(t.TempDir())
171 g.Fail("yahoo", http.StatusTooManyRequests, 0)
172
173 start := time.Now()
174 if err := g.Reserve(t.Context(), "yahoo"); err == nil {
175 t.Error("reserve waited on an open breaker instead of refusing")
176 }
177 if waited := time.Since(start); waited > time.Second {
178 t.Errorf("reserve took %s to refuse an open breaker", waited)
179 }
180}
181
182func TestGuardReserveStillRefusesASpentBudget(t *testing.T) {
183 g := NewGuard(t.TempDir())
184
185 for i := 0; i < budgets["uptime"].perHour; i++ {
186 if err := g.Reserve(t.Context(), "uptime"); err != nil {
187 t.Fatalf("refused at call %d: %v", i+1, err)
188 }
189 }
190 if err := g.Reserve(t.Context(), "uptime"); err == nil {
191 t.Error("reserve waited on a spent budget instead of refusing")
192 }
193}
194
195func TestGuardReserveHonoursContextCancellation(t *testing.T) {
196 g := NewGuard(t.TempDir())
197 if err := g.Reserve(t.Context(), "yahoo"); err != nil {
198 t.Fatal(err)
199 }
200
201 ctx, cancel := context.WithCancel(t.Context())
202 cancel()
203
204 if err := g.Reserve(ctx, "yahoo"); err == nil {
205 t.Error("reserve ignored a cancelled context and waited anyway")
206 }
207}