1 # Webhook signatures: Standard Webhooks sign and verify, Stripe and GitHub verify, over the raw body. Source: https://github.com/paymog/bend-kit/tree/main/webhooks 2 import Base 3 import bend-kit-crypto@0.2.2.1/crypto.bend as Crypto 4 import bend-kit-time@0.1.2.1/time.bend as Time 5 import bend-kit-int@0.2.0.0/int.bend as Int 6 # By hash, as http imports them: bend-kit-encoding@0.3.0.0, -bytes@0.3.0.0. 7 import 0xcfc8be7b076f41f95c8e118383892d55/encoding.bend as Enc 8 import 0x49814d83de8f70993a43e1002be29ecd/bytes.bend as Bytes 9 import bend-kit-hairpin@0.4.0.0/hairpin.bend as Hairpin 10 import bend-kit-retry@0.1.0.0/retry.bend as Retry 11 import bend-kit-http@0.32.0.0/http.bend as Http 12 13 # Every MAC is HMAC-SHA256 over the body exactly as received, never a re-encoding of parsed JSON. 14 # Headers are Http.Req headers: lowercase names, octet-string values. Each verify hands the body back. 15 # secrets: candidates, tried in turn, for key rotation. Every MAC comparison is Crypto.eq.ct.words. 16 # now is explicit, so a check is deterministic; a handler passes Time.now(). 17 18 type Err is Data: 19 Missing{name: String} 20 BadSecret{} 21 BadId{} 22 BadTimestamp{} 23 TooOld{} 24 TooNew{} 25 NoMatch{} 26 CryptoErr{code: U32, msg: String} 27 28 # ---- headers and text 29 30 def field.head(xs: List<&2, String>) -> Maybe<&2, String>: 31 match xs: 32 case Nil{}: 33 None{} 34 case Con{v, t}: 35 Some{v} 36 37 def field.of(r: Map<&2, List<&2, String>> & List<&2, String>) -> Maybe<&2, String>: 38 (h, xs) = r 39 field.head(xs) 40 41 def field(+h: Map<&2, List<&2, String>>, k: String) -> Maybe<&2, String>: 42 field.of(Map.get(List<&2, String>, Nil{}, h, k)) 43 44 def need(+name: String, m: Maybe<&2, String>) -> Result<&1, &1, Err, String>: 45 match m: 46 case None{}: 47 Fail{Missing{name}} 48 case Some{v}: 49 Done{v} 50 51 # s after the prefix p, or None. 52 def strip(+p: String, +s: String) -> Maybe<&2, String>: 53 Bool.pick(Maybe<&2, String>, String.starts_with(s, p), Some{String.drop(s, String.length(p))}, None{}) 54 55 def push(m: Maybe<&2, String>, rest: List<&2, String>) -> List<&2, String>: 56 match m: 57 case None{}: 58 rest 59 case Some{s}: 60 Con{s, rest} 61 62 def put(m: Map<&2, List<&2, String>>, k: String, v: String) -> Map<&2, List<&2, String>>: 63 Map.set(&2, List<&2, String>, m, k, Con{v, Nil{}}) 64 65 def digits(s: String) -> Bool: 66 match s: 67 case SNil{}: 68 True{} 69 case SCon{Chr{+c}, t}: 70 Bool.and(Bool.and(U32.is_le(48, c), U32.is_le(c, 57)), digits(t)) 71 72 def ts.some(ok: Bool, m: Maybe<&2, Int.I64>) -> Result<&1, &1, Err, Int.I64>: 73 match ok: 74 case False{}: 75 Fail{BadTimestamp{}} 76 case True{}: 77 match m: 78 case None{}: 79 Fail{BadTimestamp{}} 80 case Some{t}: 81 Done{t} 82 83 # Unix seconds: 1 to 18 ASCII digits, no sign, so the parse never overflows. 84 def ts.read(+s: String) -> Result<&1, &1, Err, Int.I64>: 85 +n = String.length(s) 86 ts.some(Bool.and(Bool.and(Nat.is_lt(0n, n), Nat.is_le(n, 18n)), digits(s)), Int.I64.read(s)) 87 88 def window.pick(old: Bool, ahead: Bool) -> Result<&1, &1, Err, Unit>: 89 match old: 90 case True{}: 91 Fail{TooOld{}} 92 case False{}: 93 Bool.pick(Result<&1, &1, Err, Unit>, ahead, Fail{TooNew{}}, Done{Unit{}}) 94 95 # t within tol seconds of now, either way. 96 def window(+tol: U32, now: Time.Instant, t: Int.I64) -> Result<&1, &1, Err, Unit>: 97 match now: 98 case Time.Instant{n, ns}: 99 +d = Int.I64.sub(n, t) 100 +w = Int.I64.from_u32(tol) 101 window.pick(Int.I64.is_gt(d, w), Int.I64.is_lt(d, Int.I64.neg(w))) 102 103 # A webhook id is not empty and has no '.', so id.timestamp.body reads one way. 104 def id.check(+id: String) -> Result<&1, &1, Err, Unit>: 105 Bool.pick(Result<&1, &1, Err, Unit>, Bool.or(String.is_empty(id), String.contains(id, ".")), Fail{BadId{}}, Done{Unit{}}) 106 107 # ---- keys and signatures 108 109 def b64.same(+s: String, r: Bytes.Bytes & Bytes.Bytes) -> Maybe<&1, Bytes.Bytes>: 110 (b, c) = r 111 Bool.pick(Maybe<&1, Bytes.Bytes>, String.eq(Bytes.to_base64(c), s), Some{b}, None{}) 112 113 def b64.canon(+s: String, m: Maybe<&1, Bytes.Bytes>) -> Maybe<&1, Bytes.Bytes>: 114 match m: 115 case None{}: 116 None{} 117 case Some{b}: 118 b64.same(s, Bytes.slice(b, 0, 4294967295)) 119 120 # Padded standard base64, only in its canonical text: Bytes.from_base64 alone accepts nonzero pad bits. 121 def b64(+s: String) -> Maybe<&1, Bytes.Bytes>: 122 b64.canon(s, Bytes.from_base64(s)) 123 124 # '=' up to a multiple of four chars. 125 def pad(+s: String) -> String: 126 +r = (String.length(s) % 4n : Nat) 127 s ++ Bool.pick(String, Nat.is_eq(r, 2n), "==", Bool.pick(String, Nat.is_eq(r, 3n), "=", "")) 128 129 def nonempty.of(b: Bytes.Bytes) -> Maybe<&1, Bytes.Bytes>: 130 Bytes.Bytes{+n, a} = b 131 Bool.pick(Maybe<&1, Bytes.Bytes>, U32.is_eq(n, 0), None{}, Some{Bytes.Bytes{n, a}}) 132 133 def nonempty(m: Maybe<&1, Bytes.Bytes>) -> Maybe<&1, Bytes.Bytes>: 134 match m: 135 case None{}: 136 None{} 137 case Some{b}: 138 nonempty.of(b) 139 140 # A Standard Webhooks secret: whsec_ then base64 of the key, padded or not. The prefix is optional. 141 def key.std(+s: String) -> Maybe<&1, Bytes.Bytes>: 142 nonempty(b64(pad(Bool.pick(String, String.starts_with(s, "whsec_"), String.drop(s, 6n), s)))) 143 144 # std: a Standard Webhooks secret. Otherwise the secret's UTF-8 is the key, as Stripe and GitHub use it. 145 def key(+std: Bool, +s: String) -> Maybe<&1, Bytes.Bytes>: 146 match std: 147 case True{}: 148 key.std(s) 149 case False{}: 150 nonempty(Some{Enc.utf8.encode(s)}) 151 152 # A signature's octets: hex (Stripe, GitHub) or canonical base64 (Standard Webhooks). 153 def decode(+hex: Bool, +s: String) -> Maybe<&1, Bytes.Bytes>: 154 match hex: 155 case True{}: 156 Bytes.from_hex(s) 157 case False{}: 158 b64(s) 159 160 def mac.of(r: Result<&1, &1, U32 & String, U32 & Array>) -> Result<&1, &1, Err, Bytes.Bytes>: 161 match r: 162 case Fail{(c, w)}: 163 Fail{CryptoErr{c, w}} 164 case Done{(n, b)}: 165 Done{Bytes.Bytes{n, b}} 166 167 def mac(k: Bytes.Bytes, data: Bytes.Bytes) -> IO(Result<&1, &1, Err, Bytes.Bytes>): 168 Bytes.Bytes{+kn, kb} = k 169 Bytes.Bytes{+dn, db} = data 170 do IO>: 171 r : Result<&1, &1, U32 & String, U32 & Array> <- Crypto.hmac.words("SHA256", kn, kb, dn, db) 172 return mac.of(r) 173 174 def ct.go(m: Bytes.Bytes, s: Bytes.Bytes) -> IO(Bool): 175 Bytes.Bytes{+mn, mb} = m 176 Bytes.Bytes{+sn, sb} = s 177 Crypto.eq.ct.words(mn, mb, sn, sb) 178 179 # A signature that does not decode matches nothing. 180 def ct(s: Maybe<&1, Bytes.Bytes>, m: Bytes.Bytes) -> IO(Bool): 181 match s: 182 case None{}: 183 IO.pure(Bool, False{}) 184 case Some{b}: 185 ct.go(m, b) 186 187 # Does any signature equal the MAC? r holds the MAC and a copy; every signature is compared. 188 def any.sig(sigs: List<&2, String>, +hex: Bool, r: Bytes.Bytes & Bytes.Bytes) -> IO(Bool): 189 match sigs: 190 case Nil{}: 191 IO.pure(Bool, False{}) 192 case Con{s, t}: 193 (m1, m2) = r 194 do IO: 195 ok : Bool <- ct(decode(hex, s), m2) 196 rest : Bool <- any.sig(t, hex, Bytes.slice(m1, 0, 4294967295)) 197 return Bool.or(ok, rest) 198 199 def key.cmp(r: Result<&1, &1, Err, Bytes.Bytes>, +hex: Bool, +sigs: List<&2, String>) -> IO(Result<&1, &1, Err, Bool>): 200 match r: 201 case Fail{e}: 202 IO.pure(Result<&1, &1, Err, Bool>, Fail{e}) 203 case Done{m}: 204 do IO>: 205 ok : Bool <- any.sig(sigs, hex, Bytes.slice(m, 0, 4294967295)) 206 return Done{ok} 207 208 def key.check(k: Maybe<&1, Bytes.Bytes>, +hex: Bool, +sigs: List<&2, String>, msg: Bytes.Bytes) -> IO(Result<&1, &1, Err, Bool>): 209 match k: 210 case None{}: 211 IO.pure(Result<&1, &1, Err, Bool>, Fail{BadSecret{}}) 212 case Some{b}: 213 do IO>: 214 r : Result<&1, &1, Err, Bytes.Bytes> <- mac(b, msg) 215 key.cmp(r, hex, sigs) 216 217 def both.done(+x: Bool, b: Result<&1, &1, Err, Bool>) -> Result<&1, &1, Err, Bool>: 218 match b: 219 case Fail{e}: 220 Fail{e} 221 case Done{y}: 222 Done{Bool.or(x, y)} 223 224 # A bad secret fails the check, even when another secret matches. 225 def both(a: Result<&1, &1, Err, Bool>, b: Result<&1, &1, Err, Bool>) -> Result<&1, &1, Err, Bool>: 226 match a: 227 case Fail{e}: 228 Fail{e} 229 case Done{x}: 230 both.done(x, b) 231 232 # Does any signature sign the message under any secret? r holds the message and a copy. 233 def any.key(keys: List<&2, String>, +std: Bool, +hex: Bool, +sigs: List<&2, String>, r: Bytes.Bytes & Bytes.Bytes) -> 234 IO(Result<&1, &1, Err, Bool>): 235 match keys: 236 case Nil{}: 237 IO.pure(Result<&1, &1, Err, Bool>, Done{False{}}) 238 case Con{k, t}: 239 (m1, m2) = r 240 do IO>: 241 a : Result<&1, &1, Err, Bool> <- key.check(key(std, k), hex, sigs, m2) 242 b : Result<&1, &1, Err, Bool> <- any.key(t, std, hex, sigs, Bytes.slice(m1, 0, 4294967295)) 243 return both(a, b) 244 245 def found(-A: Data, r: Result<&1, &1, Err, Bool>, v: A) -> Result<&1, &1, Err, A>: 246 match r: 247 case Fail{e}: 248 Fail{e} 249 case Done{ok}: 250 Bool.pick(Result<&1, &1, Err, A>, ok, Done{v}, Fail{NoMatch{}}) 251 252 # The body back beside a failure. 253 def back(-A: Data, r: Bytes.Bytes & Bytes.Bytes, e: Err) -> IO(Bytes.Bytes & Result<&1, &1, Err, A>): 254 (keep, copy) = r 255 IO.pure(Bytes.Bytes & Result<&1, &1, Err, A>, (keep, Fail{e})) 256 257 def run.mac(-A: Data, v: A, pre: String, +sigs: List<&2, String>, +std: Bool, +hex: Bool, secrets: List<&2, String>, 258 r: Bytes.Bytes & Bytes.Bytes) -> IO(Bytes.Bytes & Result<&1, &1, Err, A>): 259 (keep, copy) = r 260 do IO>: 261 hit : Result<&1, &1, Err, Bool> <- 262 any.key(secrets, std, hex, sigs, Bytes.slice(Bytes.append(Bytes.from_string(pre), copy), 0, 4294967295)) 263 return (keep, found(A, hit, v)) 264 265 # c: the header checks' answer, the signed prefix before the body, and the candidate signatures. 266 def run(-A: Data, c: Result<&1, &1, Err, A & String & List<&2, String>>, +std: Bool, +hex: Bool, secrets: List<&2, String>, 267 r: Bytes.Bytes & Bytes.Bytes) -> IO(Bytes.Bytes & Result<&1, &1, Err, A>): 268 match c: 269 case Fail{e}: 270 back(A, r, e) 271 case Done{(v, pre, sigs)}: 272 run.mac(A, v, pre, sigs, std, hex, secrets, r) 273 274 # ---- Standard Webhooks (https://www.standardwebhooks.com) 275 276 # The v1 entries of a space-separated webhook-signature; other versions are skipped. 277 def std.sigs(xs: List<&2, String>) -> List<&2, String>: 278 match xs: 279 case Nil{}: 280 Nil{} 281 case Con{x, t}: 282 push(strip("v1,", x), std.sigs(t)) 283 284 def std.check(+tol: U32, now: Time.Instant, +h: Map<&2, List<&2, String>>) -> Result<&1, &1, Err, String & String & List<&2, String>>: 285 do Result<&1, &1, Err, String & String & List<&2, String>>: 286 +id : String <- need("webhook-id", field(h, "webhook-id")) 287 +ts : String <- need("webhook-timestamp", field(h, "webhook-timestamp")) 288 sig : String <- need("webhook-signature", field(h, "webhook-signature")) 289 u : Unit <- id.check(id) 290 t : Int.I64 <- ts.read(ts) 291 w : Unit <- window(tol, now, t) 292 return (id, id ++ "." ++ ts ++ ".", std.sigs(String.split(sig, ' '))) 293 294 # Done with the webhook-id when a v1 signature in webhook-signature signs id.timestamp.body under a secret, 295 # and webhook-timestamp is within tol seconds of now. 296 def verify.with(+tol: U32, secrets: List<&2, String>, now: Time.Instant, +headers: Map<&2, List<&2, String>>, body: Bytes.Bytes) -> 297 IO(Bytes.Bytes & Result<&1, &1, Err, String>): 298 run(String, std.check(tol, now, headers), True{}, False{}, secrets, Bytes.slice(body, 0, 4294967295)) 299 300 # verify.with a 300 s tolerance. 301 def verify(secrets: List<&2, String>, now: Time.Instant, +headers: Map<&2, List<&2, String>>, body: Bytes.Bytes) -> 302 IO(Bytes.Bytes & Result<&1, &1, Err, String>): 303 verify.with(300, secrets, now, headers, body) 304 305 def sign.check(+id: String, +ts: Int.I64) -> Result<&1, &1, Err, String & String>: 306 do Result<&1, &1, Err, String & String>: 307 u : Unit <- id.check(id) 308 w : Unit <- Bool.pick(Result<&1, &1, Err, Unit>, Int.I64.is_neg(ts), Fail{BadTimestamp{}}, Done{Unit{}}) 309 return (id, Int.I64.show(ts)) 310 311 def sign.headers(+id: String, +ts: String, m: Result<&1, &1, Err, Bytes.Bytes>) -> Result<&1, &1, Err, Map<&2, List<&2, String>>>: 312 match m: 313 case Fail{e}: 314 Fail{e} 315 case Done{s}: 316 Done{put(put(put(Map.new(&2, List<&2, String>), "webhook-id", id), "webhook-timestamp", ts), "webhook-signature", "v1," ++ Bytes.to_base64(s))} 317 318 def sign.mac(k: Bytes.Bytes, +id: String, +ts: String, r: Bytes.Bytes & Bytes.Bytes) -> 319 IO(Bytes.Bytes & Result<&1, &1, Err, Map<&2, List<&2, String>>>): 320 (keep, copy) = r 321 do IO>>>: 322 m : Result<&1, &1, Err, Bytes.Bytes> <- mac(k, Bytes.append(Bytes.from_string(id ++ "." ++ ts ++ "."), copy)) 323 return (keep, sign.headers(id, ts, m)) 324 325 def sign.key(k: Maybe<&1, Bytes.Bytes>, +id: String, +ts: String, r: Bytes.Bytes & Bytes.Bytes) -> 326 IO(Bytes.Bytes & Result<&1, &1, Err, Map<&2, List<&2, String>>>): 327 match k: 328 case None{}: 329 back(Map<&2, List<&2, String>>, r, BadSecret{}) 330 case Some{b}: 331 sign.mac(b, id, ts, r) 332 333 def sign.go(c: Result<&1, &1, Err, String & String>, +secret: String, r: Bytes.Bytes & Bytes.Bytes) -> 334 IO(Bytes.Bytes & Result<&1, &1, Err, Map<&2, List<&2, String>>>): 335 match c: 336 case Fail{e}: 337 back(Map<&2, List<&2, String>>, r, e) 338 case Done{(id, ts)}: 339 sign.key(key.std(secret), id, ts, r) 340 341 # The webhook-id, webhook-timestamp, and webhook-signature ("v1,") headers for body. 342 # id: not empty, no '.'. ts: unix seconds, not negative. secret: whsec_; send the prefix. 343 def sign(+id: String, +ts: Int.I64, body: Bytes.Bytes, +secret: String) -> 344 IO(Bytes.Bytes & Result<&1, &1, Err, Map<&2, List<&2, String>>>): 345 sign.go(sign.check(id, ts), secret, Bytes.slice(body, 0, 4294967295)) 346 347 # ---- Stripe (https://docs.stripe.com/webhooks#verify-manually) 348 349 def stripe.t(s: String, r: List<&2, String> & List<&2, String>) -> List<&2, String> & List<&2, String>: 350 (ts, vs) = r 351 (Con{s, ts}, vs) 352 353 def stripe.v.push(s: String, r: List<&2, String> & List<&2, String>) -> List<&2, String> & List<&2, String>: 354 (ts, vs) = r 355 (ts, Con{s, vs}) 356 357 def stripe.v(m: Maybe<&2, String>, r: List<&2, String> & List<&2, String>) -> List<&2, String> & List<&2, String>: 358 match m: 359 case None{}: 360 r 361 case Some{s}: 362 stripe.v.push(s, r) 363 364 def stripe.item(t: Maybe<&2, String>, v: Maybe<&2, String>, r: List<&2, String> & List<&2, String>) -> 365 List<&2, String> & List<&2, String>: 366 match t: 367 case None{}: 368 stripe.v(v, r) 369 case Some{s}: 370 stripe.t(s, r) 371 372 # The t= and v1= values of comma-separated items; other schemes (v0) are skipped. 373 def stripe.items(xs: List<&2, String>) -> List<&2, String> & List<&2, String>: 374 match xs: 375 case Nil{}: 376 (Nil{}, Nil{}) 377 case Con{+x, rest}: 378 stripe.item(strip("t=", x), strip("v1=", x), stripe.items(rest)) 379 380 def stripe.single(ts: List<&2, String>, vs: List<&2, String>) -> Result<&1, &1, Err, String & List<&2, String>>: 381 match ts: 382 case Nil{}: 383 Fail{BadTimestamp{}} 384 case Con{t, Nil{}}: 385 Done{(t, vs)} 386 case Con{t, more}: 387 Fail{BadTimestamp{}} 388 389 # Exactly one t=: a second one would leave the signed timestamp ambiguous. 390 def stripe.one(r: List<&2, String> & List<&2, String>) -> Result<&1, &1, Err, String & List<&2, String>>: 391 (ts, vs) = r 392 stripe.single(ts, vs) 393 394 def stripe.when(+tol: U32, now: Time.Instant, p: String & List<&2, String>) -> Result<&1, &1, Err, Unit & String & List<&2, String>>: 395 (+ts, vs) = p 396 do Result<&1, &1, Err, Unit & String & List<&2, String>>: 397 t : Int.I64 <- ts.read(ts) 398 w : Unit <- window(tol, now, t) 399 return (Unit{}, ts ++ ".", vs) 400 401 def stripe.check(+tol: U32, now: Time.Instant, +h: Map<&2, List<&2, String>>) -> Result<&1, &1, Err, Unit & String & List<&2, String>>: 402 do Result<&1, &1, Err, Unit & String & List<&2, String>>: 403 sig : String <- need("stripe-signature", field(h, "stripe-signature")) 404 p : String & List<&2, String> <- stripe.one(stripe.items(String.split(sig, ','))) 405 stripe.when(tol, now, p) 406 407 # Done when a v1 hex signature in stripe-signature signs t.body under a secret (the whsec_ text itself, as UTF-8), 408 # and t is within tol seconds of now. 409 def stripe.verify.with(+tol: U32, secrets: List<&2, String>, now: Time.Instant, +headers: Map<&2, List<&2, String>>, body: Bytes.Bytes) -> 410 IO(Bytes.Bytes & Result<&1, &1, Err, Unit>): 411 run(Unit, stripe.check(tol, now, headers), False{}, True{}, secrets, Bytes.slice(body, 0, 4294967295)) 412 413 # stripe.verify.with a 300 s tolerance, Stripe's default. 414 def stripe.verify(secrets: List<&2, String>, now: Time.Instant, +headers: Map<&2, List<&2, String>>, body: Bytes.Bytes) -> 415 IO(Bytes.Bytes & Result<&1, &1, Err, Unit>): 416 stripe.verify.with(300, secrets, now, headers, body) 417 418 # ---- GitHub (https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries) 419 420 def github.check(+h: Map<&2, List<&2, String>>) -> Result<&1, &1, Err, Unit & String & List<&2, String>>: 421 do Result<&1, &1, Err, Unit & String & List<&2, String>>: 422 sig : String <- need("x-hub-signature-256", field(h, "x-hub-signature-256")) 423 return (Unit{}, "", push(strip("sha256=", sig), Nil{})) 424 425 # Done when x-hub-signature-256 is sha256= of the body under a secret's UTF-8. GitHub signs no timestamp. 426 def github.verify(secrets: List<&2, String>, +headers: Map<&2, List<&2, String>>, body: Bytes.Bytes) -> 427 IO(Bytes.Bytes & Result<&1, &1, Err, Unit>): 428 run(Unit, github.check(headers), False{}, True{}, secrets, Bytes.slice(body, 0, 4294967295)) 429 430 type SendErr is Data: 431 SendSign{err: Err} 432 SendNet{err: Hairpin.Err} 433 434 def send.result(x: Hairpin.Client & Retry.Budget & Result<&1, &1, Hairpin.Err, Http.Res>) -> 435 Hairpin.Client & Retry.Budget & Result<&1, &1, SendErr, Http.Res>: 436 (c, b, r) = x 437 match r: 438 case Fail{e}: 439 (c, b, Fail{SendNet{e}}) 440 case Done{res}: 441 (c, b, Done{res}) 442 443 # A timeout, a refused connect, 408, 429, or any 5xx may clear. 444 def send.judge(r: Result<&1, &1, Http.Err, Http.Res>) -> 445 Result<&1, &1, Http.Err, Http.Res> & Maybe<&2, String>: 446 match r: 447 case Fail{e}: 448 Http.retry.judge(True{}, Fail{e}) 449 case Done{res}: 450 Http.Res{+status, +headers, body} = res 451 +again = Bool.or(Http.retry.status(status), U32.is_eq((status / 100 : U32), 5)) 452 (Done{Http.Res{status, headers, body}}, Bool.pick(Maybe<&2, String>, again, Some{Http.header(headers, "retry-after")}, None{})) 453 454 # Receivers deduplicate on the event ID, so the POST is safe to repeat. 455 def send.signed(c: Hairpin.Client, b: Retry.Budget, +url: String, r: Bytes.Bytes & Result<&1, &1, Err, Map<&2, List<&2, String>>>) -> 456 IO(Hairpin.Client & Retry.Budget & Result<&1, &1, SendErr, Http.Res>): 457 (body, signed) = r 458 match signed: 459 case Fail{e}: 460 IO.pure(Hairpin.Client & Retry.Budget & Result<&1, &1, SendErr, Http.Res>, (c, b, Fail{SendSign{e}})) 461 case Done{+h}: 462 do IO>: 463 enc : String <- Http.codings() 464 x : Hairpin.Client & Retry.Budget & Result<&1, &1, Hairpin.Err, Http.Res> <- 465 Hairpin.request.as(~send.judge, Hairpin.redirect(c, Http.ModeManual{}), b, True{}, enc, "POST", url, 466 Http.set(h, "content-type", "application/json"), body) 467 return send.result(x) 468 469 def send.at(c: Hairpin.Client, b: Retry.Budget, url: String, id: String, secret: String, body: Bytes.Bytes, now: Time.Instant) -> 470 IO(Hairpin.Client & Retry.Budget & Result<&1, &1, SendErr, Http.Res>): 471 match now: 472 case Time.Instant{secs, nanos}: 473 do IO>: 474 signed : Bytes.Bytes & Result<&1, &1, Err, Map<&2, List<&2, String>>> <- sign(id, secs, body, secret) 475 send.signed(c, b, url, signed) 476 477 # One event and one signature per delivery, inside a caller's retry layer. Every attempt takes from b and 478 # reuses this event ID, timestamp, headers, and raw body; the client's retry setting caps this delivery. 479 # The budget left comes back: pass it to the next delivery of the same operation. 480 def send.in(c: Hairpin.Client, b: Retry.Budget, url: String, id: String, secret: String, body: Bytes.Bytes) -> 481 IO(Hairpin.Client & Retry.Budget & Result<&1, &1, SendErr, Http.Res>): 482 do IO>: 483 now : Time.Instant <- Time.now() 484 send.at(c, b, url, id, secret, body, now) 485 486 def send.drop(x: Hairpin.Client & Retry.Budget & Result<&1, &1, SendErr, Http.Res>) -> Hairpin.Client & Result<&1, &1, SendErr, Http.Res>: 487 (c, b, r) = x 488 (c, r) 489 490 def send.budget(url: String, id: String, secret: String, body: Bytes.Bytes, y: Hairpin.Client & Retry.Budget) -> 491 IO(Hairpin.Client & Retry.Budget & Result<&1, &1, SendErr, Http.Res>): 492 (c, b) = y 493 send.in(c, b, url, id, secret, body) 494 495 # n extra attempts, on a budget of n + 1 and the client's deadline, with Hairpin's backoff. The client comes back with retry n. 496 def send.with(+n: U32, c: Hairpin.Client, url: String, id: String, secret: String, body: Bytes.Bytes) -> 497 IO(Hairpin.Client & Result<&1, &1, SendErr, Http.Res>): 498 do IO>: 499 y : Hairpin.Client & Retry.Budget <- Hairpin.budget(Hairpin.retry(c, n)) 500 x : Hairpin.Client & Retry.Budget & Result<&1, &1, SendErr, Http.Res> <- send.budget(url, id, secret, body, y) 501 return send.drop(x) 502 503 # Three extra attempts by default. 504 def send(c: Hairpin.Client, url: String, id: String, secret: String, body: Bytes.Bytes) -> 505 IO(Hairpin.Client & Result<&1, &1, SendErr, Http.Res>): 506 send.with(3, c, url, id, secret, body)