diff --git a/src/Std/Http/Client.lean b/src/Std/Http/Client.lean index dfccd3d95046..405fd6b963e4 100644 --- a/src/Std/Http/Client.lean +++ b/src/Std/Http/Client.lean @@ -6,7 +6,7 @@ Authors: Sofia Rodrigues module prelude -public import Std.Http.Client.Agent +public import Std.Http.Client.Pool public section diff --git a/src/Std/Http/Client/Connector.lean b/src/Std/Http/Client/Connector.lean new file mode 100644 index 000000000000..259af4f1ecc6 --- /dev/null +++ b/src/Std/Http/Client/Connector.lean @@ -0,0 +1,81 @@ +/- +Copyright (c) 2026 Lean FRO, LLC. All rights reserved. +Released under Apache 2.0 license as described in the file LICENSE. +Authors: Sofia Rodrigues +-/ +module + +prelude +public import Std.Http.Client.Connection +import Std.Async.DNS + +public section + +/-! +# Connector + +A `Connector` abstracts DNS resolution and TCP/transport connection establishment for the +connection pool. + +The default `Connector.tcp` resolves via the system DNS and dials a raw TCP socket. +-/ + +namespace Std.Http.Client + +open Std Async TCP +open Time + +set_option linter.all true + +/-- +Opens a new transport connection to a target `(scheme, host, port)` and wraps it in a `Connection`. + +Supply your own function to customize DNS resolution or transport selection (plain TCP, TLS, +Unix socket). `scheme` is provided so implementations can dispatch between plain and encrypted +transports; `config.proxy` is available for proxy routing. + +Failures are reported as a typed `Error` (usually `Error.connect`). An exception thrown by a +connector is also treated as a connect failure by the pool. +-/ +abbrev Connector := URI.Scheme → URI.Host → UInt16 → Config → Async (Except Error Connection) + +/-- +The default connector: resolves `host` via the system DNS, iterates over the returned +addresses, and opens a TCP socket to the first one that succeeds. + +When `config.proxy` is set, the TCP connection is made to the proxy address instead +and the original `host`/`port` are left for the HTTP layer to handle. +-/ +def Connector.tcp : Connector := fun scheme host port config => do + + if scheme.val == "https" then + return .error (.connect "default TCP connector does not support https.") + + if scheme.val != "http" then + return .error (.connect + s!"default TCP connector only supports http, got scheme {scheme.val.quote}") + + let (connectHost, connectPort) := config.proxy.getD (toString host, port) + let addrs ← + try DNS.getAddrInfo connectHost (toString connectPort) + catch err => return .error (.connect (toString err)) + + if addrs.isEmpty then + return .error (.connect s!"could not resolve host: {connectHost.quote}") + + let mut lastErr : Error := .connect s!"could not connect to {connectHost.quote}:{connectPort}" + + for ipAddr in addrs do + let socketAddr : Std.Net.SocketAddress := match ipAddr with + | .v4 ip => .v4 ⟨ip, connectPort⟩ + | .v6 ip => .v6 ⟨ip, connectPort⟩ + try + let socket ← Socket.Client.mk + socket.connect socketAddr + return .ok (← Connection.new socket config) + catch err => + lastErr := .connect (toString err) + + return .error lastErr + +end Std.Http.Client diff --git a/src/Std/Http/Client/Pool.lean b/src/Std/Http/Client/Pool.lean new file mode 100644 index 000000000000..d855a2aad22a --- /dev/null +++ b/src/Std/Http/Client/Pool.lean @@ -0,0 +1,299 @@ +/- +Copyright (c) 2026 Lean FRO, LLC. All rights reserved. +Released under Apache 2.0 license as described in the file LICENSE. +Authors: Sofia Rodrigues +-/ +module + +prelude +public import Std.Http.Client.Agent +public import Std.Http.Client.Connector +import Init.Data.Array + +public section + +/-! +# Pool + +A simple connection pool that keeps at most one reusable connection. + +If the next request targets the current connection's origin, the connection is reused. If the origin +changes, the current connection is retired and a new one is opened for the new origin. + +Use `Pool.new` to create a pool, then call `pool.send` to dispatch requests through managed +connections. The pool handles redirect following and middlewares. +-/ + +namespace Std.Http.Client + +open Std Async TCP Protocol +open Time + +set_option linter.all true + +/-- +The single reusable connection currently held by the pool. +-/ +structure Pool.Slot where + /-- + Origin this connection is connected to. + -/ + origin : URI.Origin + + /-- + The current connection. + -/ + connection : Connection + +/-- +Default number of connection-level retries for pools and clients. One retry absorbs the +stale keep-alive race (the server closed a pooled connection just as it was reused) +without sending any request more than twice. +-/ +def Pool.defaultMaxRetries : Nat := 1 + +/-- +A connection pool that manages one reusable connection at a time. +-/ +structure Pool where + /-- + Current reusable connection, if any. + -/ + state : Mutex (Option Pool.Slot) + + /-- + Configuration used when creating new connections. + -/ + config : Config + + /-- + Monotonically increasing counter for unique connection IDs. + -/ + nextId : Mutex UInt64 + + /-- + Middlewares applied (outermost-first) around every request/response hop. + -/ + middlewares : Array Middleware := #[] + + /-- + Function used to open new transport connections. Supply a custom `Connector` via `Pool.new`. + -/ + connect : Connector := Connector.tcp + + /-- + Maximum number of times to retry a failed request on a fresh connection. `0` disables + retries. + + Retries only apply to connection-level failures (the connection died before a response was + received). Application-level errors (4xx, 5xx) are never retried automatically. + + **Only idempotent methods with replayable bodies are retried.** Requests whose method + returns `false` from `Method.isIdempotent` (e.g. `POST`, `PATCH`) are never retried + regardless of this value, to prevent unintended duplicate side-effects. + -/ + maxRetries : Nat := Pool.defaultMaxRetries + +namespace Pool + +/-- +Creates a new, empty connection pool. Supply a custom `connect` function (e.g. a TLS +connector or a mock) to customize how transport connections are opened. +-/ +def new (config : Config := {}) (connect : Connector := Connector.tcp) + (maxRetries : Nat := Pool.defaultMaxRetries) (middlewares : Array Middleware := #[]) : + Async Pool := do + let state ← Mutex.new (none : Option Pool.Slot) + let nextId ← Mutex.new (1 : UInt64) + pure { state, config, nextId, middlewares, connect, maxRetries } + +/-- +Closes and removes the pool's current connection, if any. The pool remains usable: a later +`send` simply opens a fresh connection. +-/ +def close (pool : Pool) : Async Unit := do + let slot ← pool.state.atomically <| modifyGet fun slot => (slot, none) + if let some slot := slot then + slot.connection.close + +/-- +Acquires a fresh unique connection ID. +-/ +private def nextConnectionId (pool : Pool) : Async UInt64 := + pool.nextId.atomically <| modifyGet fun id => (id, id + 1) + +/-- +Opens a new connection for `origin` and assigns it a pool-local ID. The connector runs under +`Config.connectTimeout`, bounding DNS resolution and the transport connect. An exception +thrown by a custom connector is reported as `Error.connect`, keeping every connector-level +failure typed on one path. +-/ +private def openConnection (pool : Pool) (origin : URI.Origin) : + Async (Except Error Connection) := do + let resultChannel : Std.Channel (Except Error Connection) ← Std.Channel.new + + let connectTask ← async (t := AsyncTask) do + try + pool.connect origin.scheme origin.host origin.port pool.config + catch err => + pure (.error (.connect (toString err))) + + BaseIO.chainTask connectTask fun + | .ok result => discard <| resultChannel.send result + | .error err => discard <| resultChannel.send (.error (.connect (toString err))) + + let outcome ← Selectable.one #[ + .case resultChannel.recvSelector (fun result => pure (some result)), + .case (← Selector.sleep pool.config.connectTimeout.val) (fun _ => pure none) + ] + + match outcome with + | some (.ok connection) => + let id ← nextConnectionId pool + return .ok { connection with id } + | some (.error e) => return .error e + | none => + -- The connector may still complete after the timeout; drain its result in the + -- background and close the late connection so the transport does not leak. + background do + let late ← Selectable.one #[.case resultChannel.recvSelector pure] + if let .ok connection := late then + connection.close + let timeout := pool.config.connectTimeout.val + return .error (.connect + s!"connecting to {origin.host}:{origin.port} timed out after {timeout}ms") + +/-- +Returns the pool's single connection for `origin`. + +If the current connection has the same origin, it is checked out again; HTTP/1.1 requests +queue on the connection. If the origin differs, the current connection is retired and replaced. + +A new connection is opened *outside* the state mutex: DNS resolution and the TCP connect can block, +and holding the lock across them would serialize every other pool operation (including connection +retirement). The lock is taken only for the brief fast-path check and to install the freshly +opened connection. +-/ +def getOrCreateConnection (pool : Pool) (origin : URI.Origin) : + Async (Except Error Connection) := do + -- Fast path: reuse an existing same-origin connection without opening anything. A parked + -- connection whose background loop has already shut down (server EOF, idle keep-alive timeout) is + -- evicted instead of returned: handing it out would fail the request even though nothing was ever + -- written to the wire for it. A connection can still die between this check and the actual send; + -- that residual race surfaces as a connection error handled by the retry policy in `send`. + let existing ← pool.state.atomically do + match ← get with + | some slot => + if slot.origin == origin then + if ← slot.connection.isClosed then + set (none : Option Pool.Slot) + pure none + else + pure (some slot.connection) + else + pure none + | none => pure none + if let some connection := existing then + return .ok connection + + -- Slow path: open a new connection with the lock released. + match ← pool.openConnection origin with + | .error e => return .error e + | .ok connection => + + -- Install it, retiring whatever is parked. If another task installed a same-origin connection + -- while we were connecting, keep theirs and discard ours so the pool never holds two live + -- connections. + let (chosen, evicted) ← pool.state.atomically do + match ← get with + | some slot => + if slot.origin == origin then + pure (slot.connection, some connection) + else + set (some ({ origin, connection } : Pool.Slot)) + pure (connection, some slot.connection) + | none => + set (some ({ origin, connection } : Pool.Slot)) + pure (connection, none) + if let some evictedConnection := evicted then + evictedConnection.close + return .ok chosen + +/-- +Removes a connection from the pool and closes its request channel. +-/ +private def retireConnection (pool : Pool) (connection : Connection) (origin : URI.Origin) : + Async Unit := do + pool.state.atomically <| modify fun + | some slot => + if slot.origin == origin && slot.connection.id == connection.id then none else some slot + | none => none + connection.close + +/-- +Sends a request through the pooled connection, following redirects and applying middlewares, +returning the response or the typed `Error` that ended the exchange. +On a retryable connection-level failure (see `Error.isRetryable`), retries up to +`pool.maxRetries` times on fresh connections. Application-level failures (timeouts, +protocol violations, body-size limits) are never retried. + +Connection lifecycle is owned by the agent driving the exchange: a failed hop, a cross-origin +redirect swap, or a connection error after the final response hands the connection back to the +pool, which retires it. Cross-origin redirects keep the pool to one live origin at a time. +-/ +def trySend {β : Type} [Coe β Body.Any] (pool : Pool) (origin : URI.Origin) (request : Request β) + (overrides : RequestOverrides := {}) : Async (Except Error (Response Body.Stream)) := do + -- Erase the body type once; every layer below the pool works with `Request Body.Any`. + let request : Request Body.Any := { request with } + + -- Non-idempotent methods (POST, PATCH, …) must never be retried; a connection + -- failure after partial delivery could cause duplicate side-effects. + -- The body must also be replayable (`reset?`): the failed attempt may have consumed a + -- streaming body, and retrying would silently send an empty or truncated body. + let reset? := request.body.reset? + let retries := if request.line.method.isIdempotent && reset?.isSome then pool.maxRetries else 0 + + let attempts := retries + 1 + + -- A single attempt: acquire a connection and run the exchange. `Agent.trySend` owns connection + -- cleanup — every failure path inside it releases the connection back to the pool — so the + -- attempt only has to report the typed result to the retry loop below. Connection + -- establishment is part of the attempt so that DNS/TCP failures are retried too. + let attemptOnce : Async (Except Error (Response Body.Stream)) := do + match ← pool.getOrCreateConnection origin with + | .error e => return .error e + | .ok connection => + Agent.trySend { + connection + origin + middlewares := pool.middlewares + release := pool.retireConnection + crossOrigin := .follow pool.getOrCreateConnection + } request overrides + + for attempt in 0...attempts do + -- A prior attempt may have consumed (part of) the body; rewind it before resending. + if attempt > 0 then + if let some reset := reset? then + reset + match ← attemptOnce with + | .ok response => return .ok response + | .error e => + -- Report on the final attempt or for non-retryable failures; + -- retryable failures fall through to the next attempt. + if ¬e.isRetryable || attempt + 1 ≥ attempts then + return .error e + + -- Unreachable: the loop runs at least once and the final attempt always returns. + return .error (.io (IO.userError "HTTP client retry loop exhausted without returning")) + +/-- +Sends a request through the pooled connection, following redirects and applying middlewares. +Use `trySend` to receive failures as a typed `Error` instead of a thrown exception. +-/ +def send {β : Type} [Coe β Body.Any] (pool : Pool) (origin : URI.Origin) (request : Request β) + (overrides : RequestOverrides := {}) : Async (Response Body.Stream) := + pool.trySend origin request overrides >>= Error.throwOrPure + +end Pool +end Std.Http.Client diff --git a/tests/elab/async_http_client_edge_pool.lean b/tests/elab/async_http_client_edge_pool.lean new file mode 100644 index 000000000000..a47b4ca32ec1 --- /dev/null +++ b/tests/elab/async_http_client_edge_pool.lean @@ -0,0 +1,943 @@ +module + +import Std.Http.Test.Helpers + +/-! HTTP client connection-pool, keep-alive, timeout, shutdown, and retry edge cases. -/ + +open Std.Async +open Std Http Internal +open Test.ClientHelpers + +-- ============================================================ +-- Section 7 — Keep-alive and Connection: close +-- ============================================================ + +-- The simplified pool keeps one connection at a time. A same-origin request sent +-- while the previous response body is unread queues on that connection and reaches +-- the wire only after the caller closes or drains the previous body. + +#eval show IO _ from + runWithTimeout "single-connection pool queues behind unread response body" 4000 <| + Async.block do + let (mockClient1, mockServer1) ← Mock.new + let connectCount ← IO.mkRef 0 + + let connect : Client.Connector := fun _ _ _ config => do + let n ← connectCount.get + connectCount.set (n + 1) + match n with + | 0 => return .ok (← Client.Connection.new mockServer1 (config := config)) + | _ => throw (IO.userError "pool opened more connections than expected") + + let pool ← Client.Pool.new {} connect + let some domain := URI.DomainName.ofString? "example.com" + | throw (IO.userError "DomainName parse failed") + let origin : URI.Origin := { + scheme := URI.Scheme.ofString! "http" + host := .name domain + port := 80 + } + + let req1 ← Request.new |>.method .get |>.uri! "/one" + |>.header! "Host" "example.com" |>.empty + let p1 : IO.Promise (Except String (Response Body.Stream)) ← IO.Promise.new + background do + let result ← try + let resp ← pool.send origin req1 + pure (Except.ok resp) + catch e => pure (Except.error (toString e)) + discard <| p1.resolve result + + let _ ← drainRequest mockClient1 + mockClient1.send (rawResp "200 OK" + #[("Content-Length", "5"), ("Connection", "keep-alive")] "hello") + + let resp1 ← match ← await p1.result! with + | Except.error e => throw (IO.userError s!"first pooled request failed: {e}") + | Except.ok resp => pure resp + + let req2 ← Request.new |>.method .get |>.uri! "/two" + |>.header! "Host" "example.com" |>.empty + let p2 : IO.Promise (Except String (Response Body.Stream)) ← IO.Promise.new + background do + let result ← try + let resp ← pool.send origin req2 + pure (Except.ok resp) + catch e => pure (Except.error (toString e)) + discard <| p2.resolve result + + IO.sleep 50 + unless (← connectCount.get) == 1 do + resp1.body.close + mockClient1.close + throw (IO.userError "single-connection pool opened a second same-origin connection") + + if let some bytes ← mockClient1.tryRecv? then + resp1.body.close + mockClient1.close + let text := String.fromUTF8! bytes + throw (IO.userError + s!"queued request reached the wire before the first body was closed:\n{text.quote}") + + resp1.body.close + + let secondBytes ← drainRequest mockClient1 + mockClient1.send (rawResp "200 OK" + #[("Content-Length", "3"), ("Connection", "close")] "two") + + match ← await p2.result! with + | Except.error e => throw (IO.userError s!"second pooled request failed: {e}") + | Except.ok resp2 => + let body ← resp2.body.readAll (α := String) + unless body == "two" do + throw (IO.userError s!"expected second body 'two', got {body.quote}") + + let secondText := String.fromUTF8! secondBytes + unless secondText.startsWith "GET /two" do + throw <| IO.userError s!"second request did not use the queued connection:\n{secondText.quote}" + +-- Once the caller closes an unread pooled response body, the connection loop +-- drains the wire body and reports completion. The pool should then park the +-- connection as idle instead of opening a new one. + +#eval show IO _ from + runWithTimeout "pool reuses connection after unread response body is closed" 4000 <| + Async.block do + let (mockClient1, mockServer1) ← Mock.new + let (_mockClient2, mockServer2) ← Mock.new + let connectCount ← IO.mkRef 0 + + let connect : Client.Connector := fun _ _ _ config => do + let n ← connectCount.get + connectCount.set (n + 1) + match n with + | 0 => return .ok (← Client.Connection.new mockServer1 (config := config)) + | 1 => return .ok (← Client.Connection.new mockServer2 (config := config)) + | _ => throw (IO.userError "pool opened more connections than expected") + + let pool ← Client.Pool.new {} connect + let some domain := URI.DomainName.ofString? "example.com" + | throw (IO.userError "DomainName parse failed") + let origin : URI.Origin := { + scheme := URI.Scheme.ofString! "http" + host := .name domain + port := 80 + } + + let req1 ← Request.new |>.method .get |>.uri! "/one" + |>.header! "Host" "example.com" |>.empty + let p1 : IO.Promise (Except String (Response Body.Stream)) ← IO.Promise.new + background do + let result ← try + let resp ← pool.send origin req1 + pure (Except.ok resp) + catch e => pure (Except.error (toString e)) + discard <| p1.resolve result + + let _ ← drainRequest mockClient1 + mockClient1.send (rawResp "200 OK" + #[("Content-Length", "5"), ("Connection", "keep-alive")] "hello") + + let resp1 ← match ← await p1.result! with + | Except.error e => throw (IO.userError s!"first pooled request failed: {e}") + | Except.ok resp => pure resp + + resp1.body.close + IO.sleep 50 + + let req2 ← Request.new |>.method .get |>.uri! "/two" + |>.header! "Host" "example.com" |>.empty + let p2 : IO.Promise (Except String (Response Body.Stream)) ← IO.Promise.new + background do + let result ← try + let resp ← pool.send origin req2 + pure (Except.ok resp) + catch e => pure (Except.error (toString e)) + discard <| p2.resolve result + + IO.sleep 50 + if (← connectCount.get) != 1 then + mockClient1.close + throw (IO.userError "pool opened a second connection after the first response body was closed") + + let secondBytes ← drainRequest mockClient1 + mockClient1.send (rawResp "200 OK" + #[("Content-Length", "3"), ("Connection", "close")] "two") + + match ← await p2.result! with + | Except.error e => throw (IO.userError s!"second pooled request failed: {e}") + | Except.ok resp2 => + let body ← resp2.body.readAll (α := String) + unless body == "two" do + throw (IO.userError s!"expected second body 'two', got {body.quote}") + + let secondText := String.fromUTF8! secondBytes + unless secondText.startsWith "GET /two" do + throw <| IO.userError s!"second request did not reuse the first connection:\n{secondText.quote}" + +-- A zero-length pooled response still needs to drive the connection through +-- completion so the connection is returned to idle. + +#eval show IO _ from + runWithTimeout "pool reuses connection after zero-length response body completes" 4000 <| + Async.block do + let (mockClient1, mockServer1) ← Mock.new + let (_mockClient2, mockServer2) ← Mock.new + let connectCount ← IO.mkRef 0 + + let connect : Client.Connector := fun _ _ _ config => do + let n ← connectCount.get + connectCount.set (n + 1) + match n with + | 0 => return .ok (← Client.Connection.new mockServer1 (config := config)) + | 1 => return .ok (← Client.Connection.new mockServer2 (config := config)) + | _ => throw (IO.userError "pool opened more connections than expected") + + let pool ← Client.Pool.new {} connect + let some domain := URI.DomainName.ofString? "example.com" + | throw (IO.userError "DomainName parse failed") + let origin : URI.Origin := { + scheme := URI.Scheme.ofString! "http" + host := .name domain + port := 80 + } + + let req1 ← Request.new |>.method .get |>.uri! "/empty" + |>.header! "Host" "example.com" |>.empty + let p1 : IO.Promise (Except String (Response Body.Stream)) ← IO.Promise.new + background do + let result ← try + let resp ← pool.send origin req1 + pure (Except.ok resp) + catch e => pure (Except.error (toString e)) + discard <| p1.resolve result + + let _ ← drainRequest mockClient1 + mockClient1.send (rawResp "200 OK" + #[("Content-Length", "0"), ("Connection", "keep-alive")] "") + + match ← await p1.result! with + | Except.error e => throw (IO.userError s!"first pooled request failed: {e}") + | Except.ok resp1 => + let body ← resp1.body.readAll (α := String) + unless body == "" do + throw (IO.userError s!"expected empty first body, got {body.quote}") + + IO.sleep 50 + + let req2 ← Request.new |>.method .get |>.uri! "/two" + |>.header! "Host" "example.com" |>.empty + let p2 : IO.Promise (Except String (Response Body.Stream)) ← IO.Promise.new + background do + let result ← try + let resp ← pool.send origin req2 + pure (Except.ok resp) + catch e => pure (Except.error (toString e)) + discard <| p2.resolve result + + IO.sleep 50 + if (← connectCount.get) != 1 then + mockClient1.close + throw (IO.userError "pool opened a second connection after a zero-length response completed") + + let secondBytes ← drainRequest mockClient1 + mockClient1.send (rawResp "200 OK" + #[("Content-Length", "3"), ("Connection", "close")] "two") + + match ← await p2.result! with + | Except.error e => throw (IO.userError s!"second pooled request failed: {e}") + | Except.ok resp2 => + let body ← resp2.body.readAll (α := String) + unless body == "two" do + throw (IO.userError s!"expected second body 'two', got {body.quote}") + + let secondText := String.fromUTF8! secondBytes + unless secondText.startsWith "GET /two" do + throw <| IO.userError s!"second request did not reuse the first connection:\n{secondText.quote}" + +-- A different origin replaces the pool's single current connection. + +#eval show IO _ from + runWithTimeout "single-connection pool replaces connection on origin change" 4000 <| + Async.block do + let (mockClient1, mockServer1) ← Mock.new + let (mockClient2, mockServer2) ← Mock.new + let connectCount ← IO.mkRef 0 + + let connect : Client.Connector := fun _ _ _ config => do + let n ← connectCount.get + connectCount.set (n + 1) + match n with + | 0 => return .ok (← Client.Connection.new mockServer1 (config := config)) + | 1 => return .ok (← Client.Connection.new mockServer2 (config := config)) + | _ => throw (IO.userError "pool opened more connections than expected") + + let pool ← Client.Pool.new {} connect + let some domain1 := URI.DomainName.ofString? "example.com" + | throw (IO.userError "DomainName parse failed") + let some domain2 := URI.DomainName.ofString? "other.example" + | throw (IO.userError "DomainName parse failed") + let origin1 : URI.Origin := { + scheme := URI.Scheme.ofString! "http" + host := .name domain1 + port := 80 + } + let origin2 : URI.Origin := { + scheme := URI.Scheme.ofString! "http" + host := .name domain2 + port := 80 + } + + let req1 ← Request.new |>.method .get |>.uri! "/one" + |>.header! "Host" "example.com" |>.empty + let p1 : IO.Promise (Except String (Response Body.Stream)) ← IO.Promise.new + background do + let result ← try + let resp ← pool.send origin1 req1 + pure (Except.ok resp) + catch e => pure (Except.error (toString e)) + discard <| p1.resolve result + + let _ ← drainRequest mockClient1 + mockClient1.send (rawResp "200 OK" + #[("Content-Length", "0"), ("Connection", "keep-alive")] "") + + match ← await p1.result! with + | Except.error e => throw (IO.userError s!"first pooled request failed: {e}") + | Except.ok resp1 => + let body ← resp1.body.readAll (α := String) + unless body == "" do + throw (IO.userError s!"expected empty body, got {body.quote}") + + IO.sleep 50 + + let req2 ← Request.new |>.method .get |>.uri! "/two" + |>.header! "Host" "other.example" |>.empty + let p2 : IO.Promise (Except String (Response Body.Stream)) ← IO.Promise.new + background do + let result ← try + let resp ← pool.send origin2 req2 + pure (Except.ok resp) + catch e => pure (Except.error (toString e)) + discard <| p2.resolve result + + -- `recv?` returns `none` only once the old connection's transport is closed, so this doubles as + -- the retirement check: an old connection left open makes the test run out its wall clock here. + match ← mockClient1.recv? with + | none => pure () + | some bytes => + mockClient1.close + mockClient2.close + throw (IO.userError + s!"the new origin's request was written to the old connection:\n{(String.fromUTF8! bytes).quote}") + + let secondBytes ← drainRequest mockClient2 + mockClient2.send (rawResp "200 OK" + #[("Content-Length", "3"), ("Connection", "close")] "two") + + match ← await p2.result! with + | Except.error e => throw (IO.userError s!"second-origin request failed: {e}") + | Except.ok resp2 => + let body ← resp2.body.readAll (α := String) + unless body == "two" do + throw (IO.userError s!"expected second body 'two', got {body.quote}") + + unless (← connectCount.get) == 2 do + throw (IO.userError "origin change did not open exactly one replacement connection") + let secondText := String.fromUTF8! secondBytes + unless secondText.startsWith "GET /two" do + throw <| IO.userError + s!"second-origin request did not use the replacement connection:\n{secondText.quote}" + +-- If a pooled cross-origin redirect leaves the original origin, the outgoing +-- connection is retired instead of being returned idle. A target-acquire failure +-- must close that old connection and leave the pool able to open a clean +-- replacement for the original origin. + +#eval show IO _ from + runWithTimeout "failed cross-origin redirect retires old connection and keeps pool usable" 4000 <| + Async.block do + let (mockClient1, mockServer1) ← Mock.new + let (mockClient2, mockServer2) ← Mock.new + let originalConnectCount ← IO.mkRef 0 + + let connect : Client.Connector := fun _scheme host _port config => do + if toString host == "other.example" then + throw (IO.userError s!"redirect target dial failed for {host}") + else + let n ← originalConnectCount.get + originalConnectCount.set (n + 1) + match n with + | 0 => return .ok (← Client.Connection.new mockServer1 (config := config)) + | 1 => return .ok (← Client.Connection.new mockServer2 (config := config)) + | _ => throw (IO.userError "opened too many original-origin connections") + + -- Retries are disabled: this test asserts the state the pool is left in after a + -- single failed cross-origin acquire, not the retry policy. + let pool ← Client.Pool.new {} connect (maxRetries := 0) + let some domain := URI.DomainName.ofString? "example.com" + | throw (IO.userError "DomainName parse failed") + let origin : URI.Origin := { + scheme := URI.Scheme.ofString! "http" + host := .name domain + port := 80 + } + + let req1 ← Request.new |>.method .get |>.uri! "/start" + |>.header! "Host" "example.com" |>.empty + let p1 : IO.Promise (Except String (Response Body.Stream)) ← IO.Promise.new + background do + let result ← try + let resp ← pool.send origin req1 + pure (Except.ok resp) + catch e => pure (Except.error (toString e)) + discard <| p1.resolve result + + let _ ← drainRequest mockClient1 + mockClient1.send (rawResp "302 Found" + #[("Location", "http://other.example/landing"), + ("Content-Length", "0"), + ("Connection", "keep-alive")] "") + + match ← await p1.result! with + | Except.ok _ => + mockClient1.close + mockClient2.close + throw (IO.userError "redirect unexpectedly succeeded") + | Except.error e => + unless e.contains "redirect target dial failed" do + mockClient1.close + mockClient2.close + throw (IO.userError s!"unexpected redirect failure: {e}") + + -- As above, `none` means the transport was closed; a source connection left open instead ends + -- this test on its wall clock. + match ← mockClient1.recv? with + | none => pure () + | some bytes => + mockClient1.close + mockClient2.close + throw (IO.userError + s!"a request was written to the retired redirect source connection:\n{(String.fromUTF8! bytes).quote}") + + let req2 ← Request.new |>.method .get |>.uri! "/again" + |>.header! "Host" "example.com" |>.empty + let p2 : IO.Promise (Except String (Response Body.Stream)) ← IO.Promise.new + background do + let result ← try + let resp ← pool.send origin req2 + pure (Except.ok resp) + catch e => pure (Except.error (toString e)) + discard <| p2.resolve result + + IO.sleep 50 + if (← originalConnectCount.get) != 2 then + mockClient1.close + mockClient2.close + throw (IO.userError + "pool did not open a replacement original-origin connection for the follow-up request") + + let secondBytes ← drainRequest mockClient2 + mockClient2.send (rawResp "200 OK" + #[("Content-Length", "2"), ("Connection", "close")] "ok") + + match ← await p2.result! with + | Except.error e => + throw (IO.userError + s!"original connection was not reused after failed redirect acquire: {e}") + | Except.ok resp => + let body ← resp.body.readAll (α := String) + unless body == "ok" do + throw (IO.userError s!"expected second response body 'ok', got {body.quote}") + + let secondText := String.fromUTF8! secondBytes + unless secondText.startsWith "GET /again" do + throw <| IO.userError + s!"second request did not use the replacement connection:\n{secondText.quote}" + +-- Two sequential requests on the same connection must both succeed, exercising the +-- `.next` reset path in the connection state machine. + +#eval show IO _ from runWithTimeout "two sequential GETs on keep-alive succeed" 4000 <| + Async.block do + let (mockClient, mockServer) ← Mock.new + let agent ← mkAgent mockServer + + -- First request. + let req1 ← Request.new |>.method .get |>.uri! "/one" + |>.header! "Host" "example.com" |>.empty + let p1 ← sendInBackground agent req1 + + let _ ← drainRequest mockClient + mockClient.send (rawResp "200 OK" + #[("Content-Length", "3"), ("Connection", "keep-alive")] "one") + + match ← await p1.result! with + | Except.error e => throw (IO.userError s!"first request failed: {e}") + | Except.ok resp => + let body ← resp.body.readAll (α := String) + unless body == "one" do + throw (IO.userError s!"expected 'one', got {body.quote}") + + -- Second request on same connection must succeed. + let req2 ← Request.new |>.method .get |>.uri! "/two" + |>.header! "Host" "example.com" |>.empty + let p2 ← sendInBackground agent req2 + + let _ ← drainRequest mockClient + mockClient.send (rawResp "200 OK" + #[("Content-Length", "3"), ("Connection", "close")] "two") + + match ← await p2.result! with + | Except.error e => throw (IO.userError s!"second request failed: {e}") + | Except.ok resp => + let body ← resp.body.readAll (α := String) + unless body == "two" do + throw (IO.userError s!"expected 'two', got {body.quote}") + +-- `Connection: close` on the response must close the connection; a follow-up request +-- on the same connection must error out rather than hang. + +#eval show IO _ from runWithTimeout "Connection: close prevents reuse" 4000 <| Async.block do + let (mockClient, mockServer) ← Mock.new + let agent ← mkAgent mockServer + + let req1 ← Request.new |>.method .get |>.uri! "/" + |>.header! "Host" "example.com" |>.empty + let p1 ← sendInBackground agent req1 + + let _ ← drainRequest mockClient + mockClient.send (rawResp "200 OK" + #[("Content-Length", "2"), ("Connection", "close")] "ok") + + match ← await p1.result! with + | Except.error e => throw (IO.userError s!"first request failed: {e}") + | Except.ok resp => + let _ ← resp.body.readAll (α := String) + + -- The response itself must retire the connection. Closing the mock here instead would fail the + -- second request through a dead transport whether or not `Connection: close` was honoured. + let mut retired := false + for _ in [0:50] do + if ← agent.connection.isClosed then + retired := true + break + IO.sleep 20 + unless retired do + mockClient.close + throw (IO.userError "the connection was still open after a Connection: close response") + + -- Second send must not hang; it must fail because the connection is closed. + let req2 ← Request.new |>.method .get |>.uri! "/" + |>.header! "Host" "example.com" |>.empty + let p2 ← sendInBackground agent req2 + + -- Nothing reaches the wire either: a retired connection *is* one whose request channel is closed + -- (`Connection.isClosed` reads that channel), so the send above failed before writing anything. + match ← await p2.result! with + | Except.ok _ => + throw (IO.userError "second request unexpectedly succeeded after Connection: close") + | Except.error _ => pure () + + mockClient.close + +-- ============================================================ +-- Section 12 — Request deadline and connection close +-- ============================================================ + +-- The absolute `requestTimeout` deadline must abort a response whose body stalls after the headers +-- arrive, surfacing the error to a caller blocked reading the body (rather than hanging until the +-- much larger per-read idle timeout). +#eval show IO _ from runWithTimeout "request deadline aborts a stalled response body" 4000 <| + Async.block do + let (mockClient, mockServer) ← Mock.new + let agent ← mkAgent mockServer (config := { requestTimeout := ⟨300, by decide⟩ }) + + let request ← Request.new + |>.method .get + |>.uri! "/slow" + |>.header! "Host" "example.com" + |>.empty + + let resultPromise ← sendInBackground agent request + + let _ ← drainRequest mockClient + -- Promise a 10-byte body but never send it: the exchange must hit the request deadline. + mockClient.send (rawResp "200 OK" + #[("Content-Length", "10"), ("Connection", "close")] "") + + -- The head arrives immediately and the deadline is 300ms away, so it must reach the caller; + -- accepting an up-front error here would hide a regression that withholds the head. + match ← await resultPromise.result! with + | Except.error e => + throw (IO.userError s!"the response head was not delivered before the deadline: {e}") + | Except.ok resp => + let got : Except String String ← try + let s ← resp.body.readAll (α := String) + pure (Except.ok s) + catch e => pure (Except.error (toString e)) + match got with + | Except.error _ => pure () + | Except.ok s => + throw (IO.userError s!"expected request-timeout error on stalled body, read {s.quote}") + +-- Incoming progress must not re-arm the whole-request timeout. A server can keep the idle timer +-- alive by dripping bytes, but the absolute request deadline must still end the exchange. +#eval show IO _ from runWithTimeout "request deadline aborts a slow-drip response body" 4000 <| + Async.block do + let (mockClient, mockServer) ← Mock.new + let agent ← mkAgent mockServer (config := { requestTimeout := ⟨250, by decide⟩ }) + + let request ← Request.new |>.method .get |>.uri! "/slow-drip" + |>.header! "Host" "example.com" |>.empty + let resultPromise ← sendInBackground agent request + + let _ ← drainRequest mockClient + mockClient.send (rawResp "200 OK" + #[("Content-Length", "5"), ("Connection", "keep-alive")] "") + background do + for byte in #["a", "b", "c", "d", "e"] do + IO.sleep 100 + try mockClient.send byte.toUTF8 catch _ => pure () + + match ← await resultPromise.result! with + | Except.error _ => pure () + | Except.ok resp => + let result : Except String String ← try + pure (Except.ok (← resp.body.readAll (α := String))) + catch e => pure (Except.error (toString e)) + match result with + | Except.error _ => pure () + | Except.ok body => + throw (IO.userError s!"slow-drip response escaped request deadline with {body.quote}") + +-- `Connection.close` must abort an in-flight exchange promptly (via the connection's cancellation +-- context), not leave the caller blocked until the request timeout. The request timeout below is +-- set far beyond the test budget so that only `close` can end the request; without the context +-- wiring the background loop stays parked on the socket and this test times out. +#eval show IO _ from runWithTimeout "connection close aborts an in-flight request" 4000 <| + Async.block do + let (mockClient, mockServer) ← Mock.new + let agent ← mkAgent mockServer (config := { requestTimeout := ⟨60000, by decide⟩ }) + + let request ← Request.new + |>.method .get + |>.uri! "/never-answered" + |>.header! "Host" "example.com" + |>.empty + + let resultPromise ← sendInBackground agent request + + -- Server receives the request but never responds; only close should end it. + let _ ← drainRequest mockClient + agent.connection.close + + match ← await resultPromise.result! with + | Except.error _ => pure () + | Except.ok _ => + throw (IO.userError "expected in-flight request to abort when the connection is closed") + +-- Opening a transport must not hold the pool state mutex. The first connector is deliberately +-- blocked; a second-origin acquisition must enter its connector before the first is released. +#eval show IO _ from runWithTimeout "pool does not hold state mutex while connecting" 4000 <| + Async.block do + let (_mockClient1, mockServer1) ← Mock.new + let (_mockClient2, mockServer2) ← Mock.new + let calls ← Std.Mutex.new 0 + let firstStarted : IO.Promise Unit ← IO.Promise.new + let releaseFirst : IO.Promise Unit ← IO.Promise.new + let released ← IO.mkRef false + let secondSawReleased ← IO.mkRef true + + let connect : Client.Connector := fun _ _ _ config => do + let callNo ← calls.atomically <| modifyGet fun n => (n, n + 1) + if callNo == 0 then + discard <| firstStarted.resolve () + await releaseFirst.result! + return .ok (← Client.Connection.new mockServer1 (config := config)) + else + secondSawReleased.set (← released.get) + return .ok (← Client.Connection.new mockServer2 (config := config)) + + let pool ← Client.Pool.new {} connect + let some domainA := URI.DomainName.ofString? "a.example" + | throw (IO.userError "DomainName parse failed") + let some domainB := URI.DomainName.ofString? "b.example" + | throw (IO.userError "DomainName parse failed") + let originA : URI.Origin := { + scheme := URI.Scheme.ofString! "http", host := .name domainA, port := 80 } + let originB : URI.Origin := { + scheme := URI.Scheme.ofString! "http", host := .name domainB, port := 80 } + + let firstDone : IO.Promise Unit ← IO.Promise.new + let secondDone : IO.Promise Unit ← IO.Promise.new + + background do + let .ok connection ← pool.getOrCreateConnection originA + | throw (IO.userError "first-origin connection acquisition failed") + discard <| firstDone.resolve () + connection.close + await firstStarted.result! + background do + let .ok connection ← pool.getOrCreateConnection originB + | throw (IO.userError "second-origin connection acquisition failed") + discard <| secondDone.resolve () + connection.close + background do + IO.sleep 300 + released.set true + discard <| releaseFirst.resolve () + + await secondDone.result! + if ← secondSawReleased.get then + throw (IO.userError "second connection was blocked behind the pool state mutex") + await firstDone.result! + +-- Idempotent requests retry a connector failure, including a failure before a `Connection` exists. +#eval show IO _ from runWithTimeout "GET retries after the first connection attempt fails" 4000 <| + Async.block do + let (mockClient, mockServer) ← Mock.new + let calls ← IO.mkRef 0 + let connect : Client.Connector := fun _ _ _ config => do + let callNo ← calls.get + calls.set (callNo + 1) + if callNo == 0 then + throw (IO.userError "synthetic first connect failure") + return .ok (← Client.Connection.new mockServer (config := config)) + let pool ← Client.Pool.new {} connect (maxRetries := 1) + let some domain := URI.DomainName.ofString? "example.com" + | throw (IO.userError "DomainName parse failed") + let origin : URI.Origin := { + scheme := URI.Scheme.ofString! "http", host := .name domain, port := 80 } + let request ← Request.new |>.method .get |>.uri! "/retry" + |>.header! "Host" "example.com" |>.empty + let result : IO.Promise (Except String (Response Body.Stream)) ← IO.Promise.new + + background do + let attempt ← try + pure (Except.ok (← pool.send origin request)) + catch e => pure (Except.error (toString e)) + discard <| result.resolve attempt + background do + let _ ← drainRequest mockClient + mockClient.send (rawResp "200 OK" + #[("Content-Length", "2"), ("Connection", "close")] "ok") + + match ← await result.result! with + | Except.error e => throw (IO.userError s!"GET was not retried after connect failure: {e}") + | Except.ok resp => + let body ← resp.body.readAll (α := String) + unless body == "ok" do + throw (IO.userError s!"expected retry body 'ok', got {body.quote}") + unless (← calls.get) == 2 do + throw (IO.userError s!"expected two connection attempts, got {← calls.get}") + +-- A non-idempotent request is never retried after the peer drops the first connection mid-flight. +#eval show IO _ from runWithTimeout "POST is not retried after a mid-flight connection drop" 4000 <| + Async.block do + let (mockClient1, mockServer1) ← Mock.new + let (_mockClient2, mockServer2) ← Mock.new + let calls ← IO.mkRef 0 + let connect : Client.Connector := fun _ _ _ config => do + let callNo ← calls.get + calls.set (callNo + 1) + let mockServer := if callNo == 0 then mockServer1 else mockServer2 + return .ok (← Client.Connection.new mockServer (config := config)) + let pool ← Client.Pool.new {} connect (maxRetries := 3) + let some domain := URI.DomainName.ofString? "example.com" + | throw (IO.userError "DomainName parse failed") + let origin : URI.Origin := { + scheme := URI.Scheme.ofString! "http", host := .name domain, port := 80 } + let request ← Request.new |>.method .post |>.uri! "/side-effect" + |>.header! "Host" "example.com" |>.text "payload" + let result : IO.Promise (Except String (Response Body.Stream)) ← IO.Promise.new + + background do + let attempt ← try + pure (Except.ok (← pool.send origin request)) + catch e => pure (Except.error (toString e)) + discard <| result.resolve attempt + let _ ← drainRequest mockClient1 + mockClient1.close + + match ← await result.result! with + | Except.ok _ => throw (IO.userError "POST unexpectedly succeeded after connection drop") + | Except.error _ => pure () + unless (← calls.get) == 1 do + throw (IO.userError s!"POST was retried; expected one connection attempt, got {← calls.get}") + +-- ============================================================ +-- Section 14 — Retry body integrity and dead-connection detection +-- ============================================================ + +-- An idempotent request whose streaming body was consumed by the failed attempt must NOT be +-- retried: the body cannot be replayed, so a retry would silently send an empty body. +#eval show IO _ from runWithTimeout "PUT with non-replayable stream body is not retried" 4000 <| + Async.block do + let (mockClient1, mockServer1) ← Mock.new + let (mockClient2, mockServer2) ← Mock.new + let calls ← IO.mkRef 0 + let connect : Client.Connector := fun _ _ _ config => do + let callNo ← calls.get + calls.set (callNo + 1) + let mockServer := if callNo == 0 then mockServer1 else mockServer2 + return .ok (← Client.Connection.new mockServer (config := config)) + let pool ← Client.Pool.new {} connect (maxRetries := 3) + let some domain := URI.DomainName.ofString? "example.com" + | throw (IO.userError "DomainName parse failed") + let origin : URI.Origin := { + scheme := URI.Scheme.ofString! "http", host := .name domain, port := 80 } + let request ← Request.new |>.method .put |>.uri! "/upload" + |>.header! "Host" "example.com" + |>.stream (fun out => do + out.send (Chunk.ofByteArray "payload".toUTF8) + out.close) + let result : IO.Promise (Except String (Response Body.Stream)) ← IO.Promise.new + + background do + let attempt ← try + pure (Except.ok (← pool.send origin request)) + catch e => pure (Except.error (toString e)) + discard <| result.resolve attempt + + -- Drain the first request fully (chunked or Content-Length framing), then drop the connection + -- without responding, after the body has been consumed from the caller's stream. + let mut firstBytes := ByteArray.empty + repeat + let some chunk ← mockClient1.recv? + | throw (IO.userError "connection closed before first PUT arrived") + firstBytes := firstBytes ++ chunk + let t := String.fromUTF8! firstBytes + if t.endsWith "0\r\n\r\n" || t.endsWith "payload" then break + mockClient1.close + + -- If the client (incorrectly) retries, answer the second connection so the test fails fast on + -- the `calls` assertion instead of timing out. + background do + if (← mockClient2.recv?).isSome then + mockClient2.send (rawResp "200 OK" + #[("Content-Length", "2"), ("Connection", "close")] "ok") + + match ← await result.result! with + | Except.ok _ => + throw (IO.userError "PUT with a consumed stream body unexpectedly succeeded via retry") + | Except.error _ => pure () + let attempts ← calls.get + unless attempts == 1 do + throw (IO.userError + s!"PUT with non-replayable body was retried; expected 1 attempt, got {attempts}") + +-- A replayable (`Body.Full`) request body must be reset before a retry so the second attempt +-- sends the complete payload again, not the consumed remainder. +#eval show IO _ from runWithTimeout "retried PUT resends the full replayable body" 4000 <| + Async.block do + let (mockClient1, mockServer1) ← Mock.new + let (mockClient2, mockServer2) ← Mock.new + let calls ← IO.mkRef 0 + let connect : Client.Connector := fun _ _ _ config => do + let callNo ← calls.get + calls.set (callNo + 1) + let mockServer := if callNo == 0 then mockServer1 else mockServer2 + return .ok (← Client.Connection.new mockServer (config := config)) + let pool ← Client.Pool.new {} connect (maxRetries := 1) + let some domain := URI.DomainName.ofString? "example.com" + | throw (IO.userError "DomainName parse failed") + let origin : URI.Origin := { + scheme := URI.Scheme.ofString! "http", host := .name domain, port := 80 } + let request ← Request.new |>.method .put |>.uri! "/upload" + |>.header! "Host" "example.com" |>.text "payload" + let result : IO.Promise (Except String (Response Body.Stream)) ← IO.Promise.new + + background do + let attempt ← try + pure (Except.ok (← pool.send origin request)) + catch e => pure (Except.error (toString e)) + discard <| result.resolve attempt + + -- First attempt: consume the whole request (headers + body), then drop without responding. + let _ ← drainRequest mockClient1 + mockClient1.close + + -- Second attempt: the retried request must carry the full body again. + let retryBytes ← drainRequest mockClient2 + mockClient2.send (rawResp "200 OK" + #[("Content-Length", "2"), ("Connection", "close")] "ok") + + match ← await result.result! with + | Except.error e => throw (IO.userError s!"retried PUT failed: {e}") + | Except.ok resp => + let _ ← resp.body.readAll (α := String) + unless (← calls.get) == 2 do + throw (IO.userError s!"expected two connection attempts, got {← calls.get}") + let retryText := String.fromUTF8! retryBytes + unless retryText.contains "payload" do + throw <| IO.userError + s!"retried PUT did not resend the request body:\n{retryText.quote}" + +-- A pooled connection whose background loop has already shut down (idle timeout, server EOF) must +-- not be +-- handed to the next request: the request was never written to the wire, so the pool can safely +-- open a fresh connection — even for non-idempotent methods and with retries disabled. +#eval show IO _ from + runWithTimeout "pool discards a dead connection instead of failing the next request" 4000 <| + Async.block do + let (mockClient1, mockServer1) ← Mock.new + let (mockClient2, mockServer2) ← Mock.new + let calls ← IO.mkRef 0 + let connect : Client.Connector := fun _ _ _ config => do + let callNo ← calls.get + calls.set (callNo + 1) + let mockServer := if callNo == 0 then mockServer1 else mockServer2 + return .ok (← Client.Connection.new mockServer (config := config)) + let pool ← Client.Pool.new {} connect (maxRetries := 0) + let some domain := URI.DomainName.ofString? "example.com" + | throw (IO.userError "DomainName parse failed") + let origin : URI.Origin := { + scheme := URI.Scheme.ofString! "http", host := .name domain, port := 80 } + + -- First exchange completes cleanly; the connection is parked in the pool. + let req1 ← Request.new |>.method .get |>.uri! "/one" + |>.header! "Host" "example.com" |>.empty + let p1 : IO.Promise (Except String (Response Body.Stream)) ← IO.Promise.new + background do + let attempt ← try + pure (Except.ok (← pool.send origin req1)) + catch e => pure (Except.error (toString e)) + discard <| p1.resolve attempt + let _ ← drainRequest mockClient1 + mockClient1.send (rawResp "200 OK" + #[("Content-Length", "5"), ("Connection", "keep-alive")] "hello") + match ← await p1.result! with + | Except.error e => throw (IO.userError s!"first pooled request failed: {e}") + | Except.ok resp => + let _ ← resp.body.readAll (α := String) + + -- The server silently drops the parked connection; give the background loop + -- time to observe EOF and shut down. + mockClient1.close + IO.sleep 200 + + -- The next request (a POST: retries disabled and non-idempotent anyway) must transparently get + -- a fresh connection rather than an error from the dead parked connection. + let req2 ← Request.new |>.method .post |>.uri! "/two" + |>.header! "Host" "example.com" |>.text "data" + let p2 : IO.Promise (Except String (Response Body.Stream)) ← IO.Promise.new + background do + let attempt ← try + pure (Except.ok (← pool.send origin req2)) + catch e => pure (Except.error (toString e)) + discard <| p2.resolve attempt + + let secondBytes ← drainRequest mockClient2 + mockClient2.send (rawResp "200 OK" + #[("Content-Length", "2"), ("Connection", "close")] "ok") + + match ← await p2.result! with + | Except.error e => throw (IO.userError s!"POST after dead parked connection failed: {e}") + | Except.ok resp => + let _ ← resp.body.readAll (α := String) + unless (← calls.get) == 2 do + throw (IO.userError s!"expected a fresh second connection, got {← calls.get} attempts") + let secondText := String.fromUTF8! secondBytes + unless secondText.startsWith "POST /two" do + throw <| IO.userError s!"unexpected second request:\n{secondText.quote}"