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 "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}