repos
/ orchard main

orchard

mirror

Every 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

6.2 KB · 208 lines · Go Raw History
  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}