#gaato/mttp_core/h1client

    An HTTP/1.1 client connection engine over the gaato/mttp_core/rt ports.

    It keeps a pool of connections per origin, admits at most the configured number of active exchanges, reads every response head, chunk-size line and trailer section through the bounded gaato/mttp_core/h1 codec, and puts each phase of an exchange under a deadline. Client implements @mttp_core.Backend, so a policy layer (retries, redirects, cookies, content decoding) wraps it without knowing it.

    gaato/mttp wires this engine to sockets, TLS and an event loop; most applications use that instead.

    #Sending a request

    The engine needs the ports only: a Dialer, a Clock, a Timer and a Coordination. Here the in-memory doubles of gaato/mttp_core/sim stand in for a network, so the example runs on every target:

    ///|
    test "send a request and read the response" {
    let driver = @driver.Driver::new()
    let conn = @sim.ScriptedConn::new([
    Chunk(b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello"),
    ])
    let client = @h1client.Client::new(
    dialer=@sim.ScriptedDialer::new([conn]),
    clock=@sim.SimClock::new(driver),
    timer=@sim.SimTimer::new(driver),
    coordination=@sim.SimCoordination::new(driver),
    config={
    ..@h1client.ClientConfig::default(),
    connect_timeout_ms: Some(10000),
    },
    )
    driver.run(() => {
    let response = client.send(
    @mttp_core.Request::get("http://example.test/"),
    { timeout_ms: Some(30000), body: Exchange(max_bytes=1 << 20), },
    )
    assert_eq(response.status, 200)
    guard response.body is Stream(body) else { fail("expected a body") }
    assert_eq(@mttp_core.read_bounded(body.take(), 1 << 20), b"hello")
    // Read to its end, the connection is parked for the next request.
    assert_eq(client.idle_connections(), 1)
    client.close()
    })
    assert_eq(conn.closes(), 1)
    }

    #What it guarantees

    • Validation first. An invalid URL, method, header or framing, and SendOptions that validate refuses, fail with Invalid (or Timeout(ms) for a nonpositive deadline) in phase Pool before any dial.
    • Raw bytes. The engine adds a Host field and the framing fields and nothing else: no Accept-Encoding, no decoding, no redirects, no retries.
    • Bounded input. Heads, chunk-size lines and trailers are read within ClientConfig.head_limits. With Exchange(max_bytes) at most max_bytes body bytes (and ClientConfig.max_body_bytes) are read from the wire; a streamed body is bounded by the configured ceiling. The read that would cross the bound raises TooLarge(limit) in phase Body and discards the connection.
    • Deadlines. SendOptions.timeout_ms covers the head and, with Exchange, the whole body; with Stream(idle_ms), each body read waits at most idle_ms. The ClientConfig limits bound each phase on top. A Timeout(ms) names the limit that ran out and the phase it ran out in.
    • Uploads. A stream body is taken and its first piece read before anything is written, so a stream that fails or lies about being empty sends nothing; it is closed on every exit. A response that arrives during an upload ends the upload, and that connection is not reused. A request with Expect: 100-continue and a body sends its head alone, and its body after the server's 100 Continue, or after one second without an answer (never past the attempt deadline); a final response that comes first is the answer, and the body is not sent.
    • Reuse. A connection is parked only after a complete, keep-alive exchange with no unread bytes, and is handed only to a request with the same origin and User-Agent. A connection parked for 100 ms or more is checked first, waiting at most 2 ms on the Timer: if the server closed it or sent anything on it, it is closed and the request goes out on another, whatever its method. So does a request whose first write the reused connection refuses with nothing sent (it raised Closed, as a TLS session the server shut down does when the check missed the close). A request on a reused connection that still fails before any response byte arrived is resent once on a fresh connection, if its body is replayable and it is idempotent (@mttp_core.is_idempotent); such a response carries the stale_resend attribute.
    • Proxies. ClientConfig.proxy names an HTTP proxy per scheme and a NO_PROXY list, as data: ProxyConfig::from_env(lookup) reads the usual environment variables once, through a lookup the caller passes, and the client never reads the environment itself. http:// requests are forwarded with an absolute target; https:// requests go through a CONNECT tunnel with TLS end to end. A request's own Proxy-Authorization is never sent; a proxy's credentials (from its URL) go to that proxy only. Connections through different proxies, or none, are pooled apart.
    • Ownership. A response body owns its connection and slot until it is read to its end or closed. Cancelling a send or a read closes what it owns and propagates unchanged.

    Client

    pub struct Client {
    // private fields
    }

    An HTTP/1.1 client over the rt ports: a pool of connections per origin, bounded heads and bodies, and per-phase deadlines. It implements @mttp_core.Backend, so it moves raw bytes: no redirects, retries, cookies or content decoding, and no Accept-Encoding of its own.

    A response owns its connection until its body is read to the end, which parks a reusable connection for the next request to the same origin, or closed, which closes it. TLS and its trust settings belong to the Dialer; the client reads no host environment.
    impl Backend for Client

    Client::close

    fn Client::close(self : Client) -> Unit

    The Backend operations as methods of the client.

    Client::idle_connections

    fn Client::idle_connections(self : Client) -> Int

    The number of parked idle connections over all origins.

    Client::new

    Creates a client with config over the given ports.

    test {
    let driver = @driver.Driver::new()
    let conn = @sim.ScriptedConn::new([
    Chunk(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nhi"),
    ])
    let client = @h1client.Client::new(
    dialer=@sim.ScriptedDialer::new([conn]),
    clock=@sim.SimClock::new(driver),
    timer=@sim.SimTimer::new(driver),
    coordination=@sim.SimCoordination::new(driver),
    config=@h1client.ClientConfig::default(),
    )
    driver.run(() => {
    let response = client.send(
    @mttp_core.Request::get("http://example.test/"),
    @mttp_core.SendOptions::default(),
    )
    guard response.body is Stream(body) else { fail("expected a body") }
    assert_eq(@mttp_core.read_bounded(body.take(), 1024), b"hi")
    assert_eq(client.idle_connections(), 1)
    client.close()
    })
    assert_eq(conn.closes(), 1)
    }

    Client::send

    The Backend operations as methods of the client.

    ClientConfig

    pub(all) struct ClientConfig {
    pool_timeout_ms : Int?
    connect_timeout_ms : Int?
    write_timeout_ms : Int?
    read_timeout_ms : Int?
    max_connections : Int?
    max_connections_per_origin : Int?
    max_idle_connections : Int
    max_idle_per_origin : Int
    idle_max_ms : Int
    max_body_bytes : Int?
    head_limits :
    Limits

    proxy : ProxyConfig
    } derive(Eq,
    Debug
    )

    The settings of a Client, fixed at construction. Build them from ClientConfig::default() with a record update; the field set is frozen.

    Time limits are in milliseconds and bound one phase each; None sets no limit for that phase, so only the per-send SendOptions.timeout_ms applies. A nonpositive limit fails its phase at once with Timeout(ms).

    test {
    let config = {
    ..@h1client.ClientConfig::default(),
    connect_timeout_ms: Some(5000),
    max_connections_per_origin: Some(8),
    }
    assert_eq(config.max_idle_per_origin, 4)
    assert_eq(config.max_body_bytes, None)
    }

    ClientConfig::default

    fn ClientConfig::default() -> ClientConfig

    No phase limits and no connection caps; idle reuse of at most 256 connections, 4 per origin, for 10 seconds; no wire ceiling beyond the per-send one; @h1.Limits::default() for heads; no proxy.

    ClientConfig::equal

    Compares configurations.

    ClientConfig::not_equal

    fn ClientConfig::not_equal(x : ClientConfig, y : ClientConfig) -> Bool

    Compares configurations.

    ClientConfig::to_repr

    Formats a configuration for diagnostics.

    ProxyConfig

    pub(all) struct ProxyConfig {
    http : String?
    https : String?
    no_proxy : String
    } derive(Eq,
    Debug
    )

    Which HTTP proxies a Client uses, as data fixed at construction: one URL for http:// requests, one for https:// requests, and a NO_PROXY list of the hosts that go direct. Build it with none, fixed or from_env, or as a record.

    A proxy URL is http:// or https:// (a missing scheme is http://), with an optional user:password@ that becomes Proxy-Authorization:Basic for that proxy alone. http:// requests are forwarded with an absolute target; https:// requests go through a CONNECT tunnel, with TLS end to end inside it. A setting that cannot be used fails the requests it would carry with Protocol in phase Pool, rather than sending them direct.

    test {
    let env = {
    "https_proxy": "http://proxy.test:3128",
    "NO_PROXY": "internal.test",
    }
    let proxy = @h1client.ProxyConfig::from_env(name => env.get(name))
    assert_eq(proxy.http, None)
    assert_eq(proxy.https, Some("http://proxy.test:3128"))
    assert_eq(proxy.no_proxy, "internal.test")
    }

    ProxyConfig::equal

    fn ProxyConfig::equal(ProxyConfig, ProxyConfig) -> Bool

    Compares proxy settings.

    ProxyConfig::fixed

    fn ProxyConfig::fixed(url : String) -> ProxyConfig

    Every request goes through the proxy at url, as httpx's proxy=.

    ProxyConfig::from_env

    fn ProxyConfig::from_env(lookup : (String) -> String?) -> ProxyConfig

    The proxies the environment names, read once through lookup, as curl and httpx read them:

    • http_proxy for http:// requests, https_proxy for https://, and all_proxy for either when its own is unset;
    • no_proxy for the hosts that go direct;
    • each name in lower case first, then upper case; a value of only spaces is unset. Upper-case HTTP_PROXY is ignored when REQUEST_METHOD is set, since a CGI program gets it from a request's Proxy header (httpoxy, CVE-2016-5385), as Go and Python ignore it.

    The client never reads the environment itself; lookup is called here and nowhere else.

    ProxyConfig::none

    fn ProxyConfig::none() -> ProxyConfig

    Every request goes direct.

    ProxyConfig::not_equal

    fn ProxyConfig::not_equal(x : ProxyConfig, y : ProxyConfig) -> Bool

    Compares proxy settings.

    ProxyConfig::to_repr

    Formats proxy settings for diagnostics; they may hold credentials.

    stale_resend

    Set to true on a response that came from resending the request on a fresh connection, after the reused idle connection it was first sent on failed before any response byte arrived. Only a request with a replayable body that is idempotent or marked replay_safe is resent, and only once. The resend is a repair of a stale connection, not a policy attempt.