This version of the module is deprecated: Renamed to gaato/mttp 0.3, the module an app adds; the I/O-free core is gaato/mttp_core. See https://github.com/gaato/mttp

    mttp_async

    HTTP for MoonBit apps that outgrow the built-in client: the module an app starts with, holding a ready client (retries with a budget, deadlines, redirects, cookies, decompression) and a server, on moonbitlang/core/async for native, wasm and js. Typed endpoints and OpenAPI come from gaato/mttp, which libraries depend on instead.

    http
    client
    server
    retry
    sse
    async
    Download zip
    Author
    Version
    0.1.0
    License
    Apache-2.0
    Last updated
    9 hours ago
    Downloads
    43

    #gaato/mttp_async

    HTTP for MoonBit apps that need more than the standard library's client: an API client with retries under a budget, deadlines, redirects, cookies, decompression and typed failures, plus an HTTP/1.1 server, on moonbitlang/core/async for native, wasm and js. It is the module an app starts with; an app that calls APIs and serves plain handlers needs no other.

    Add gaato/mttp to the app as well when it uses the parts without I/O that this module does not re-export: typed endpoints, the router and OpenAPI documents, multipart forms, rate limits such as WindowLimiter, the request attributes @mttp.background and @mttp.remote_addr, or the test doubles of gaato/mttp/testing.

    Writing a library, such as an SDK for some API? Depend on gaato/mttp only: it has the same types and client without any I/O, so it builds on every target and the app chooses how requests are sent.

    #Install

    moon add gaato/mttp_async

    #Call an API

    ///|
    pub async fn show_user() -> Unit {
    let api = @mttp_async.client(base_url="https://api.example.com")
    defer api.close()
    let user = @mttp_async.get("/users/1").response(@mttp_async.as_json_value())
    match api.send(user).body {
    Ok(json) => println(json.stringify())
    Err(failure) => println(failure)
    }
    }

    Start it from fn main with run; the app imports neither moonbitlang/core/async nor its experimental opt-in:

    ///|
    fn main {
    @mttp_async.run(show_user)
    }

    get, post and request build a request; as_json, as_json_value, as_string, as_bytes, as_lines and as_sse say how to read the reply. An HTTP failure, such as a 404 or a body that does not decode, is a value in reply.body. A network failure is raised as a TransportError that says in which phase it happened and whether the request was sent; catch it where the app can do something about it:

    ///|
    /// Fetches a user and prints it, the status that stopped it, or the network
    /// failure.
    pub async fn print_user(id : Int32) -> Unit {
    let api = @mttp_async.client(base_url="https://api.example.com")
    defer api.close()
    let reply = api.send(
    @mttp_async.get("/users/\{id}").response(@mttp_async.as_json_value()),
    ) catch {
    error => {
    println("network: \{error}")
    return
    }
    }
    match reply.body {
    Ok(user) => println(user.stringify())
    Err(failure) => println("HTTP: \{failure}")
    }
    }

    #What client() does by default

    DefaultChange it with
    RetriesUp to two, for idempotent requests on 408, 409, 429, 500, 502, 503 and 504 and on network failures, and for any request that was never sent; Retry-After is honoured, and a server that asks to wait more than 60 s is not retried; backoff is exponential with jitterretry=RetryPolicy::none() or another policy
    Retry budgetRetries stay within 20 % of the requests of the last 10 s, plus 10 per second, so a failing server is not floodedretry_budget=RetryBudget::new(..) or RetryBudget::unlimited()
    Deadlines30 s per attempt, 120 s for the whole call, retries includedtimeout_ms=, total_ms=
    RedirectsUp to 10 hops, never from HTTPS to HTTP, credentials dropped across originsredirect=, redirect=RedirectConfig::none() to turn them off, or .follow_redirects(false) on a request
    CookiesNone keptcookies= a CookieJar
    Decompressiongzip, deflate, br and zstdper request, by setting Accept-Encoding
    Body sizeBuffered bodies up to 64 MiB after decodingmax_body_bytes=
    TLSThe system's rootstrust=PemFile(..)
    Network, clock and timersThe real onesbackend=, clock=, timer=, for tests (see Tests)

    #Cookies

    A Client keeps no cookies unless it is given a jar. With cookies=, every response's Set-Cookie is stored, and matching cookies are sent with each request, on every redirect hop too, so a login that answers with a redirect and a cookie works as in a browser:

    ///|
    /// Logs in, then reads a page that needs the session cookie.
    pub async fn account_page(token : String) -> String? {
    let jar = @mttp_async.CookieJar::new(@mttp_async.clock())
    let site = @mttp_async.client(base_url="https://shop.example", cookies=jar)
    defer site.close()
    ignore(site.send(@mttp_async.post("/login").body_json({ "token": token })))
    site.send(@mttp_async.get("/account")).body.to_option()
    }

    The jar belongs to the caller: several Clients may share it, and it outlives them. A Client built with Client::new takes one the same way, cookies=. The jar does not apply browsers' SameSite rules.

    #With an API SDK

    An SDK built on gaato/mttp (such as gaato/openai or gaato/anthropic) creates its own Client with the provider's base URL, authentication and retry rules. Give it the I/O with backend(), clock() and timer(); the timer lets it enforce its deadlines. backend() is the network alone, HTTP/1.1 with decompression; the SDK's Client follows redirects itself, as every Client does:

    ///|
    let claude = @anthropic.Anthropic::new(
    api_key~,
    @mttp_async.backend(),
    @mttp_async.clock(),
    timer=@mttp_async.timer(),
    )

    The app's moon.mod lists the SDK and gaato/mttp_async.

    #Serve

    gaato/mttp_async/server serves any async (Request) -> Response handler on native and wasm, with bounded connections, headers and bodies and a graceful shutdown:

    @server.serve(request => @mttp_async.Response::new(200), addr="127.0.0.1:8080")

    A request body arrives as a stream. Body::read_all(max~) reads it into memory and refuses more than max bytes:

    ///|
    /// Echoes a request body of up to 64 KiB; a larger one gets 413.
    pub async fn echo(
    request : @mttp_async.Request,
    ) -> @mttp_async.Response noraise {
    let body = request.body.read_all(max=65536) catch {
    TransportError(kind=TooLarge(..), ..) =>
    return @mttp_async.Response::new(413)
    _ => return @mttp_async.Response::new(400)
    }
    @mttp_async.Response::new(200, body=Bytes(body))
    }

    The server binds with SO_REUSEADDR, as Go's and Node's servers do, so a restarted process can listen again while the old one's connections wait out TIME-WAIT. On Linux a second server still cannot take a port that is being listened on; on macOS and the BSDs a bind to a specific address can coexist with an earlier wildcard bind of the same port.

    An app with forms and a cookie session adds the router's same_origin middleware, which refuses state-changing requests from other sites, and sets its cookies SameSite. On JavaScript hosts, gaato/mttp_async/fetch turns the same handler into a fetch export for Cloudflare Workers, Deno, Bun, Node, Fastly, Vercel and AWS Lambda. gaato/mttp_async/files serves a directory's files, such as stylesheets and scripts, as one more handler, alone or behind a Router's 404s.

    #A complete app

    A typed endpoint validates its request and documents itself for OpenAPI; a Router serves a set of them as one handler. They come from gaato/mttp, so the app's moon.mod lists both modules (moon add gaato/mttp), and its moon.pkg imports what it uses:

    import { "gaato/mttp_async", "gaato/mttp_async/server", "gaato/mttp/endpoint", "gaato/mttp/router", "gaato/mttp/schema", } pkgtype(kind: "executable")

    ///|
    /// GET /hello/{name} answers with a greeting.
    let hello : @endpoint.ServerEndpoint = @endpoint.endpoint
    .get("/hello/{name}")
    .input(@endpoint.path("name", @schema.string))
    .output(@endpoint.text_out())
    .serve_public(name => Ok("hello, \{name}"))

    ///|
    let app : @router.Router = @router.Router::must([hello])

    ///|
    pub async fn serve_app(addr : String) -> Unit {
    @server.serve(app.handler(), addr~, on_listen=bound => println(bound))
    }

    ///|
    fn main {
    @mttp_async.run(async fn() { serve_app("127.0.0.1:8080") })
    }

    The repository has runnable examples: examples/get (a command-line GET), examples/serve (a server) and examples/fetch (the same handlers on JavaScript hosts, with a Cloudflare Workers app).

    #Tests

    Code that takes its Client as a parameter can be tested without the network: give it the app's own client() over BackendStub from gaato/mttp/testing, which answers with canned responses and records what was sent. backend= replaces only the network, so the Client keeps every default above: retries, deadlines, redirects and cookies. The test package imports gaato/mttp/testing (gaato/mttp in moon.mod), and a plain test block runs the async code with run, again without importing core/async:

    ///|
    /// Reads a user's name; the Client is a parameter, so a test can pass a stub.
    pub async fn user_name(api : @mttp_async.Client, id : Int32) -> String? {
    let user = @mttp_async.get("/users/\{id}").response(
    @mttp_async.as_json_value(),
    )
    match api.send(user).body {
    Ok({ "name": String(name), .. }) => Some(name)
    _ => None
    }
    }

    ///|
    test "user_name reads the name from the reply" {
    let stub = @testing.BackendStub::new().when(
    request => request.url == "https://api.example/users/1",
    @testing.Canned::bytes(200, body=b"{\"name\":\"Ada\"}"),
    )
    let api = @mttp_async.client(base_url="https://api.example", backend=stub)
    let name : Ref[String?] = Ref(None)
    @mttp_async.run(async fn() { name.val = user_name(api, 1) })
    assert_eq(name.val, Some("Ada"))
    assert_eq(stub.sent().length(), 1)
    }

    A test of retries or deadlines also passes clock= a virtual clock, such as VirtualClock from gaato/mttp_async/runtime; the Client's timers then run on it too, so 30 s pass without waiting.

    Check results after run, as above, not inside it. A failing inspect or assert_eq inside run reports only its error's name, InspectError, without the difference, and moon test --update cannot rewrite its snapshot, because core/async's run_async_main prints nothing more of the error that ends it.

    To test a handler, call it: it is an async function from Request to Response. To test a server end to end, serve it on 127.0.0.1:0 and read the bound address in on_listen.

    Running work concurrently (spawning tasks, task groups, async test) is moonbitlang/core/async's job: import it and list it in the package's allow-experimental-packages. This module does not re-export it, because its public API names no runtime types.

    #Targets

    native, wasm (under moonrun) and js. On js the client uses the host's fetch, which decides proxies, pooling and decompression itself. wasm-gc has no runtime here; use gaato/mttp there and provide its runtime ports.

    #Packages

    PackageTargetsPurpose
    gaato/mttp_asyncnative, wasm, jsrun, client(), backend(), clock(), timer(), coordination(), and re-exports of the types and client functions an app uses
    backendnative, wasm, jsAsyncBackend: the HTTP/1.1 engine over TCP and TLS with decompression; the host's fetch on js
    servernative, wasmserve: bounded listener, graceful shutdown, background work
    filesnative, wasmstatic_files: a directory's files for GET and HEAD, streamed, with ETag revalidation and paths kept inside the directory
    fetchjsfetch_handler: a handler as a JavaScript fetch export
    runtimenative, wasm, jsClocks, timers, an admission gate and coordination for gaato/mttp's runtime ports
    codingsnative, wasm, jsBounded content codecs, Decompress for clients and compress for handlers
    connnative, wasmTCP and TLS dialing, including CONNECT tunnels
    trustnative, wasm, jsTrust: which TLS roots a socket backend accepts

    The module also ships host shims (hosts/), JavaScript files that connect a fetch export to Workers, Deno, Bun, Node, Fastly, Vercel and Lambda; they are not a MoonBit package.

    #License

    Apache-2.0

    Body

    using @gaato/mttp { type Body }

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

    test {
    let request = @mttp.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())
    }

    Client

    using @gaato/mttp/client { type Client }

    A backend plus shared request defaults, policies and injected timing services. Client::send is the only sending path: it applies the defaults, admission, deadlines, decoded-body limits, redirects, cookies and retries, and maps each plan onto the SendOptions the backend receives.

    CookieJar

    An RFC 6265bis-22 cookie store for an HTTP client: store takes the Set-Cookie headers of a response, header_for builds the Cookie header of a request.

    It is a client's jar, not a browser's: HttpOnly cookies are sent like any other, and SameSite is kept but not enforced, since a client has no notion of a cross-site request. Without the Public Suffix List it rejects only a Domain of a single label (com, localhost); pass is_public_suffix to reject more. An IP-address host only gets host-only cookies. Hosts must arrive in ASCII form, so a Domain with other characters is ignored.

    A host written with the root dot (example.com.) is a different host from example.com, as in browsers: neither sets nor receives the other's cookies. The public-suffix checks read a Domain without its root dot, so Domain=com. is refused as a single label, and a Domain with an empty label (a..b, com..) is ignored.

    test {
    let jar = @cookie.CookieJar::new(@sim.FakeClock::new())
    jar.store(
    @mttp.Headers::of([("Set-Cookie", "sid=1; Path=/; Secure")]),
    "https://example.com/login",
    )
    assert_eq(jar.header_for("https://example.com/"), Some("sid=1"))
    assert_eq(jar.header_for("http://example.com/"), None)
    }

    Headers

    using @gaato/mttp { type Headers }

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

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

    Method

    using @gaato/mttp { type Method }

    Standard HTTP methods and extension tokens.

    RedirectConfig

    How a Client (its redirect argument) or a FollowRedirects wrapper follows redirects. Build it from RedirectConfig::default() with a record update, or take RedirectConfig::none(); the field set is frozen.

    Reply

    using @gaato/mttp/client { type Reply }

    A decoded response, preserving the actual HTTP and request metadata.

    Request

    using @gaato/mttp { type Request }

    url is the request target as given: an absolute URL or a relative reference ("/users/1?x=2"). Backends parse it once at send time (Kind::Invalid, phase Pool, so it is unsent); Client joins a relative target onto its base URL by prefix (sdk-runtime semantics, not RFC 3986).

    RequestPlan

    A wire request, response description and per-request options.

    Response

    using @gaato/mttp { type Response }

    One response type for both directions. A server closes the connection after a response carrying Connection: close (413 does). attributes is the response-side channel: FollowRedirects records the final URL and history there, and the router records the matched route template.

    ResponseFailure

    A received response that is not the value the description asked for. It is a value: readers return it as Err, and transport failures stay raised TransportErrors.

    ResponsePlan

    An immutable response description. Selection after the head arrives decides the actual lifetime; the marker describes possible streaming.

    RetryBudget

    A cap on retries as a share of recent requests, shared by every send of a Client. When a server fails, every caller's retries would otherwise multiply its load just as it tries to recover.

    Each request deposits one token; each retry withdraws one. A retry goes ahead while the withdrawals of the last window_ms stay below min_per_second times the window in seconds plus percent of the deposits in the window. Time comes from the Client's clock. Pass the same budget to several Clients to share it.

    RetryPolicy

    A retry policy for Client: a RetryRule of gaato/mttp/client/retry applied to HTTP outcome snapshots.

    Start from a preset — conservative() (the default), none() — or from any rule with from_rule, and adjust it with with_max_retries, allow_non_idempotent or a struct update of rule.

    TransportError

    A failure with its category, phase and diagnostic description.

    Trust

    TLS peer verification for socket backends. JavaScript fetch uses the host's trust store and ignores this setting. PemFile replaces system roots; supply a combined PEM bundle to trust private and system roots.

    as_bytes

    Buffers successful bodies; non-2xx responses are bounded failure values.

    as_json_value

    Parses JSON; status and decoding failures are received-response values.

    as_lines

    Streams LF-separated UTF-8 lines, stripping CR in CRLF and replacing malformed UTF-8. A final nonempty unterminated line is delivered; a lone CR is data. Limits content bytes per line (default one MiB), retaining at most that limit plus a pending CR. Callback errors close the body and never retry an already-delivered stream.

    as_sse

    Reads bounded SSE events until EOF or a protocol stop predicate matches. An event over max_event_bytes raises TooLarge(limit) in phase Body as soon as the chunk holding it is read, after the events before it in that chunk were delivered.

    as_string

    Decodes UTF-8 lossily while preserving HTTP failures as values.

    backend

    The real network as a Backend: HTTP/1.1 over TCP and TLS with connection reuse and decompressed responses. It is only the I/O: the Client built over it follows redirects, and keeps cookies when given a jar, as Client::new does over any backend. Pass it, with clock and timer, to a library that builds its own Client, such as an API SDK:

    let claude = @anthropic.Anthropic::new(
    api_key~,
    @mttp_async.backend(),
    @mttp_async.clock(),
    timer=@mttp_async.timer(),
    )

    The libraries keep their own defaults (base URL, authentication, retries) and follow redirects through Client::new; this only supplies the I/O. Closing the library's Client closes the connections.

    client

    A Client over the real network with the defaults an API client wants:

    • retry (default RetryPolicy::conservative()): up to two retries of an idempotent request on 408, 409, 429, 500, 502, 503 or 504 or a network failure, and of any request that was never sent, honouring Retry-After (a server that asks to wait more than 60 s is not retried); set it to RetryPolicy::none() to turn retries off.
    • retry_budget (default RetryBudget::new()): retries stay within 20 % of the last 10 s of requests plus 10 per second.
    • timeout_ms (default 30 s) for each attempt, and total_ms (default 120 s) for the whole call, retries and their waits included.
    • redirect (default RedirectConfig::default()): up to 10 hops, no HTTPS-to-HTTP downgrade, credentials dropped across origins; RedirectConfig::none() turns redirects off, and a request opts out with RequestPlan::follow_redirects(false).
    • Responses are decompressed (gzip, deflate, br, zstd) and buffered bodies are limited to 64 MiB after decoding (max_body_bytes).
    • cookies (no jar by default): a CookieJar that stores every response's Set-Cookie and sends matching cookies, on each redirect hop too. The jar belongs to the caller, so several Clients may share it and it outlives them:

    let jar = @mttp_async.CookieJar::new(@mttp_async.clock())

    let site = @mttp_async.client(cookies=jar)

    base_url is joined to each relative request target, headers are sent with every request, limiter admits requests (no limit by default), trust chooses the TLS roots and config tunes the connection pool. Close the Client when done; that closes its connections.

    backend, clock and timer replace the real network, the system clock and the event loop's timers, so a test gets the same Client, with all the defaults above, over a stub such as @testing.BackendStub and a virtual clock. timer defaults to a timer on clock, so a virtual clock drives the deadlines too; without backend, the network backend runs its connection deadlines and pool expiry on the same clock and timer. A given backend is used as it is: trust and config configure only the network backend this builds when backend is omitted, and are ignored otherwise.

    clock

    The system's wall and monotonic clock, for a Client built by hand.

    coordination

    Signals on core/async's event loop, for the parts of gaato/mttp that wait without doing I/O themselves, such as @endpoint.EventStream::channel.

    get

    Creates a GET request with the default Result-valued string reader.

    post

    Creates a POST request with the default Result-valued string reader.

    request

    Pairs a wire request with a UTF-8 string reader accepting successful statuses.

    run

    fn run(main : async () -> Unit) -> Unit

    Runs main on moonbitlang/core/async's event loop and returns when it and every task it started have finished. An app calls it from a plain fn main, so it needs neither an import of core/async nor its experimental opt-in:

    fn main {
    @mttp_async.run(async fn() { ... })
    }

    timer

    Timers on core/async's event loop, which let a Client enforce its per-attempt and total deadlines.