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