retry.bend
bend-kit-hairpin@0.3.0.0 · 249 lines · 0 definitions
1# Retry policy: a shared attempt budget, an operation deadline, bounded backoff, and a circuit breaker.
2# All times are ms on one monotonic clock. The caller reads the clock; these defs are pure.
3import Base
4
5# left: attempts the whole operation may still start, the first one included. Nested layers share one Budget.
6# deadline: no attempt starts at or after this time. None: no deadline.
7type Budget is Data:
8 Budget{left: Nat, deadline: Maybe<&2, Nat>}
9
10type Deny is Data:
11 DenyLate{}
12 DenySpent{}
13 DenyOpen{}
14
15type Gate is Data:
16 GateGo{}
17 GateNo{why: Deny}
18
19# threshold: failures in a row, each inside window ms of the newest, that open the circuit. cooldown: ms it stays open.
20# probes: attempts allowed at once while half-open.
21type Circuit is Data:
22 Circuit{threshold: Nat, window: Nat, cooldown: Nat, probes: Nat}
23
24# Closed: the failures since the last success that are still inside the window, newest first.
25# Open: no attempt before until. Half: probes in flight.
26type State is Data:
27 CircuitClosed{fails: List<&2, Nat>}
28 CircuitOpen{until: Nat}
29 CircuitHalf{busy: Nat}
30
31type Breaker is Data:
32 BreakerOff{}
33 BreakerOn{circuit: Circuit, state: State}
34
35# Settled: a success, or a failure that another try would repeat. Unsafe: a transient failure of a
36# request that must not repeat. Transient: another try can help; after is the server's wait in ms.
37type Outcome is Data:
38 Settled{}
39 Unsafe{}
40 Transient{after: Maybe<&2, Nat>}
41
42# attempts: tries in one layer, the first one included. base: the first backoff in ms. cap: the longest wait in ms.
43# deadline: ms from the start of the operation to its deadline. None: no deadline.
44type Policy is Data:
45 Policy{attempts: Nat, base: Nat, cap: U32, deadline: Maybe<&2, Nat>}
46
47type Plan is Data:
48 PlanStop{}
49 PlanWait{ms: Nat}
50
51# ---- budget
52
53def Budget.new(+attempts: Nat, +now: Nat, ms: Maybe<&2, Nat>) -> Budget:
54 match ms:
55 case None{}:
56 Budget{attempts, None{}}
57 case Some{m}:
58 Budget{attempts, Some{Nat.add(now, m)}}
59
60def Budget.late(+now: Nat, d: Maybe<&2, Nat>) -> Bool:
61 match d:
62 case None{}:
63 False{}
64 case Some{t}:
65 Nat.is_le(t, now)
66
67# ---- circuit
68
69# The failure times still inside the window: now < t + window. Newest first, so the first old time ends the list.
70def prune(+now: Nat, +window: Nat, fs: List<&2, Nat>) -> List<&2, Nat>:
71 match fs:
72 case Nil{}:
73 Nil{}
74 case Con{+t, rest}:
75 Bool.pick(List<&2, Nat>, Nat.is_lt(now, Nat.add(t, window)), Con{t, prune(now, window, rest)}, Nil{})
76
77def Circuit.probes(c: Circuit) -> Nat:
78 Circuit{t, w, cd, p} = c
79 p
80
81def Circuit.cooldown(c: Circuit) -> Nat:
82 Circuit{t, w, cd, p} = c
83 cd
84
85def half.admit(go: Bool, +k: Nat) -> State & Bool:
86 match go:
87 case True{}:
88 (CircuitHalf{1n+k}, True{})
89 case False{}:
90 (CircuitHalf{k}, False{})
91
92def open.admit(early: Bool, +p: Nat, +u: Nat) -> State & Bool:
93 match early:
94 case True{}:
95 (CircuitOpen{u}, False{})
96 case False{}:
97 half.admit(Nat.is_lt(0n, p), 0n)
98
99# Closed admits. Open admits nothing before until; at or after until it turns half-open and admits a probe.
100# Half-open admits while fewer than probes are in flight.
101def State.admit(+c: Circuit, s: State, +now: Nat) -> State & Bool:
102 match s:
103 case CircuitClosed{fs}:
104 (CircuitClosed{fs}, True{})
105 case CircuitOpen{+u}:
106 open.admit(Nat.is_lt(now, u), Circuit.probes(c), u)
107 case CircuitHalf{+k}:
108 half.admit(Nat.is_lt(k, Circuit.probes(c)), k)
109
110def closed.trip(under: Bool, fs: List<&2, Nat>, +until: Nat) -> State:
111 match under:
112 case True{}:
113 CircuitClosed{fs}
114 case False{}:
115 CircuitOpen{until}
116
117def closed.fail(+c: Circuit, fs: List<&2, Nat>, +now: Nat) -> State:
118 Circuit{+t, +w, +cd, p} = c
119 +kept = {Con{now, prune(now, w, fs)} : List<&2, Nat>}
120 closed.trip(Nat.is_lt(List.length(&2, Nat, kept), t), kept, Nat.add(now, cd))
121
122# A closed success clears the failures. A closed failure opens the circuit when the window then holds threshold
123# failures. A half-open success closes it; a half-open failure opens it again for cooldown. An attempt that ends
124# while open changes nothing.
125def State.done(+c: Circuit, s: State, +now: Nat, failed: Bool) -> State:
126 match s:
127 case CircuitClosed{fs}:
128 match failed:
129 case False{}:
130 CircuitClosed{Nil{}}
131 case True{}:
132 closed.fail(c, fs, now)
133 case CircuitOpen{u}:
134 CircuitOpen{u}
135 case CircuitHalf{k}:
136 match failed:
137 case False{}:
138 CircuitClosed{Nil{}}
139 case True{}:
140 CircuitOpen{Nat.add(now, Circuit.cooldown(c))}
141
142def Breaker.new(c: Circuit) -> Breaker:
143 BreakerOn{c, CircuitClosed{Nil{}}}
144
145def Breaker.on(+c: Circuit, x: State & Bool) -> Breaker & Bool:
146 (s, ok) = x
147 (BreakerOn{c, s}, ok)
148
149def Breaker.admit(br: Breaker, +now: Nat) -> Breaker & Bool:
150 match br:
151 case BreakerOff{}:
152 (BreakerOff{}, True{})
153 case BreakerOn{+c, s}:
154 Breaker.on(c, State.admit(c, s, now))
155
156def Breaker.done(br: Breaker, +now: Nat, failed: Bool) -> Breaker:
157 match br:
158 case BreakerOff{}:
159 BreakerOff{}
160 case BreakerOn{+c, s}:
161 BreakerOn{c, State.done(c, s, now, failed)}
162
163# ---- admission
164
165def admit.ok(ok: Bool, br: Breaker, +p: Nat, d: Maybe<&2, Nat>) -> Breaker & Budget & Gate:
166 match ok:
167 case True{}:
168 (br, Budget{p, d}, GateGo{})
169 case False{}:
170 (br, Budget{1n+p, d}, GateNo{DenyOpen{}})
171
172def admit.breaker(x: Breaker & Bool, +p: Nat, d: Maybe<&2, Nat>) -> Breaker & Budget & Gate:
173 (br, ok) = x
174 admit.ok(ok, br, p, d)
175
176def admit.late(late: Bool, br: Breaker, left: Nat, d: Maybe<&2, Nat>, +now: Nat) -> Breaker & Budget & Gate:
177 match late:
178 case True{}:
179 (br, Budget{left, d}, GateNo{DenyLate{}})
180 case False{}:
181 match left:
182 case 0n:
183 (br, Budget{0n, d}, GateNo{DenySpent{}})
184 case 1n+p:
185 admit.breaker(Breaker.admit(br, now), p, d)
186
187# Ask to start one attempt at now. The deadline is checked first, then the budget, then the breaker.
188# Only GateGo takes one attempt from the budget.
189def admit(br: Breaker, b: Budget, +now: Nat) -> Breaker & Budget & Gate:
190 Budget{left, +d} = b
191 admit.late(Budget.late(now, d), br, left, d, now)
192
193# ---- backoff
194
195# min(cap, base * 2^n), doubled one step at a time, so no step passes cap.
196def delay(n: Nat, +base: Nat, +cap: Nat) -> Nat:
197 match n:
198 case 0n:
199 Nat.min(base, cap)
200 case 1n+p:
201 +d = delay(p, base, cap)
202 Nat.min(Nat.add(d, d), cap)
203
204# Equal jitter: d / 2, plus r mod (d / 2 + 1). The wait is in [d / 2, d] for every r.
205def jitter(+d: Nat, +r: Nat) -> Nat:
206 +h = Nat.div(d, 2n)
207 Nat.add(h, Nat.mod(r, 1n+h))
208
209# The wait before retry n (0 first): the server's wait when it gave one, else the jittered backoff. At most cap.
210def wait(after: Maybe<&2, Nat>, p: Policy, +n: Nat, +r: Nat) -> Nat:
211 match after:
212 case None{}:
213 Policy{a, +base, +cap, d} = p
214 jitter(delay(n, base, U32.to_nat(cap)), r)
215 case Some{+ms}:
216 Policy{a, base, +cap, d} = p
217 Nat.min(ms, U32.to_nat(cap))
218
219def plan.left(left: Nat, +w: Nat, d: Maybe<&2, Nat>, +now: Nat) -> Plan:
220 match left:
221 case 0n:
222 PlanStop{}
223 case 1n+k:
224 Bool.pick(Plan, Budget.late(Nat.add(now, w), d), PlanStop{}, PlanWait{w})
225
226def plan.at(+w: Nat, b: Budget, +now: Nat) -> Plan:
227 Budget{left, d} = b
228 plan.left(left, w, d, now)
229
230# After an attempt ends at now: stop, or wait ms and then ask admit again. Settled and Unsafe always stop.
231# A transient outcome stops when the budget is spent or the wait would reach the deadline.
232def plan(o: Outcome, p: Policy, b: Budget, +n: Nat, +now: Nat, +r: Nat) -> Plan:
233 match o:
234 case Settled{}:
235 PlanStop{}
236 case Unsafe{}:
237 PlanStop{}
238 case Transient{after}:
239 plan.at(wait(after, p, n, r), b, now)
240
241# Unsafe and Transient count as failures for the breaker.
242def failed(o: Outcome) -> Bool:
243 match o:
244 case Settled{}:
245 False{}
246 case Unsafe{}:
247 True{}
248 case Transient{after}:
249 True{}