hairpin.bend
bend-kit-hairpin@0.11.0.0 · 511 lines · 0 definitions
1# Hairpin: an HTTP client with a base URL, default headers, a pool, cookies, mTLS, redirects, and proven retries. Source: https://github.com/paymog/bend-kit/tree/main/hairpin
2import Base
3import bend-kit-http@0.36.0.0/http.bend as Http
4import bend-kit-time@0.2.0.0/time.bend as Time
5import bend-kit-url@0.4.1.0/url.bend as Url
6import bend-kit-json@0.6.1.1/json.bend as Json
7import bend-kit-bytes@0.3.2.0/bytes.bend as Bytes
8import bend-kit-retry@0.2.0.0/retry.bend as Retry
9
10# base: "" for none. headers: lowercase names. ms: the step timeout. policy: attempts, backoff, and deadline of each request.
11# ca: a PEM CA file to trust; "" trusts the default store.
12type Opts is Data:
13 Opts{base: String, headers: Map<&2, List<&2, String>>, ms: U32, mode: Http.Mode, policy: Retry.Policy, cert: Maybe<&2, Http.Cert>, ca: String}
14
15# breaker: the circuit breaker of every request on this client.
16type Client is Type:
17 Client{opts: Opts, pool: Http.Pool, jar: Maybe<&2, Http.Jar>, breaker: Retry.Breaker}
18
19# ErrHttp: the last attempt failed. ErrDenied: no attempt started, for why.
20type Err is Data:
21 ErrHttp{error: Http.Err}
22 ErrDenied{why: Retry.Deny}
23
24# One attempt, the first included; backoff from 500 ms, doubled to at most 30 s; no deadline.
25def policy() -> Retry.Policy:
26 Retry.Policy{1n, 500n, 30000, None{}}
27
28# A pool of up to cap idle sockets per origin, no base, no default headers, 30 s steps, follow redirects,
29# no retries, and no circuit breaker.
30def new.with(+cap: U32) -> Client:
31 Client{Opts{"", Http.empty(), 30000, Http.ModeFollow{}, policy(), None{}, ""}, Http.pool.new.with(cap), None{}, Retry.BreakerOff{}}
32
33def new() -> Client:
34 new.with(8)
35
36def base(c: Client, url: String) -> Client:
37 Client{Opts{b, h, ms, mode, p, cert, ca}, pool, j, br} = c
38 Client{Opts{url, h, ms, mode, p, cert, ca}, pool, j, br}
39
40# A default header. It replaces an earlier default of the same name; a request header of that name replaces it.
41def header(c: Client, k: String, v: String) -> Client:
42 Client{Opts{b, h, ms, mode, p, cert, ca}, pool, j, br} = c
43 Client{Opts{b, Http.set(h, String.to_lower(k), v), ms, mode, p, cert, ca}, pool, j, br}
44
45def timeout(c: Client, ms: U32) -> Client:
46 Client{Opts{b, h, old, mode, p, cert, ca}, pool, j, br} = c
47 Client{Opts{b, h, ms, mode, p, cert, ca}, pool, j, br}
48
49def redirect(c: Client, mode: Http.Mode) -> Client:
50 Client{Opts{b, h, ms, old, p, cert, ca}, pool, j, br} = c
51 Client{Opts{b, h, ms, mode, p, cert, ca}, pool, j, br}
52
53# Up to n retries after the first attempt: n + 1 attempts in all.
54def retry(c: Client, n: U32) -> Client:
55 Client{Opts{b, h, ms, mode, Retry.Policy{old, base, cap, d}, cert, ca}, pool, j, br} = c
56 Client{Opts{b, h, ms, mode, Retry.Policy{1n+U32.to_nat(n), base, cap, d}, cert, ca}, pool, j, br}
57
58# No attempt of a request starts ms or more after the request starts.
59def deadline(c: Client, ms: U32) -> Client:
60 Client{Opts{b, h, t, mode, Retry.Policy{a, base, cap, old}, cert, ca}, pool, j, br} = c
61 Client{Opts{b, h, t, mode, Retry.Policy{a, base, cap, Some{U32.to_nat(ms)}}, cert, ca}, pool, j, br}
62
63# A circuit breaker in front of every request, starting closed.
64def circuit(c: Client, circ: Retry.Circuit) -> Client:
65 Client{o, pool, j, old} = c
66 Client{o, pool, j, Retry.Breaker.new(circ)}
67
68# The breaker, for inspection.
69def breaker.of(c: Client) -> Client & Retry.Breaker:
70 Client{o, pool, j, +br} = c
71 (Client{o, pool, j, br}, br)
72
73# Paths to a PEM client chain and its private key, presented to every HTTPS origin.
74def cert(c: Client, chain: String, key: String) -> Client:
75 Client{Opts{b, h, ms, mode, p, old, ca}, pool, j, br} = c
76 Client{Opts{b, h, ms, mode, p, Some{Http.Cert{chain, key}}, ca}, pool, j, br}
77
78# Path to a PEM CA file, trusted in place of the default store. Like the certificate, it follows same-origin redirects only.
79def ca(c: Client, path: String) -> Client:
80 Client{Opts{b, h, ms, mode, p, cert, old}, pool, j, br} = c
81 Client{Opts{b, h, ms, mode, p, cert, path}, pool, j, br}
82
83# Every request stores and sends cookies through jar. Its clock moves to Time.now() before each try.
84def jar(c: Client, jar: Http.Jar) -> Client:
85 Client{o, pool, old, br} = c
86 Client{o, pool, Some{jar}, br}
87
88# The jar, if the client has one.
89def jar.of(c: Client) -> Client & Maybe<&2, Http.Jar>:
90 Client{o, pool, +j, br} = c
91 (Client{o, pool, j, br}, j)
92
93def close(c: Client) -> IO(Unit):
94 Client{o, pool, j, br} = c
95 Http.pool.close(pool)
96
97def target.pick(none: Bool, b: String, ref: String) -> Maybe<&2, String>:
98 match none:
99 case True{}:
100 Some{ref}
101 case False{}:
102 Http.resolve(b, ref)
103
104# ref against the base (RFC 3986 §5.2); with no base, ref as given. None when the base is not absolute.
105def target(+b: String, ref: String) -> Maybe<&2, String>:
106 target.pick(String.is_empty(b), b, ref)
107
108def target.abs(m: Maybe<&2, String>) -> Maybe<&2, Url.Abs>:
109 match m:
110 case None{}:
111 None{}
112 case Some{s}:
113 Url.absolute(s)
114
115# The defaults under the request headers, names lowercased; a request header replaces a default of its name.
116# Host, Connection, Content-Length, and Transfer-Encoding are the client's own, so both sides drop them.
117def merge(defaults: Map<&2, List<&2, String>>, headers: Map<&2, List<&2, String>>) -> Map<&2, List<&2, String>>:
118 Http.req.put(Map.to_list(&2, List<&2, String>, headers), Http.req.put(Map.to_list(&2, List<&2, String>, defaults), Http.empty()))
119
120def jar.now(j: Maybe<&2, Http.Jar>) -> IO(Maybe<&2, Http.Jar>):
121 match j:
122 case None{}:
123 IO.pure(Maybe<&2, Http.Jar>, None{})
124 case Some{jar}:
125 do IO<Maybe<&2, Http.Jar>>:
126 now : Time.Instant <- Time.now()
127 return Some{Http.jar.at(jar, now)}
128
129def once.final(+enc: String, x: Http.Pool & Result<&1, &1, Http.Err, Http.Res> & Maybe<&2, Http.Jar>) ->
130 IO(Http.Pool & Maybe<&2, Http.Jar> & Result<&1, &1, Http.Err, Http.Res>):
131 (p, r, j) = x
132 do IO<Http.Pool & Maybe<&2, Http.Jar> & Result<&1, &1, Http.Err, Http.Res>>:
133 d : Result<&1, &1, Http.Err, Http.Res> <- Http.fetch.final(enc, r)
134 return (p, j, d)
135
136# One try: the redirect loop on the pool, with the jar, the certificate, and the CA file, then the body decoded.
137def once(+ms: U32, +mode: Http.Mode, +cert: Maybe<&2, Http.Cert>, +ca: String, +enc: String, p: Http.Pool, j: Maybe<&2, Http.Jar>,
138 +method: String, +u: Maybe<&2, Url.Abs>, +headers: Map<&2, List<&2, String>>, body: Bytes.Bytes) ->
139 IO(Http.Pool & Maybe<&2, Http.Jar> & Result<&1, &1, Http.Err, Http.Res>):
140 do IO<Http.Pool & Maybe<&2, Http.Jar> & Result<&1, &1, Http.Err, Http.Res>>:
141 now : Maybe<&2, Http.Jar> <- jar.now(j)
142 x : Http.Pool & Result<&1, &1, Http.Err, Http.Res> & Maybe<&2, Http.Jar> <-
143 Http.pool.hops(mode, ms, Http.Hops{p, Http.fetch.start(method, headers, body, u, enc), cert, now, ca})
144 once.final(enc, x)
145
146def hint.ms(m: Maybe<&2, U32>) -> Maybe<&2, Nat>:
147 match m:
148 case None{}:
149 None{}
150 case Some{ms}:
151 Some{U32.to_nat(ms)}
152
153# A judge's verdict on one attempt. Ok: a success. Caller: the request is at fault (a 4xx), so it stops
154# and the breaker does not count it. Target: the target failed in a way another try would repeat, so it
155# stops and the breaker counts it. Again: another try may help; after is the Retry-After value, "" for none.
156type Verdict is Data:
157 VerdictOk{}
158 VerdictCaller{}
159 VerdictTarget{}
160 VerdictAgain{after: String}
161
162# Ok and Caller do not count against the breaker; Target and Again do.
163def counts(v: Verdict) -> Bool:
164 match v:
165 case VerdictOk{}:
166 False{}
167 case VerdictCaller{}:
168 False{}
169 case VerdictTarget{}:
170 True{}
171 case VerdictAgain{after}:
172 True{}
173
174# Ok is Settled; Caller and Target are Failed. Again is Transient for an idempotent request, else Unsafe.
175def outcome(+idem: Bool, v: Verdict, now: Time.Instant) -> Retry.Outcome:
176 match v:
177 case VerdictOk{}:
178 Retry.Settled{}
179 case VerdictCaller{}:
180 Retry.Failed{}
181 case VerdictTarget{}:
182 Retry.Failed{}
183 case VerdictAgain{+after}:
184 Bool.pick(Retry.Outcome, idem, Retry.Transient{hint.ms(Http.retry.after(after, now))}, Retry.Unsafe{})
185
186def lift(-R: Type, r: Result<&1, &1, Http.Err, R>) -> Result<&1, &1, Err, R>:
187 match r:
188 case Fail{e}:
189 Fail{ErrHttp{e}}
190 case Done{res}:
191 Done{res}
192
193# The live state of one request between attempts. R: a response or a stream. last: the result of the last
194# attempt, None before the first.
195type Step<-R: Type> is Type:
196 Step{pool: Http.Pool, jar: Maybe<&2, Http.Jar>, breaker: Retry.Breaker, budget: Retry.Budget, body: Bytes.Bytes,
197 last: Maybe<&1, Result<&1, &1, Err, R>>, plan: Retry.Plan}
198
199# The clock, read only when a decision depends on it; 0 otherwise, which no such decision reads.
200def clock(need: Bool) -> IO(Nat):
201 match need:
202 case True{}:
203 Time.mono.ms()
204 case False{}:
205 IO.pure(Nat, 0n)
206
207def breaker.on(br: Retry.Breaker) -> Bool:
208 match br:
209 case Retry.BreakerOff{}:
210 False{}
211 case Retry.BreakerOn{c, s}:
212 True{}
213
214def transient(o: Retry.Outcome) -> Bool:
215 match o:
216 case Retry.Transient{after}:
217 True{}
218 case Retry.Settled{}:
219 False{}
220 case Retry.Unsafe{}:
221 False{}
222 case Retry.Failed{}:
223 False{}
224
225def some(m: Maybe<&2, Nat>) -> Bool:
226 match m:
227 case None{}:
228 False{}
229 case Some{t}:
230 True{}
231
232def deadline.some(b: Retry.Budget) -> Bool:
233 Retry.Budget{l, d} = b
234 some(d)
235
236# The wall clock dates a Retry-After, so it is read only for Again.
237def outcome.io(+idem: Bool, v: Verdict) -> IO(Retry.Outcome):
238 match v:
239 case VerdictAgain{after}:
240 do IO<Retry.Outcome>:
241 wall : Time.Instant <- Time.now()
242 return outcome(idem, VerdictAgain{after}, wall)
243 case VerdictOk{}:
244 IO.pure(Retry.Outcome, Retry.Settled{})
245 case VerdictCaller{}:
246 IO.pure(Retry.Outcome, Retry.Failed{})
247 case VerdictTarget{}:
248 IO.pure(Retry.Outcome, Retry.Failed{})
249
250# Jitter for the backoff, drawn only when a retry may follow.
251def jitter.io(need: Bool) -> IO(Nat):
252 match need:
253 case True{}:
254 do IO<Nat>:
255 rnd : Result<&1, &1, U32 & String, U32> <- IO.random_u32()
256 return U32.to_nat(Http.retry.jitter(rnd))
257 case False{}:
258 IO.pure(Nat, 0n)
259
260# hit: the breaker counts this attempt as a failure.
261def settle.at(-R: Type, +now: Nat, +rnd: Nat, +p: Retry.Policy, +n: Nat, hit: Bool, +o: Retry.Outcome, br: Retry.Breaker, +b: Retry.Budget,
262 body: Bytes.Bytes, pool: Http.Pool, jar: Maybe<&2, Http.Jar>, r: Result<&1, &1, Http.Err, R>) -> Step<R>:
263 Step{pool, jar, Retry.Breaker.done(br, now, hit), b, body, Some{lift(R, r)}, Retry.plan(o, p, b, n, now, rnd)}
264
265# The attempt has ended: tell the breaker, and plan the next step.
266def settle.outcome(-R: Type, +p: Retry.Policy, +n: Nat, hit: Bool, +o: Retry.Outcome, +br: Retry.Breaker, b: Retry.Budget, body: Bytes.Bytes,
267 pool: Http.Pool, jar: Maybe<&2, Http.Jar>, r: Result<&1, &1, Http.Err, R>) -> IO(Step<R>):
268 do IO<Step<R>>:
269 now : Nat <- clock(Bool.or(breaker.on(br), transient(o)))
270 rnd : Nat <- jitter.io(transient(o))
271 return settle.at(R, now, rnd, p, n, hit, o, br, b, body, pool, jar, r)
272
273def settle.judged(-R: Type, +p: Retry.Policy, +idem: Bool, +n: Nat, br: Retry.Breaker, b: Retry.Budget, body: Bytes.Bytes,
274 pool: Http.Pool, jar: Maybe<&2, Http.Jar>, j: Result<&1, &1, Http.Err, R> & Verdict) -> IO(Step<R>):
275 (r, +v) = j
276 do IO<Step<R>>:
277 o : Retry.Outcome <- outcome.io(idem, v)
278 settle.outcome(R, p, n, counts(v), o, br, b, body, pool, jar, r)
279
280# A status that Http.retry.judge does not retry: below 400 is Ok, a 4xx is Caller, and the rest is Target.
281def judge.status(+s: U32) -> Verdict:
282 Bool.pick(Verdict, U32.is_lt(s, 400), VerdictOk{}, Bool.pick(Verdict, U32.is_lt(s, 500), VerdictCaller{}, VerdictTarget{}))
283
284def judge.settled(r: Result<&1, &1, Http.Err, Http.Res>) -> Result<&1, &1, Http.Err, Http.Res> & Verdict:
285 match r:
286 case Fail{e}:
287 (Fail{e}, VerdictTarget{})
288 case Done{res}:
289 Http.Res{+s, h, b} = res
290 (Done{Http.Res{s, h, b}}, judge.status(s))
291
292def judge.of(x: Result<&1, &1, Http.Err, Http.Res> & Maybe<&2, String>) -> Result<&1, &1, Http.Err, Http.Res> & Verdict:
293 (r, m) = x
294 match m:
295 case Some{after}:
296 (r, VerdictAgain{after})
297 case None{}:
298 judge.settled(r)
299
300# The default judge: Http.retry.judge, as if the request were idempotent, picks Again; outcome then applies idem.
301def judge(r: Result<&1, &1, Http.Err, Http.Res>) -> Result<&1, &1, Http.Err, Http.Res> & Verdict:
302 judge.of(Http.retry.judge(True{}, r))
303
304def settle(~R: Type, ~judge: Result<&1, &1, Http.Err, R> -> Result<&1, &1, Http.Err, R> & Verdict,
305 +p: Retry.Policy, +idem: Bool, +n: Nat, br: Retry.Breaker, b: Retry.Budget, body: Bytes.Bytes,
306 x: Http.Pool & Maybe<&2, Http.Jar> & Result<&1, &1, Http.Err, R>) -> IO(Step<R>):
307 (pool, jar, r) = x
308 settle.judged(R, p, idem, n, br, b, body, pool, jar, judge(r))
309
310# The inputs of one attempt. u: the target, None when it is not absolute. headers: the merged headers.
311type Call is Data:
312 Call{enc: String, method: String, u: Maybe<&2, Url.Abs>, headers: Map<&2, List<&2, String>>}
313
314# One attempt that reads a whole response: the redirect loop on the pool, with the jar and the certificate.
315def try.res(o: Opts, call: Call, pool: Http.Pool, jar: Maybe<&2, Http.Jar>, body: Bytes.Bytes) ->
316 IO(Http.Pool & Maybe<&2, Http.Jar> & Result<&1, &1, Http.Err, Http.Res>):
317 Opts{base, h, +ms, +mode, p, +cert, +ca} = o
318 Call{+enc, +method, +u, +headers} = call
319 once(ms, mode, cert, ca, enc, pool, jar, method, u, headers, body)
320
321def try.stream.got(pool: Http.Pool, x: Maybe<&2, Http.Jar> & Result<&1, &1, Http.Err, Http.Stream>) ->
322 Http.Pool & Maybe<&2, Http.Jar> & Result<&1, &1, Http.Err, Http.Stream>:
323 (jar, s) = x
324 (pool, jar, s)
325
326# One attempt that opens a stream on its own connection, outside the pool: the redirect loop with the mode,
327# the jar, and the certificate, then the body decoded by enc.
328def try.stream(o: Opts, call: Call, pool: Http.Pool, jar: Maybe<&2, Http.Jar>, body: Bytes.Bytes) ->
329 IO(Http.Pool & Maybe<&2, Http.Jar> & Result<&1, &1, Http.Err, Http.Stream>):
330 Opts{base, h, +ms, mode, p, cert, ca} = o
331 Call{+enc, method, u, headers} = call
332 do IO<Http.Pool & Maybe<&2, Http.Jar> & Result<&1, &1, Http.Err, Http.Stream>>:
333 now : Maybe<&2, Http.Jar> <- jar.now(jar)
334 r : Maybe<&2, Http.Jar> & Result<&1, &1, Http.Err, Http.Stream> <-
335 Http.stream.hops(mode, ms, Http.StreamHops{Http.stream.start(method, Http.accept.enc(headers, enc), body, u), cert, now, ca})
336 x : Maybe<&2, Http.Jar> & Result<&1, &1, Http.Err, Http.Stream> <- Http.stream.init.jar(enc, r)
337 return try.stream.got(pool, x)
338
339def close.res(r: Http.Res) -> IO(Unit):
340 IO.pure(Unit, Unit{})
341
342def close.last(~R: Type, ~close: R -> IO(Unit), last: Maybe<&1, Result<&1, &1, Err, R>>) -> IO(Unit):
343 match last:
344 case None{}:
345 IO.pure(Unit, Unit{})
346 case Some{r}:
347 match r:
348 case Fail{e}:
349 IO.pure(Unit, Unit{})
350 case Done{x}:
351 close(x)
352
353def attempt.run(~R: Type, ~judge: Result<&1, &1, Http.Err, R> -> Result<&1, &1, Http.Err, R> & Verdict,
354 ~try: Opts -> Call -> Http.Pool -> Maybe<&2, Http.Jar> -> Bytes.Bytes -> IO(Http.Pool & Maybe<&2, Http.Jar> & Result<&1, &1, Http.Err, R>),
355 +o: Opts, +call: Call, +idem: Bool, +n: Nat, pool: Http.Pool, jar: Maybe<&2, Http.Jar>, br: Retry.Breaker,
356 b: Retry.Budget, r: Bytes.Bytes & Bytes.Bytes) -> IO(Step<R>):
357 Opts{base, h, ms, mode, +p, cert, ca} = o
358 (body, spare) = r
359 do IO<Step<R>>:
360 x : Http.Pool & Maybe<&2, Http.Jar> & Result<&1, &1, Http.Err, R> <- try(o, call, pool, jar, spare)
361 settle(~R, ~judge, p, idem, n, br, b, body, x)
362
363def denied(-R: Type, why: Retry.Deny, last: Maybe<&1, Result<&1, &1, Err, R>>) -> Maybe<&1, Result<&1, &1, Err, R>>:
364 match last:
365 case None{}:
366 Some{Fail{ErrDenied{why}}}
367 case Some{r}:
368 Some{r}
369
370# A refused stream closes only when the next attempt is admitted, so a denied attempt still returns it.
371def attempt.gate(~R: Type, ~judge: Result<&1, &1, Http.Err, R> -> Result<&1, &1, Http.Err, R> & Verdict,
372 ~try: Opts -> Call -> Http.Pool -> Maybe<&2, Http.Jar> -> Bytes.Bytes -> IO(Http.Pool & Maybe<&2, Http.Jar> & Result<&1, &1, Http.Err, R>),
373 ~close: R -> IO(Unit),
374 x: Retry.Breaker & Retry.Budget & Retry.Gate, +o: Opts, +call: Call, +idem: Bool, +n: Nat, pool: Http.Pool,
375 jar: Maybe<&2, Http.Jar>, body: Bytes.Bytes, last: Maybe<&1, Result<&1, &1, Err, R>>) -> IO(Step<R>):
376 (br, b, g) = x
377 match g:
378 case Retry.GateNo{why}:
379 IO.pure(Step<R>, Step{pool, jar, br, b, body, denied(R, why, last), Retry.PlanStop{}})
380 case Retry.GateGo{}:
381 do IO<Step<R>>:
382 close.last(~R, ~close, last)
383 attempt.run(~R, ~judge, ~try, o, call, idem, n, pool, jar, br, b, Bytes.slice(body, 0, 4294967295))
384
385# Ask the gate; when it admits, make attempt n (0 first) of this layer. With no deadline and no breaker,
386# admission does not read the clock.
387def attempt(~R: Type, ~judge: Result<&1, &1, Http.Err, R> -> Result<&1, &1, Http.Err, R> & Verdict,
388 ~try: Opts -> Call -> Http.Pool -> Maybe<&2, Http.Jar> -> Bytes.Bytes -> IO(Http.Pool & Maybe<&2, Http.Jar> & Result<&1, &1, Http.Err, R>),
389 ~close: R -> IO(Unit),
390 +o: Opts, +call: Call, +idem: Bool, +n: Nat, s: Step<R>) -> IO(Step<R>):
391 Step{pool, jar, +br, +b, body, last, plan} = s
392 do IO<Step<R>>:
393 now : Nat <- clock(Bool.or(breaker.on(br), deadline.some(b)))
394 attempt.gate(~R, ~judge, ~try, ~close, Retry.admit(br, b, now), o, call, idem, n, pool, jar, body, last)
395
396def pause(ms: Nat) -> IO(Unit):
397 match ms:
398 case 0n:
399 IO.pure(Unit, Unit{})
400 case 1n+k:
401 IO.sleep(U32.from_nat(1n+k))
402
403# left: attempts this layer may still make. n: attempts made so far. Each turn follows the plan of the last step.
404def go(~R: Type, ~judge: Result<&1, &1, Http.Err, R> -> Result<&1, &1, Http.Err, R> & Verdict,
405 ~try: Opts -> Call -> Http.Pool -> Maybe<&2, Http.Jar> -> Bytes.Bytes -> IO(Http.Pool & Maybe<&2, Http.Jar> & Result<&1, &1, Http.Err, R>),
406 ~close: R -> IO(Unit),
407 left: Nat, +n: Nat, +o: Opts, +call: Call, +idem: Bool, s: Step<R>) -> IO(Step<R>):
408 match left:
409 case 0n:
410 IO.pure(Step<R>, s)
411 case 1n+rest:
412 Step{pool, jar, br, b, body, last, plan} = s
413 match plan:
414 case Retry.PlanStop{}:
415 IO.pure(Step<R>, Step{pool, jar, br, b, body, last, Retry.PlanStop{}})
416 case Retry.PlanWait{ms}:
417 do IO<Step<R>>:
418 pause(ms)
419 next : Step<R> <- attempt(~R, ~judge, ~try, ~close, o, call, idem, n, Step{pool, jar, br, b, body, last, Retry.PlanStop{}})
420 go(~R, ~judge, ~try, ~close, rest, 1n+n, o, call, idem, next)
421
422def result.of(-R: Type, m: Maybe<&1, Result<&1, &1, Err, R>>) -> Result<&1, &1, Err, R>:
423 match m:
424 case None{}:
425 Fail{ErrDenied{Retry.DenySpent{}}}
426 case Some{r}:
427 r
428
429def request.done(-R: Type, +o: Opts, s: Step<R>) -> Client & Retry.Budget & Result<&1, &1, Err, R>:
430 Step{pool, jar, br, b, body, last, plan} = s
431 (Client{o, pool, jar, br}, b, result.of(R, last))
432
433def attempts(p: Retry.Policy) -> Nat:
434 Retry.Policy{a, base, cap, d} = p
435 a
436
437# The budget of one plain request: the policy's attempts, and its deadline from now. The clock is read only for a deadline.
438def budget(c: Client) -> IO(Client & Retry.Budget):
439 Client{+o, pool, jar, br} = c
440 Opts{base, h, ms, mode, Retry.Policy{+a, bs, cap, +d}, cert, ca} = o
441 do IO<Client & Retry.Budget>:
442 now : Nat <- clock(some(d))
443 return (Client{o, pool, jar, br}, Retry.Budget.new(a, now, d))
444
445def run(~R: Type, ~judge: Result<&1, &1, Http.Err, R> -> Result<&1, &1, Http.Err, R> & Verdict,
446 ~try: Opts -> Call -> Http.Pool -> Maybe<&2, Http.Jar> -> Bytes.Bytes -> IO(Http.Pool & Maybe<&2, Http.Jar> & Result<&1, &1, Http.Err, R>),
447 ~close: R -> IO(Unit),
448 c: Client, b: Retry.Budget, +idem: Bool, enc: String, method: String, url: String,
449 headers: Map<&2, List<&2, String>>, body: Bytes.Bytes) -> IO(Client & Retry.Budget & Result<&1, &1, Err, R>):
450 Client{+o, pool, jar, br} = c
451 Opts{+base, +h, ms, mode, +p, cert, ca} = o
452 +call = {Call{enc, method, target.abs(target(base, url)), merge(h, headers)} : Call}
453 do IO<Client & Retry.Budget & Result<&1, &1, Err, R>>:
454 s : Step<R> <- go(~R, ~judge, ~try, ~close, attempts(p), 0n, o, call, idem, Step{pool, jar, br, b, body, None{}, Retry.PlanWait{0n}})
455 return request.done(R, o, s)
456
457# One request inside a caller's retry layer, with every choice explicit. Every attempt takes from b, the
458# caller's shared budget, and obeys its deadline; the client's own policy caps the attempts of this call.
459# The budget left comes back beside the result: pass it to the next call, so nested layers never reset or
460# multiply it. The client's own deadline does not apply here; b's does.
461# judge: the Verdict on each attempt; Hairpin.judge is the default. idem: the request is safe to repeat, so
462# Again is retried; else the breaker counts it and it never repeats. enc: the Accept-Encoding codings.
463def request.as(~judge: Result<&1, &1, Http.Err, Http.Res> -> Result<&1, &1, Http.Err, Http.Res> & Verdict,
464 c: Client, b: Retry.Budget, +idem: Bool, +enc: String, +method: String, url: String,
465 headers: Map<&2, List<&2, String>>, body: Bytes.Bytes) -> IO(Client & Retry.Budget & Result<&1, &1, Err, Http.Res>):
466 run(~Http.Res, ~judge, ~try.res, ~close.res, c, b, idem, enc, method, url, headers, body)
467
468# A streamed request under the same budget, deadline, backoff, and breaker as request.as. The stream opens its
469# own connection outside the pool. It follows the client's redirect mode, presents its certificate, and sends
470# and stores cookies through its jar, as request.as does. judge reads the status and headers through
471# Http.stream.res. A refused stream closes before the next attempt; the last one comes back open, so the
472# caller can read its body.
473def open.as(~judge: Result<&1, &1, Http.Err, Http.Stream> -> Result<&1, &1, Http.Err, Http.Stream> & Verdict,
474 c: Client, b: Retry.Budget, +idem: Bool, +enc: String, +method: String, url: String,
475 headers: Map<&2, List<&2, String>>, body: Bytes.Bytes) -> IO(Client & Retry.Budget & Result<&1, &1, Err, Http.Stream>):
476 run(~Http.Stream, ~judge, ~try.stream, ~Http.stream.close, c, b, idem, enc, method, url, headers, body)
477
478# request.as with judge, the idempotency of method, and every coding Http decodes.
479def request.in(c: Client, b: Retry.Budget, +method: String, url: String, headers: Map<&2, List<&2, String>>,
480 body: Bytes.Bytes) -> IO(Client & Retry.Budget & Result<&1, &1, Err, Http.Res>):
481 do IO<Client & Retry.Budget & Result<&1, &1, Err, Http.Res>>:
482 enc : String <- Http.codings()
483 request.as(~judge, c, b, Http.pool.idempotent(method), enc, method, url, headers, body)
484
485def request.drop(x: Client & Retry.Budget & Result<&1, &1, Err, Http.Res>) -> Client & Result<&1, &1, Err, Http.Res>:
486 (c, b, r) = x
487 (c, r)
488
489def request.budget(+method: String, url: String, headers: Map<&2, List<&2, String>>, body: Bytes.Bytes,
490 x: Client & Retry.Budget) -> IO(Client & Retry.Budget & Result<&1, &1, Err, Http.Res>):
491 (c, b) = x
492 request.in(c, b, method, url, headers, body)
493
494# url resolves against the base; headers go over the defaults. Redirects follow the client's mode, at most 20.
495# Each request gets a fresh budget from budget(c). Before each attempt the deadline, the
496# budget, and the breaker must admit it. GET, HEAD, OPTIONS, TRACE, PUT, and DELETE try again after a connect
497# error, a timeout, or 408, 429, 500, 502, 503, or 504, as Http.retry.judge says, on the same pool.
498# When the attempts run out, the last result comes back; when none started, ErrDenied says why.
499def request(c: Client, +method: String, url: String, headers: Map<&2, List<&2, String>>, body: Bytes.Bytes) ->
500 IO(Client & Result<&1, &1, Err, Http.Res>):
501 do IO<Client & Result<&1, &1, Err, Http.Res>>:
502 y : Client & Retry.Budget <- budget(c)
503 x : Client & Retry.Budget & Result<&1, &1, Err, Http.Res> <- request.budget(method, url, headers, body, y)
504 return request.drop(x)
505
506def get(c: Client, url: String) -> IO(Client & Result<&1, &1, Err, Http.Res>):
507 request(c, "GET", url, Http.empty(), Bytes.new(0))
508
509# POST v as compact JSON, with content-type: application/json.
510def post.json(c: Client, url: String, v: Json.Val) -> IO(Client & Result<&1, &1, Err, Http.Res>):
511 request(c, "POST", url, Http.set(Http.empty(), "content-type", "application/json"), Json.encode.bytes(v))