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

10.2 KB · 372 lines · Go Raw History
  1package main
  2
  3import (
  4	"context"
  5	"encoding/json"
  6	"errors"
  7	"fmt"
  8	"log/slog"
  9	"os"
 10	"path/filepath"
 11	"sync"
 12	"time"
 13)
 14
 15// Every upstream this site reads is free, keyless and someone else's. A ban on
 16// one of them is not something a retry fixes and it lands on Isaac rather than
 17// on a reviewer, so each endpoint is fenced: a hard budget per hour, a pause
 18// between consecutive calls, and a breaker that opens on the responses that
 19// mean stop asking.
 20//
 21// The state is written to disk because a breaker that resets on restart is not
 22// a breaker. A restart loop against an endpoint that just returned 429 is
 23// exactly the case this is here to prevent, and an in-memory counter would
 24// forget about it every few seconds. It is JSON rather than SQLite because
 25// four counters per endpoint do not need a database, and skipping one keeps
 26// this the only site in the repo with no cgo-free driver, no volume of
 27// consequence and no schema to migrate.
 28
 29var errGuardOpen = errors.New("circuit open")
 30
 31// Budgets are per rolling hour and are ceilings to fail against, not targets.
 32// The pollers ask for far less: markets at 30s is 120/hr, and everything else
 33// is minutes apart.
 34type budget struct {
 35	perHour int
 36	pace    time.Duration
 37}
 38
 39var budgets = map[string]budget{
 40	"yahoo":     {perHour: 900, pace: 2 * time.Second},
 41	"algolia":   {perHour: 120, pace: 2 * time.Second},
 42	"lobsters":  {perHour: 120, pace: 2 * time.Second},
 43	"openmeteo": {perHour: 120, pace: 2 * time.Second},
 44	"npr":       {perHour: 120, pace: 2 * time.Second},
 45	"bbc":       {perHour: 60, pace: 2 * time.Second},
 46	"nasdaq":    {perHour: 120, pace: 2 * time.Second},
 47	"nws":       {perHour: 120, pace: 2 * time.Second},
 48	"pollen":    {perHour: 60, pace: 2 * time.Second},
 49	"steam":     {perHour: 240, pace: time.Second},
 50	"justwatch": {perHour: 60, pace: 2 * time.Second},
 51	// No pacing on these two. They go to Isaac's own machine rather than to a
 52	// stranger's endpoint, and the strip fires every probe at once, so a
 53	// pause between them would only refuse all but the first.
 54	"uptime":  {perHour: 900},
 55	"logging": {perHour: 900},
 56}
 57
 58const (
 59	// Trip after this many consecutive failures. A single timeout on a free
 60	// endpoint is normal and should not stop the panel.
 61	failsToTrip = 4
 62
 63	minBackoff = 30 * time.Second
 64	maxBackoff = 30 * time.Minute
 65)
 66
 67type guardEntry struct {
 68	WindowStart time.Time     `json:"window_start"`
 69	Count       int           `json:"count"`
 70	Fails       int           `json:"fails"`
 71	LastCall    time.Time     `json:"last_call"`
 72	OpenUntil   time.Time     `json:"open_until"`
 73	Backoff     time.Duration `json:"backoff"`
 74}
 75
 76// Guard is safe for concurrent use. Every poller shares one.
 77type Guard struct {
 78	path string
 79
 80	mu      sync.Mutex
 81	entries map[string]*guardEntry
 82	dirty   bool
 83}
 84
 85func NewGuard(dataDir string) *Guard {
 86	g := &Guard{
 87		path:    filepath.Join(dataDir, "guard.json"),
 88		entries: map[string]*guardEntry{},
 89	}
 90
 91	b, err := os.ReadFile(g.path)
 92	if err != nil {
 93		// A missing file is the first boot. Anything else is worth saying out
 94		// loud, since it means the breaker starts closed against an endpoint
 95		// that may have been the reason for the restart.
 96		if !errors.Is(err, os.ErrNotExist) {
 97			slog.Warn("guard state unreadable, starting closed",
 98				slog.String("component", "guard"), slog.Any("err", err))
 99		}
100		return g
101	}
102	if err := json.Unmarshal(b, &g.entries); err != nil {
103		slog.Warn("guard state unparseable, starting closed",
104			slog.String("component", "guard"), slog.Any("err", err))
105		g.entries = map[string]*guardEntry{}
106	}
107	return g
108}
109
110func (g *Guard) entry(name string) *guardEntry {
111	e, ok := g.entries[name]
112	if !ok {
113		e = &guardEntry{}
114		g.entries[name] = e
115	}
116	return e
117}
118
119// Allow reports whether a call to name may go out now, and reserves a slot in
120// the budget when it may. A caller that is refused must not make the request.
121//
122// Pacing is reported as a refusal here, which is right for a probe that has
123// something better to do than queue. Anything on a timer should call Reserve
124// instead: three pollers sharing the Yahoo endpoint all fired at boot, two were
125// refused for being 1.6 seconds early, and because their retry interval is an
126// hour and six hours the panels they fill stayed empty for the rest of the day.
127func (g *Guard) Allow(name string) error {
128	wait, err := g.tryReserve(name)
129	if err != nil {
130		return err
131	}
132	if wait > 0 {
133		return fmt.Errorf("%s: paced, %s early", name, wait.Round(time.Millisecond))
134	}
135	return nil
136}
137
138// Reserve waits out the pace and then reserves a slot. An open breaker and a
139// spent budget still refuse outright, because neither is fixed by waiting a
140// moment and a caller that queued on them would pile up behind a dead endpoint.
141func (g *Guard) Reserve(ctx context.Context, name string) error {
142	for {
143		wait, err := g.tryReserve(name)
144		if err != nil {
145			return err
146		}
147		if wait == 0 {
148			return nil
149		}
150
151		select {
152		case <-ctx.Done():
153			return ctx.Err()
154		case <-time.After(wait):
155		}
156	}
157}
158
159// tryReserve returns how long the caller has to wait for the pace, or an error
160// for the two conditions that waiting does not help. A zero wait with no error
161// means the slot is taken.
162func (g *Guard) tryReserve(name string) (time.Duration, error) {
163	now := time.Now()
164
165	g.mu.Lock()
166	defer g.mu.Unlock()
167
168	e := g.entry(name)
169
170	if now.Before(e.OpenUntil) {
171		return 0, fmt.Errorf("%s: %w for another %s", name, errGuardOpen, e.OpenUntil.Sub(now).Round(time.Second))
172	}
173
174	b, ok := budgets[name]
175	if !ok {
176		return 0, fmt.Errorf("%s: no budget defined", name)
177	}
178
179	if now.Sub(e.WindowStart) >= time.Hour {
180		e.WindowStart = now
181		e.Count = 0
182	}
183	if e.Count >= b.perHour {
184		return 0, fmt.Errorf("%s: hourly budget of %d spent", name, b.perHour)
185	}
186	if since := now.Sub(e.LastCall); since < b.pace {
187		return b.pace - since, nil
188	}
189
190	e.Count++
191	e.LastCall = now
192	g.dirty = true
193	return 0, nil
194}
195
196// Succeed closes the breaker and clears the backoff.
197func (g *Guard) Succeed(name string) {
198	g.mu.Lock()
199	defer g.mu.Unlock()
200
201	e := g.entry(name)
202	if e.Fails != 0 || e.Backoff != 0 {
203		e.Fails = 0
204		e.Backoff = 0
205		g.dirty = true
206	}
207}
208
209// Fail records an unsuccessful call. status is the HTTP status where there was
210// one and 0 for a transport error. 429 and 503 open the breaker at once,
211// because they are the endpoint saying so rather than the network being bad.
212func (g *Guard) Fail(name string, status int, retryAfter time.Duration) {
213	g.mu.Lock()
214	defer g.mu.Unlock()
215
216	e := g.entry(name)
217	e.Fails++
218	g.dirty = true
219
220	immediate := status == 429 || status == 503
221	if !immediate && e.Fails < failsToTrip {
222		return
223	}
224
225	if e.Backoff == 0 {
226		e.Backoff = minBackoff
227	} else {
228		e.Backoff *= 2
229	}
230	if e.Backoff > maxBackoff {
231		e.Backoff = maxBackoff
232	}
233	// An explicit Retry-After wins over the doubling, both ways: the endpoint
234	// knows when it wants us back and guessing shorter is how a soft limit
235	// becomes a hard one.
236	wait := e.Backoff
237	if retryAfter > wait {
238		wait = retryAfter
239	}
240
241	e.OpenUntil = time.Now().Add(wait)
242	slog.Warn("upstream breaker opened",
243		slog.String("component", "guard"),
244		slog.String("endpoint", name),
245		slog.Int("status", status),
246		slog.String("for", wait.String()))
247}
248
249// Flush writes the state out when it has changed. Called on a timer rather
250// than on every call so a 30 second poll does not mean a write per tick.
251func (g *Guard) Flush() {
252	g.mu.Lock()
253	if !g.dirty {
254		g.mu.Unlock()
255		return
256	}
257	b, err := json.Marshal(g.entries)
258	g.dirty = false
259	g.mu.Unlock()
260
261	if err != nil {
262		slog.Warn("guard state unmarshalable", slog.String("component", "guard"), slog.Any("err", err))
263		return
264	}
265	// Rename onto the real path so a crash mid-write leaves the old state
266	// rather than a truncated file the next boot would refuse to parse.
267	tmp := g.path + ".tmp"
268	if err := os.WriteFile(tmp, b, 0o644); err != nil {
269		slog.Warn("guard state unwritable", slog.String("component", "guard"), slog.Any("err", err))
270		return
271	}
272	if err := os.Rename(tmp, g.path); err != nil {
273		slog.Warn("guard state not renamed", slog.String("component", "guard"), slog.Any("err", err))
274	}
275}
276
277// Feed is one upstream as the SIGNAL panel shows it.
278type Feed struct {
279	Name  string `json:"name"`
280	State string `json:"state"`
281	Age   string `json:"age"`
282
283	// Calls taken out of this hour's budget, and the budget itself, so the
284	// panel answers whether anything here is being hammered rather than
285	// leaving it to be worked out from the poll intervals in the source.
286	Used   int `json:"used"`
287	Budget int `json:"budget"`
288	Load   int `json:"load"`
289}
290
291// Feeds reports every upstream this site reads, in a fixed order so the panel
292// does not reshuffle between polls the way a map range would.
293var feedOrder = []struct{ key, label string }{
294	{"yahoo", "YAHOO"},
295	{"algolia", "HN"},
296	{"lobsters", "LOBSTERS"},
297	{"npr", "NPR"},
298	{"bbc", "BBC"},
299	{"nasdaq", "NASDAQ"},
300	{"openmeteo", "OPEN-METEO"},
301	{"nws", "NWS"},
302	{"pollen", "POLLEN"},
303	{"steam", "STEAM"},
304	{"justwatch", "JUSTWATCH"},
305	{"logging", "LOGGING"},
306}
307
308func (g *Guard) Feeds(now time.Time) []Feed {
309	order := feedOrder
310
311	g.mu.Lock()
312	defer g.mu.Unlock()
313
314	feeds := make([]Feed, 0, len(order))
315	for _, o := range order {
316		f := Feed{Name: o.label, State: "idle", Age: "--"}
317		if b, ok := budgets[o.key]; ok {
318			f.Budget = b.perHour
319		}
320		if e, ok := g.entries[o.key]; ok {
321			// The window rolls, so a count from a window that has already
322			// expired is not this hour's usage.
323			if now.Sub(e.WindowStart) < time.Hour {
324				f.Used = e.Count
325				if f.Budget > 0 {
326					f.Load = f.Used * 100 / f.Budget
327				}
328			}
329			switch {
330			case now.Before(e.OpenUntil):
331				f.State = "open"
332			case e.Fails > 0:
333				f.State = "degraded"
334			default:
335				f.State = "ok"
336			}
337			if !e.LastCall.IsZero() {
338				f.Age = shortAge(now.Sub(e.LastCall))
339			}
340		}
341		feeds = append(feeds, f)
342	}
343	return feeds
344}
345
346func shortAge(d time.Duration) string {
347	switch {
348	case d < time.Minute:
349		return fmt.Sprintf("%ds", int(d.Seconds()))
350	case d < time.Hour:
351		return fmt.Sprintf("%dm", int(d.Minutes()))
352	default:
353		return fmt.Sprintf("%dh", int(d.Hours()))
354	}
355}
356
357// Status is what the footer shows: which endpoints are currently shut off.
358func (g *Guard) Status() []string {
359	now := time.Now()
360
361	g.mu.Lock()
362	defer g.mu.Unlock()
363
364	var open []string
365	for name, e := range g.entries {
366		if now.Before(e.OpenUntil) {
367			open = append(open, name)
368		}
369	}
370	return open
371}