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

3.8 KB · 150 lines · Go Raw History
  1package main
  2
  3import (
  4	"context"
  5	"sync"
  6	"time"
  7)
  8
  9// Budget is a self-imposed search rate limit.
 10//
 11// DuckDuckGo publishes no threshold. What is documented is community measured:
 12// stay under 30 requests a minute from one address, detection fires well before
 13// that, and roughly one request a second is the sustainable pace. So this
 14// budgets conservatively against a number DDG never stated, and the point is
 15// less to avoid a 202 than to make the ceiling visible, since hitting one with
 16// no warning reads as the tool being broken.
 17//
 18// A question spends one to three searches, so the window is counted in searches
 19// and reported in both searches and whole questions.
 20const (
 21	budgetWindow  = 10 * time.Minute
 22	budgetMax     = 24
 23	perQuestion   = 3
 24	cooldownAfter = 90 * time.Second
 25
 26	// One search a second is the pace community reports say DuckDuckGo
 27	// tolerates. It is enforced across the whole process rather than within one
 28	// question, because three questions asked back to back are the same burst
 29	// to DDG as one question firing three queries.
 30	minGap = 1100 * time.Millisecond
 31)
 32
 33type Budget struct {
 34	mu      sync.Mutex
 35	spent   []time.Time
 36	limited time.Time // when a 202 was last seen
 37
 38	// pace serialises searches so the gap between any two is at least minGap,
 39	// whoever asked for them.
 40	pace     sync.Mutex
 41	lastFire time.Time
 42}
 43
 44// Wait blocks until another search may go out, and reports how long it waited
 45// so the page can say why it is taking a moment rather than looking stuck.
 46func (b *Budget) Wait(ctx context.Context) time.Duration {
 47	b.pace.Lock()
 48	defer b.pace.Unlock()
 49
 50	wait := minGap - time.Since(b.lastFire)
 51	if cooling, left := b.Cooling(); cooling && left > wait {
 52		wait = left
 53	}
 54	if wait <= 0 {
 55		b.lastFire = time.Now()
 56		return 0
 57	}
 58	select {
 59	case <-ctx.Done():
 60	case <-time.After(wait):
 61	}
 62	b.lastFire = time.Now()
 63	return wait
 64}
 65
 66func NewBudget() *Budget { return &Budget{} }
 67
 68// BudgetState is what the UI shows.
 69type BudgetState struct {
 70	Used      int    `json:"used"`
 71	Max       int    `json:"max"`
 72	Left      int    `json:"left"`
 73	Questions int    `json:"questions"`
 74	Cooling   bool   `json:"cooling"`
 75	ResetIn   int    `json:"resetIn"`
 76	Note      string `json:"note"`
 77}
 78
 79func (b *Budget) prune(now time.Time) {
 80	cut := now.Add(-budgetWindow)
 81	keep := b.spent[:0]
 82	for _, t := range b.spent {
 83		if t.After(cut) {
 84			keep = append(keep, t)
 85		}
 86	}
 87	b.spent = keep
 88}
 89
 90// Spend records one search.
 91func (b *Budget) Spend() {
 92	b.mu.Lock()
 93	defer b.mu.Unlock()
 94	now := time.Now()
 95	b.prune(now)
 96	b.spent = append(b.spent, now)
 97}
 98
 99// Limited records that DuckDuckGo answered 202, which starts a cooldown.
100func (b *Budget) Limited() {
101	b.mu.Lock()
102	defer b.mu.Unlock()
103	b.limited = time.Now()
104}
105
106// Cooling reports whether a recent 202 means searching should wait.
107func (b *Budget) Cooling() (bool, time.Duration) {
108	b.mu.Lock()
109	defer b.mu.Unlock()
110	if b.limited.IsZero() {
111		return false, 0
112	}
113	left := cooldownAfter - time.Since(b.limited)
114	if left <= 0 {
115		return false, 0
116	}
117	return true, left
118}
119
120func (b *Budget) State() BudgetState {
121	b.mu.Lock()
122	defer b.mu.Unlock()
123	now := time.Now()
124	b.prune(now)
125
126	st := BudgetState{Used: len(b.spent), Max: budgetMax}
127	st.Left = budgetMax - st.Used
128	if st.Left < 0 {
129		st.Left = 0
130	}
131	st.Questions = st.Left / perQuestion
132
133	if !b.limited.IsZero() {
134		if left := cooldownAfter - time.Since(b.limited); left > 0 {
135			st.Cooling = true
136			st.ResetIn = int(left.Seconds()) + 1
137			st.Note = "the search source asked us to slow down, waiting it out"
138			return st
139		}
140	}
141	// When the window is full, the oldest search leaving it is when room opens.
142	if st.Left == 0 && len(b.spent) > 0 {
143		st.ResetIn = int(budgetWindow-time.Since(b.spent[0])) + 1
144		st.Note = "search budget spent, room opens as the window rolls"
145	} else if st.Questions <= 1 {
146		st.Note = "close to the search budget"
147	}
148	return st
149}