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