retry.bend

bend-kit-hairpin@0.2.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{}