Sign in

    http

    Sans-IO HTTP vocabulary for MoonBit SDKs: request/response types, a Transport trait, Retry-After and Link parsing, a multipart writer, an incremental SSE parser, an RFC 6265 cookie jar and a scripted fake transport. No dependencies; runs on every backend.

    http
    sans-io
    transport
    sse
    cookie
    client
    Download zip
    Author
    Version
    0.4.0
    License
    Apache-2.0
    Last updated
    1 hour ago
    Downloads
    1K

    #gaato/http

    Sans-IO HTTP vocabulary for MoonBit SDKs: Request (with a Body of bytes or a StreamBody read as it is sent), Response, Headers, the Transport and BodyStream traits, middleware, URL and form encoding (percent_encode, append_query, form_urlencode), a UTF-8 encoder that never aborts (encode_utf8), header helpers (parse_retry_after / retry_after_ms, parse_link_next, is_idempotent), a Clock trait (gaato/http/clock), host randomness for jitter and boundaries (gaato/http/random), a multipart writer whose file parts may stream (gaato/http/multipart), an incremental WHATWG-conformant SSE parser (gaato/http/sse), an RFC 6265 cookie jar (gaato/http/cookie), and a scripted FakeTransport / FakeClock for tests (gaato/http/mock). No dependencies; runs on wasm-gc, wasm, js and native.

    The SSE parser bounds one event at max_event_bytes (16 MiB by default): the raw bytes of its data lines, of the event and id lines in effect (the id persists into later events, so it counts against each of them until replaced), and of the line being read. A stream that never ends a line, or never sends the blank line that ends an event, fails with HttpError::Protocol instead of growing memory without bound. Events completed before the oversized one are still delivered: when they arrive in the same chunk, SseParser::feed returns them and the next feed (an empty chunk is enough) or finish raises; each_event dispatches them and then raises without reading further. Pass max_event_bytes to SseParser::new or each_event to change it.

    Url parses, normalises and resolves URI references by RFC 3986 (non-ASCII hosts are refused; pass the IDNA xn-- form). follow_redirects, or the redirects middleware step, follows 301/302/303/307/308 up to max_redirects (10 by default), rewriting POST to GET where browsers do, stripping credentials when the origin changes and refusing https to http unless allow_downgrade is set. follow_redirects_stream does the same for send_stream, closing each redirect body unread. A redirect that would resend a stream body already sent is refused, as Fetch does. Nothing follows redirects unless you add the step.

    CookieJar stores the Set-Cookie headers of responses and builds the Cookie header of later requests, reading the time from a Clock. It keeps at most 50 cookies per domain and 3000 in all (both adjustable), evicting expired cookies first and then the least recently used, and ignores a Set-Cookie over 4096 bytes. It has no Public Suffix List: it refuses a single-label Domain such as com, and CookieJar::new(clock, is_public_suffix=...) takes a lookup to refuse more. HttpOnly cookies are sent and SameSite is recorded but not enforced, as in other HTTP clients. @cookie.cookies(jar) is the middleware step: put it after redirects in the send_with array so that every redirect hop stores and sends its own cookies.

    HttpError separates a failed connection (Connect: nothing was sent) from a Timeout or Network failure in a later Phase (Pool, Dial, Write, Head, Body, or Unknown) and a malformed message (Protocol); HttpError::is_unsent says whether a non-idempotent request may safely be sent again.

    An SDK built on this module never imports an async runtime: the caller passes a Transport and a Clock, for example from gaato/http-async.

    Unofficial and experimental. Source, issues and design notes: https://github.com/gaato/mbt-sdk

    Middleware

    type Middleware = async (Request, async (Request) -> Response raise HttpError) -> Response raise HttpError

    An async request wrapper receiving the next operation in the chain.

    BodyStream

    pub(open) trait BodyStream {
    async fn read_some(Self) -> Bytes? raise HttpError
    fn close(Self) -> Unit
    }

    A readable response body. None signals end of stream.

    Transport

    pub(open) trait Transport {
    async fn send(Self, Request) -> Response raise HttpError
    async fn send_stream(Self, Request) -> (ResponseHead, &BodyStream) raise HttpError
    }

    Sends requests without interpreting HTTP status codes as errors.

    HttpError

    pub(all) suberror HttpError {
    Connect(String)
    Timeout(phase~ : Phase, ms~ : Int)
    Network(phase~ : Phase, message~ : String)
    Protocol(String)
    Proxy(status~ : Int, message~ : String)
    } derive(
    Debug
    )

    Transport failures, separate from ordinary HTTP response statuses.

    HttpError::is_unsent

    fn HttpError::is_unsent(self : HttpError) -> Bool

    Whether the failure proves that no byte of the request was sent, so the server cannot have acted on it: a Connect failure, a proxy's refusal to open a tunnel, or a timeout while waiting for a connection slot or connecting. Such a request may be sent again even when it is not idempotent.

    test {
    assert_true(@http.Connect("refused").is_unsent())
    assert_true(@http.Timeout(phase=Dial, ms=1000).is_unsent())
    assert_true(@http.Proxy(status=407, message="auth").is_unsent())
    assert_false(@http.Timeout(phase=Head, ms=1000).is_unsent())
    assert_false(@http.Network(phase=Write, message="reset").is_unsent())
    }

    HttpError::to_repr

    Debug representation of a transport failure.

    RedirectError

    pub(all) suberror RedirectError {
    Transport(HttpError)
    TooManyRedirects(max~ : Int, location~ : String)
    Downgrade(location~ : String)
    BadLocation(location~ : String, reason~ : String)
    UnreplayableBody(location~ : String)
    } derive(
    Debug
    )

    Why a redirect was not followed. A separate error rather than new HttpError variants, which would break exhaustive matches downstream; redirects reports these as HttpError::Protocol.

    RedirectError::to_repr

    Debug representation of a refused redirect.

    UrlError

    pub(all) suberror UrlError {
    Invalid(String)
    } derive(Eq,
    Debug
    )

    Why a string is not a URI reference this package accepts.

    UrlError::equal

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

    Compares refusals by their reason.

    UrlError::not_equal

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

    Compares refusals by their reason.

    UrlError::to_repr

    Debug representation of a refused URL.

    Body

    pub(all) enum Body {
    Bytes(Bytes)
    Stream(StreamBody)
    } derive(Eq,
    Debug
    )

    A request body: bytes in memory, or a stream read as the request is sent.

    test {
    let request = @http.Request::post("/upload").body_bytes(b"abc")
    assert_eq(request.body, Bytes(b"abc"))
    assert_eq(request.body.length(), Some(3L))
    assert_true(request.body.is_replayable())
    }

    Body::as_bytes

    fn Body::as_bytes(self : Body) -> Bytes?

    The bytes of a body held in memory, or None for a stream (as reqwest's Body::as_bytes).

    Body::equal

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

    Equality: equal bytes, or the same StreamBody.

    Body::is_replayable

    fn Body::is_replayable(self : Body) -> Bool

    Whether a sender may send this body (again): always for bytes, and for a stream only while no transport has taken it.

    Body::length

    fn Body::length(self : Body) -> Int64?

    The number of bytes the body will send, when it is known: the length of the bytes, or the length a stream was declared with.

    Body::not_equal

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

    Equality: equal bytes, or the same StreamBody.

    Body::to_repr

    Debug representation of a body; a stream shows its length, not its bytes.

    Headers

    pub struct Headers {
    // private fields
    } derive(Eq,
    Debug
    )

    Mutable, ordered header pairs with ASCII lowercase names. Equality compares normalized pairs, including their order.

    test {
    let headers = @http.Headers::from_array([("Accept", "text/plain")])
    assert_eq(headers.get("ACCEPT"), Some("text/plain"))
    }

    Headers::append

    fn Headers::append(self : Headers, name : String, value : String) -> Unit

    Appends one pair, retaining all existing values.

    Headers::contains

    fn Headers::contains(self : Headers, name : String) -> Bool

    Reports whether at least one value exists for a name.

    Headers::each

    fn Headers::each(self : Headers, f : (String, String) -> Unit raise?) -> Unit raise?

    Visits a snapshot of normalized pairs in insertion order.

    Headers::equal

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

    Explicit equality methods for normalized, ordered pairs.

    Headers::from_array

    fn Headers::from_array(pairs : Array[(String, String)]) -> Headers

    Copies pairs in their original order and normalizes their names.

    Headers::get

    fn Headers::get(self : Headers, name : String) -> String?

    Returns the first value for a name, ignoring ASCII case.

    Headers::get_all

    fn Headers::get_all(self : Headers, name : String) -> Array[String]

    Returns a fresh array containing every matching value in insertion order.

    Headers::iter

    fn Headers::iter(self : Headers) -> Iter[(String, String)]

    Iterates over a snapshot of normalized pairs in insertion order.

    Headers::length

    fn Headers::length(self : Headers) -> Int

    Returns the number of pairs, counting repeated names separately.

    Headers::new

    fn Headers::new() -> Headers

    Creates an empty header collection.

    Headers::not_equal

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

    Explicit equality methods for normalized, ordered pairs.

    Headers::remove

    fn Headers::remove(self : Headers, name : String) -> Unit

    Removes all pairs for a name, ignoring ASCII case.

    Headers::set

    fn Headers::set(self : Headers, name : String, value : String) -> Unit

    Replaces all values for a name at its first position, or appends a new name.

    Headers::to_repr

    Debug representation of the header pairs.

    Phase

    pub(all) enum Phase {
    Pool
    Dial
    Write
    Head
    Body
    Unknown
    } derive(Eq,
    Debug
    )

    The step of an exchange a transport failure happened in.

    Phase::equal

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

    Equality for phases.

    Phase::not_equal

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

    Equality for phases.

    Phase::to_repr

    Debug representation of a phase.

    RedirectHop

    pub struct RedirectHop {
    request : Request
    response : Response
    next : Request
    } derive(
    Debug
    )

    One redirect followed: the request, the 3xx response it got, and the request sent next.

    RedirectHop::to_repr

    Debug representation of a hop.

    Request

    pub(all) struct Request {
    http_method : String
    url : String
    headers : Headers
    body : Body
    } derive(Eq,
    Debug
    )

    An HTTP request. Builders copy headers before returning.

    test {
    let request = @http.Request::post("/items")
    .header("Accept", "application/json")
    .body_text("hello")
    assert_eq(request.body, Bytes(b"hello"))
    }

    Request::body_bytes

    fn Request::body_bytes(self : Request, body : Bytes) -> Request

    Returns a new request with the supplied bytes and independent headers.

    Request::body_stream

    fn Request::body_stream(self : Request, stream : &BodyStream, length? : Int64) -> Request

    Returns a new request whose body is read from stream as it is sent, and independent headers. With length, the stream must produce exactly that many bytes, sent as Content-Length; without it, a transport sends the body chunked. The body can be sent once: see StreamBody.

    Request::body_text

    fn Request::body_text(self : Request, body : String) -> Request

    Returns a new request whose body is the UTF-8 encoding of the text, with any lone surrogate written as U+FFFD (see encode_utf8).

    Request::delete

    fn Request::delete(url : String) -> Request

    Creates a DELETE request.

    Request::equal

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

    Compares all request fields.

    Request::form_body

    fn Request::form_body(self : Request, pairs : ArrayView[(String, String)]) -> Request

    Encodes pairs with form_urlencode as the body and supplies application/x-www-form-urlencoded only if content-type is absent.

    Request::get

    fn Request::get(url : String) -> Request

    Creates a GET request.

    Request::header

    fn Request::header(self : Request, name : String, value : String) -> Request

    Returns a new request with an appended header and independent headers.

    Request::json_body

    fn Request::json_body(self : Request, body : Json) -> Request

    Encodes JSON as UTF-8 and supplies application/json only if content-type is absent.

    Request::new

    fn Request::new(http_method : String, url : String) -> Request

    Creates a request with the supplied method, empty headers and an empty body.

    Request::not_equal

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

    Compares all request fields.

    Request::patch

    fn Request::patch(url : String) -> Request

    Creates a PATCH request.

    Request::post

    fn Request::post(url : String) -> Request

    Creates a POST request.

    Request::put

    fn Request::put(url : String) -> Request

    Creates a PUT request.

    Request::to_repr

    Debug representation of a request.

    Response

    pub(all) struct Response {
    status : Int
    headers : Headers
    body : Bytes
    } derive(Eq,
    Debug
    )

    An HTTP response. Error status codes are ordinary responses.

    test {
    let response : @http.Response = {
    status: 200,
    headers: @http.Headers::new(),
    body: b"hello",
    }
    assert_eq(response.text(), "hello")
    assert_true(response.is_success())
    }

    Response::equal

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

    Compares all response fields.

    Response::is_success

    fn Response::is_success(self : Response) -> Bool

    Reports whether the status is in the inclusive range 200 through 299.

    Response::json

    fn Response::json(self : Response) -> Json raise

    Parses the strictly decoded body, raising on malformed UTF-8 or invalid JSON.

    Response::not_equal

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

    Compares all response fields.

    Response::text

    Strictly decodes the UTF-8 body, raising on malformed input.

    Response::to_repr

    Debug representation of a response.

    ResponseHead

    pub(all) struct ResponseHead {
    status : Int
    headers : Headers
    } derive(Eq,
    Debug
    )

    Status and headers available before consuming a streamed body.

    ResponseHead::equal

    Compares status and headers.

    ResponseHead::not_equal

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

    Compares status and headers.

    ResponseHead::to_repr

    Debug representation of a response head.

    StreamBody

    pub struct StreamBody {
    // private fields
    }

    A request body read once from a BodyStream, with its length when known.

    A transport sends a known length as Content-Length and an unknown one, on HTTP/1.1, as Transfer-Encoding: chunked. The transport that sends the body takes it (take) and closes the stream when it is done with it, sent or failed; a stream that is never taken stays open and is its creator's to close. Two stream bodies are equal only when they are the same one.
    impl Eq for StreamBody
    impl Debug for StreamBody

    StreamBody::equal

    fn StreamBody::equal(self : StreamBody, other : StreamBody) -> Bool

    Identity: the same stream body, not an equal one.

    StreamBody::length

    fn StreamBody::length(self : StreamBody) -> Int64?

    The declared length, if any.

    StreamBody::new

    fn StreamBody::new(source : &BodyStream, length? : Int64) -> StreamBody

    Wraps source, declared to produce exactly length bytes when given.

    StreamBody::not_equal

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

    Identity: the same stream body, not an equal one.

    StreamBody::take

    fn StreamBody::take(self : StreamBody) -> &BodyStream raise HttpError

    Hands the stream to the one sender that reads it. The returned stream raises Protocol when the source produces more bytes than the declared length, or ends before it; its close closes the source.

    A second call raises Protocol, since the bytes already read are gone. A negative declared length raises Protocol before anything is read, and closes the source, which no sender will take.

    StreamBody::to_repr

    Debug representation of a stream body.

    StreamRedirectHop

    pub struct StreamRedirectHop {
    request : Request
    head : ResponseHead
    next : Request
    } derive(
    Debug
    )

    One redirect followed by follow_redirects_stream: the request, the head of the 3xx response it got (its body was closed unread), and the request sent next.

    StreamRedirectHop::to_repr

    Debug representation of a hop.

    Url

    pub struct Url {
    scheme : String?
    userinfo : String?
    host : String?
    port : Int?
    path : String
    query : String?
    fragment : String?
    } derive(Eq,
    Debug
    )

    A URI reference (RFC 3986 §4.1), held normalised and percent-encoded.

    Url::parse lower-cases the scheme and host, writes an IPv6 address in RFC 5952's canonical form, drops a scheme's default port (http 80, https 443, ws 80, wss 443, ftp 21), percent-encodes what the WHATWG URL Standard's sets encode (non-ASCII as UTF-8), and removes dot segments from the path of a reference with a scheme. Existing escapes are kept as written. No scheme makes the reference relative; no host means no authority, which is not an empty host. A host is ASCII only: internationalised domain names (IDNA) are refused, so pass their xn-- form.

    test {
    let base = @http.Url::parse("HTTPS://Example.COM:443/a/b?x=1")
    assert_eq(base.to_string(), "https://example.com/a/b?x=1")
    let next = base.resolve(@http.Url::parse("../c d"))
    assert_eq(next.to_string(), "https://example.com/c%20d")
    }
    impl Show for Url

    Url::append_query_params

    fn Url::append_query_params(self : Url, params : Array[(String, String)]) -> Url

    Appends encoded pairs after the existing query, keeping it as written. Names and values keep only RFC 3986 unreserved characters, as percent_encode does.

    test {
    let url = @http.Url::parse("https://h/p?a=1#top")
    let next = url.append_query_params([("b", "x y"), ("a", "2")])
    assert_eq(next.to_string(), "https://h/p?a=1&b=x%20y&a=2#top")
    }

    Url::equal

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

    Compares all components; parsing has already normalised them.

    Url::is_absolute

    fn Url::is_absolute(self : Url) -> Bool

    Reports whether the reference has a scheme.

    Url::merge_query_params

    fn Url::merge_query_params(self : Url, params : Array[(String, String)]) -> Url

    Replaces every existing pair whose decoded name is among params' names, then appends params in order, as httpx's merge_params does. The pairs kept are kept as written.

    test {
    let url = @http.Url::parse("https://h/p?page=1&q=a+b&page=2")
    let next = url.merge_query_params([("page", "3")])
    assert_eq(next.to_string(), "https://h/p?q=a+b&page=3")
    }

    Url::not_equal

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

    Compares all components; parsing has already normalised them.

    Url::output

    fn Url::output(self : Url, logger : &Logger) -> Unit

    The serialised reference.

    Url::parse

    fn Url::parse(text : String) -> Url raise UrlError

    Parses a URI reference, absolute or relative.

    Leading and trailing C0 controls and spaces are trimmed and tabs and line breaks removed, as browsers do. Only the authority can be refused: a port that is not a decimal number up to 65535, an IP literal that is not an IPv6 address (zone identifiers and IPvFuture included), or a host with a character outside RFC 3986's reg-name — non-ASCII and percent-encoded hosts among them, since IDNA is out of scope. A backslash is data, not a separator, and is percent-encoded.

    Url::port_or_default

    fn Url::port_or_default(self : Url) -> Int?

    The port, or the scheme's default when the URL names none.

    Url::query_params

    fn Url::query_params(self : Url) -> Array[(String, String)]

    The query's name-value pairs, decoded as application/x-www-form-urlencoded: + is a space, escapes are UTF-8 (malformed ones are kept literally), empty pairs are skipped and a pair without = has an empty value.

    Url::resolve

    fn Url::resolve(self : Url, reference : Url) -> Url

    Resolves reference against this base (RFC 3986 §5.2.2, strict): a reference with a scheme, even the base's own, stays absolute. The base should be absolute; RFC 3986 defines no result otherwise.

    test {
    let base = @http.Url::parse("http://a/b/c/d;p?q")
    assert_eq(base.resolve(@http.Url::parse("../g")).to_string(), "http://a/b/g")
    assert_eq(base.resolve(@http.Url::parse("//g")).to_string(), "http://g")
    }

    Url::same_origin

    fn Url::same_origin(self : Url, other : Url) -> Bool

    Reports whether both URLs have the same scheme, host and port, counting the scheme's default port. A URL without a scheme or host has no origin and matches nothing.

    Url::to_repr

    Debug representation of the components.

    Url::to_string

    fn Url::to_string(self : Url) -> String

    The serialised reference.

    Url::without_fragment

    fn Url::without_fragment(self : Url) -> Url

    The same URL without its fragment, which is never sent to a server.

    append_query

    fn append_query(url : String, name : String, value : String) -> String

    Appends an encoded query pair, using ampersand if the URL contains ?.

    encode_utf8

    fn encode_utf8(text : String) -> Bytes

    Encodes text as UTF-8, writing each lone surrogate as U+FFFD, as the WHATWG Encoding Standard's UTF-8 encoder and TextEncoder do. @utf8.encode aborts the process on a lone surrogate instead, so it is unsafe for strings that came from outside.

    test {
    let lone = String::from_array([(0xD800).unsafe_to_char()])
    assert_eq(@http.encode_utf8("é" + lone), b"\xC3\xA9\xEF\xBF\xBD")
    }

    follow_redirects

    async fn follow_redirects(request : Request, send : async (Request) -> Response raise HttpError, max_redirects? : Int, allow_downgrade? : Bool, credential_headers? : Array[String], on_redirect? : (RedirectHop) -> Unit) -> Response raise RedirectError

    Sends request through send, following 301, 302, 303, 307 and 308 responses that carry a Location, at most max_redirects times.

    The Location is resolved against the request URL (RFC 3986 §5.2) and its fragment is not sent. 303 turns any method but HEAD into GET, 301 and 302 turn POST into GET; 307 and 308 keep the method and body. A redirect that would send a stream body again raises UnreplayableBody, since the stream was read by the first request; a request turned into GET does not need it. A request turned into GET loses its body and the headers describing it (Content-Length, Content-Type, Transfer-Encoding, and Fetch's Content-Encoding, Content-Language, Content-Location). When the origin changes, Authorization, Cookie, Proxy-Authorization, Host and the credential_headers are removed, except on an upgrade from http on port 80 to https on port 443 of the same host, which httpx also allows. Removed headers stay removed for the rest of the chain.

    An https request redirected to http raises Downgrade unless allow_downgrade is set; credentials are then stripped as for any origin change. A response without a Location is returned as it is. on_redirect sees each hop before its request is sent; the step keeps none of them, so a caller collecting a history holds at most max_redirects hops.

    follow_redirects_stream

    async fn follow_redirects_stream(request : Request, send_stream : async (Request) -> (ResponseHead, &BodyStream) raise HttpError, max_redirects? : Int, allow_downgrade? : Bool, credential_headers? : Array[String], on_redirect? : (StreamRedirectHop) -> Unit) -> (ResponseHead, &BodyStream) raise RedirectError

    follow_redirects for streamed responses, with the same rules and limit: sends through send_stream and returns the head and body of the first response that is not a redirect to follow, unread.

    Each 3xx body is closed unread before the next request goes out, and on every error after its head arrived, so only the returned stream stays open. A closed, undrained stream is not reused by a pooling transport, which costs a connection per hop but never reads a redirect body of unknown size.

    form_urlencode

    fn form_urlencode(pairs : ArrayView[(String, String)]) -> String

    Serializes name/value pairs, in order, as an application/x-www-form-urlencoded body with the WHATWG URL Standard's urlencoded serializer, as browsers and URLSearchParams do: a space becomes +, and every UTF-8 byte other than an ASCII alphanumeric or one of *-._ is percent-encoded in uppercase hex. This differs from percent_encode, which keeps ~, encodes * and writes a space as %20. A lone surrogate is encoded as U+FFFD.

    test {
    assert_eq(
    @http.form_urlencode([("q", "a b&c"), ("lang", "日本")]),
    "q=a+b%26c&lang=%E6%97%A5%E6%9C%AC",
    )
    }

    is_idempotent

    fn is_idempotent(request : Request) -> Bool

    Reports whether the method is idempotent or an idempotency key is present.
    fn parse_link_next(headers : Headers) -> String?

    Returns the target of the first RFC 8288 Link value whose relation includes next, across repeated headers and comma-separated link values.

    parse_retry_after

    fn parse_retry_after(value : String, now_unix_ms? : Int64) -> Int?

    Parses nonnegative seconds (including decimals) or an IMF-fixdate with a clock. Results are truncated to milliseconds and saturated at Int max.

    test {
    assert_eq(@http.parse_retry_after("1.2349"), Some(1234))
    }

    percent_encode

    fn percent_encode(value : String) -> String

    Percent-encodes UTF-8 bytes, preserving only RFC 3986 unreserved bytes.

    percent_encode_path

    fn percent_encode_path(value : String) -> String

    Percent-encodes a multi-segment path, also preserving slash separators.

    redirects

    fn redirects(max_redirects? : Int, allow_downgrade? : Bool, credential_headers? : Array[String], on_redirect? : (RedirectHop) -> Unit) -> (async (Request, async (Request) -> Response raise HttpError) -> Response raise HttpError)

    follow_redirects as a middleware step. A refused redirect raises HttpError::Protocol with the reason, a transport failure its own error; call follow_redirects directly to match on RedirectError.

    test {
    let _ : @http.Middleware = @http.redirects(max_redirects=5)
    }

    retry_after_ms

    fn retry_after_ms(headers : Headers, now_unix_ms? : Int64) -> Int?

    Prefers the first retry-after-ms value; falls back to retry-after when that header is absent or unparseable, as the official OpenAI SDKs do.

    send_with

    async fn send_with(transport : &Transport, middleware : Array[async (Request, async (Request) -> Response raise HttpError) -> Response raise HttpError], request : Request) -> Response raise HttpError

    Sends through middleware in array order, with the first element outermost.