flare_mlake · API · http

flare.http.client

HTTP/1.1 client with optional TLS support.

Implements a minimal but correct subset of HTTP/1.1 (RFC 7230/7231):

  • Content-Length-delimited responses
  • Transfer-Encoding: chunked responses
  • Connection: close (read until EOF) responses
  • Up to _max_redirects automatic 3xx redirects
  • Automatic Host, User-Agent, Connection headers
  • Optional Authorization header via the Auth trait

Module-level convenience functions (get, post, put, delete, head) create a one-shot HttpClient per call and are suitable for quick scripts. For multiple requests, prefer instantiating a shared HttpClient.

post and put accept a String body (sets Content-Type: application/json automatically), a json.Value body (serialised with dumps first), or a List[UInt8] body (sent as raw bytes with no implicit Content-Type).

Example: ```mojo from flare.http import get, post, HttpClient, BasicAuth, BearerAuth, Status

# One-shot GET
var resp = get("https://httpbin.org/get")

# One-shot POST JSON — String body sets Content-Type automatically
var r2 = post("https://httpbin.org/post", '{"k": 1}')

# Session with base URL and authentication
with HttpClient("https://httpbin.org", BasicAuth("alice", "s3cr3t")) as c:
    var r = c.get("/basic-auth/alice/s3cr3t")
    r.raise_for_status()
    print(r.text())
```

Structs

PoolStats

struct PoolStats

A point-in-time view of an HttpClient's connection pools.

Implements Copyable, Movable

Fields
h1_idle: Int: Idle cleartext HTTP/1.1 connections.
tls_idle: Int: Idle HTTPS HTTP/1.1 connections.
quic_idle: Int: Idle QUIC connections available for HTTP/3.
quic_dials: Int: QUIC connections dialled over this client's lifetime.

HttpClient

struct HttpClient

A blocking HTTP client (HTTP/1.1, HTTP/2, and HTTP/3).

Opens one connection per request by default; opt into keep-alive reuse for cleartext HTTP/1.1 with :meth:with_pool and into HTTP/3 QUIC connection reuse (on by default once h3 is used). Follows redirects per a configurable :meth:with_redirect_policy, optionally auto-decompresses responses, retries transient failures via :meth:with_retry, and keeps a session cookie jar via :meth:with_cookies.

This type is Movable but not Copyable. It supports the context manager protocol (__enter__) for use with with.

Constructors follow a natural ergonomic order:

  • HttpClient() — defaults only
  • HttpClient("https://api.example.com") — base URL positional
  • HttpClient(BearerAuth("token")) — auth first
  • HttpClient("https://api.example.com", BearerAuth("token")) — base URL + auth

Example:

# Simple one-liner
var resp = HttpClient().get("https://httpbin.org/get")

# Session with base URL and auth — no repeated prefixes
with HttpClient("https://api.example.com", BearerAuth("tok")) as c:
    c.post("/items", '{"name": "flare"}').raise_for_status()
    var items = c.get("/items").json()

Implements Movable

__init__

def __init__(out self, base_url: String = "", max_redirects: Int = Int(10), timeout_ms: Int = Int(30000), user_agent: String = DEFAULT_USER_AGENT, prefer_h2c: Bool = False, h2c_upgrade: Bool = False, prefer_http3: Bool = False, auto_decompress: Bool = True)

Initialise an HttpClient with secure defaults.

Args
base_url: Optional base URL prepended to relative paths.
max_redirects: Maximum number of redirects to follow (default 10).
timeout_ms: Connect timeout in milliseconds (default 30 s). Body reads stay unbounded unless :meth:with_read_timeout sets a read timeout.
user_agent: Value for the User-Agent header.
prefer_h2c: When True, http:// requests speak HTTP/2 over cleartext via prior knowledge. https:// requests always negotiate via ALPN regardless.
h2c_upgrade: When True, http:// requests issue an HTTP/1.1 Upgrade: h2c handshake (RFC 7540 §3.2) instead of using prior knowledge; the connection switches to HTTP/2 only if the server returns 101 Switching Protocols, otherwise it stays on HTTP/1.1.
prefer_http3: When True, https:// requests prefer HTTP/3 over QUIC on first contact rather than waiting for an Alt-Svc advert; any QUIC dial failure falls back transparently to h2/h1. Without this, h3 still kicks in automatically once a server advertises it via Alt-Svc.
auto_decompress: When True (default), compressed response bodies are decoded transparently.
def __init__(out self, tls: TlsConfig, base_url: String = "", max_redirects: Int = Int(10), timeout_ms: Int = Int(30000), user_agent: String = DEFAULT_USER_AGENT, prefer_h2c: Bool = False, h2c_upgrade: Bool = False, prefer_http3: Bool = False, auto_decompress: Bool = True)

Initialise an HttpClient with custom TLS configuration.

def __init__[A: Auth](out self, auth: A, base_url: String = "", max_redirects: Int = Int(10), timeout_ms: Int = Int(30000), user_agent: String = DEFAULT_USER_AGENT, prefer_h2c: Bool = False, h2c_upgrade: Bool = False, prefer_http3: Bool = False, auto_decompress: Bool = True)

Initialise an HttpClient with authentication.

def __init__[A: Auth](out self, base_url: String, auth: A, max_redirects: Int = Int(10), timeout_ms: Int = Int(30000), user_agent: String = DEFAULT_USER_AGENT, prefer_h2c: Bool = False, h2c_upgrade: Bool = False, prefer_http3: Bool = False, auto_decompress: Bool = True)

Initialise an HttpClient with a base URL and authentication.

__deinit__

def __deinit__(deinit self)

Free the pooled fds + the Alt-Svc store owned by this client.

Idempotent on a moved-from client (_pool._addr == 0 / _alt_svc._addr == 0). Single-owner: copies are never produced for HttpClient because the type is :trait:Movable only, so each heap state is freed exactly once.

with_pool

def with_pool(var self, max_idle_per_host: Int = Int(8), max_idle_total: Int = Int(64), idle_timeout_ms: Int = Int(90000)) -> Self

Enable connection pooling on this client.

Returns the client (move-in / move-out so the call chains with the regular HttpClient(...) constructor). Two pools are enabled, both keyed on the origin: the cleartext HTTP/1.1 pool (http://, reuses idle fds) and the HTTPS HTTP/1.1 pool (https://, reuses the whole established TlsStream so the TLS handshake is skipped on keep-alive reuse). An h2- negotiated TLS connection is multiplexed on the h2 path and is not pooled here.

Example:

with HttpClient(base_url="https://api.example.com")
    .with_pool() as c:
    # Two GETs reuse the same TLS connection on idle reuse.
    _ = c.get("/users")
    _ = c.get("/items")
Args
max_idle_per_host: Per-origin idle cap. Default 8.
max_idle_total: Total idle cap across origins (cleartext pool). Default 64. 0 disables the total cap.
idle_timeout_ms: Max wallclock age for a pooled connection before lazy eviction. Default 90_000 ms.

idle_count

def idle_count(self) -> Int

Return the total number of connections currently sitting idle across both keep-alive pools (cleartext HTTP/1.1 fds + HTTPS TlsStream connections). Returns 0 when pooling is disabled.

tls_idle_count

def tls_idle_count(self) -> Int

Return the number of idle HTTPS (TLS HTTP/1.1) connections in the pool. Returns 0 when pooling is disabled.

quic_dials

def quic_dials(self) -> Int

Number of fresh HTTP/3 (QUIC) connections this client has had to dial (pool misses). Stays at 1 across repeated same-origin h3 requests when reuse is working.

quic_idle_count

def quic_idle_count(self) -> Int

Number of established HTTP/3 connections currently idle in the QUIC pool.

with_prefer_http3

def with_prefer_http3(var self, enabled: Bool = True) -> Self

Opt this client into HTTP/3 (move-in / move-out so it chains off the constructor like :meth:with_pool).

When enabled, https:// requests prefer h3 over QUIC; the decision still flows through :meth:http3_wire_choice and any QUIC dial failure falls back transparently to the existing h2/h1 path.

Example:

with HttpClient("https://example.com").with_prefer_http3() as c:
    _ = c.get("/")

with_h2c

def with_h2c(var self, prior_knowledge: Bool = True, upgrade: Bool = False) -> Self

Choose how cleartext HTTP/2 is reached.

Added in v0.11 as the spelling for what the prefer_h2c and h2c_upgrade constructor arguments set. Those still work and are not going away this release, but a knob reachable two ways is a knob that drifts.

Args
prior_knowledge: Open with the HTTP/2 preface directly, no negotiation. Correct when you already know the origin speaks h2c.
upgrade: Offer Upgrade: h2c on an HTTP/1.1 request and switch on a 101. Costs a round trip; works against an origin you have not probed. The streaming calls (:meth:get_streaming and friends) do not take this path -- they use prior knowledge when it is set and HTTP/1.1 otherwise.

pool_stats

def pool_stats(self) -> PoolStats

Idle-connection counts and dial totals across all three pools.

Added in v0.11. Replaces reading idle_count / tls_idle_count / quic_idle_count / quic_dials separately, which made it easy to check one pool and believe you had checked them all. Those four accessors still work.

One difference worth knowing when migrating: idle_count() returns the cleartext and TLS pools added together, while :attr:PoolStats.h1_idle is the cleartext pool alone and :attr:PoolStats.tls_idle the TLS one. h1_idle + tls_idle is the old number.

with_read_timeout

def with_read_timeout(var self, ms: Int) -> Self

Bound body reads, not just connect(2) (move-in / move-out so it chains off the constructor like :meth:with_pool).

timeout_ms only ever reached connect; once the connection was up a read could block forever. That is what a half-open connection looks like to the client -- the network drops after the request goes out, the peer never learns it should FIN, and recv waits for bytes that will never arrive. ms sets SO_RCVTIMEO on the socket, so a read that stalls that long raises Timeout instead.

The bound is per-read inactivity, not a deadline on the whole response: a large body that keeps arriving never trips it, and a stalled one trips it after ms of silence. The client has no whole-request deadline.

Set it before the first request. The timeout rides on the socket, so a connection keeps it for its whole lifetime. Cleartext pooled fds are re-armed on checkout; pooled TLS connections keep whatever was armed when they were dialled, and lowering ms to 0 clears neither.

Example:

with HttpClient().with_read_timeout(10_000) as c:
    _ = c.get("https://example.com/")
Args
ms: Read timeout in milliseconds. 0 (the default) disables it and restores the unbounded behaviour.

with_redirect_policy

def with_redirect_policy(var self, policy: RedirectPolicy) -> Self

Set the redirect-following policy (move-in / move-out so it chains off the constructor like :meth:with_pool).

The default (:meth:RedirectPolicy.follow_all) preserves the legacy unconditional-follow behaviour. Swap in :meth:RedirectPolicy.same_origin_only to refuse cross-origin redirects (and never leak the Authorization header across origins), or :meth:RedirectPolicy.deny to surface 3xx responses to the caller unfollowed.

Example:

var pol = RedirectPolicy.same_origin_only(max_redirects=5)
with HttpClient("https://api.example.com")
    .with_redirect_policy(pol) as c:
    _ = c.get("/protected")

with_retry

def with_retry(var self, policy: RetryPolicy = RetryPolicy()) -> Self

Opt into request-level retries on transient failure (move-in / move-out, chains off the constructor).

Retries fire when a request raises (connection/I/O error) or returns a >= 500 status, up to policy.max_attempts total attempts. By default only idempotent methods (RFC 9110 sec 9.2.2) are retried; set policy.retry_only_idempotent = False to retry any method. Backoff follows the same jittered exponential schedule as the server-side :class:flare.http.reliability.Retry middleware.

Example:

var pol = RetryPolicy()
pol.max_attempts = 4
pol.initial_backoff_ms = 50
with HttpClient().with_retry(pol) as c:
    _ = c.get("https://flaky.example.com/data")

with_max_decompressed_bytes

def with_max_decompressed_bytes(var self, n: Int) -> Self

Set the decompressed-size ceiling for auto-decompressed response bodies (move-in / move-out, chains off the constructor).

Guards against a decompression bomb: a small compressed body that inflates without bound. Defaults to 16 MiB; raise or lower it to fit the largest legitimate response this client expects.

Example:

with HttpClient().with_max_decompressed_bytes(64 * 1024 * 1024) as c:
    _ = c.get("https://example.com/big.json.gz")

with_proxy

def with_proxy(var self, proxy_url: String) -> Self

Route every request through the HTTP proxy at proxy_url (move-in / move-out, chains off the constructor).

Establishes a CONNECT tunnel to the origin through the proxy for both http:// and https:// (TLS then runs over the tunnel). Overrides the HTTP_PROXY / HTTPS_PROXY env policy; NO_PROXY is still honored. Pass "" to clear.

Example:

with HttpClient().with_proxy("http://127.0.0.1:8080") as c:
    var r = c.get("http://example.com/")

with_cookies

def with_cookies(var self) -> Self

Enable a per-client cookie jar (move-in / move-out, chains off the constructor).

When enabled, the client captures Set-Cookie response headers and replays the stored cookies as a Cookie request header on subsequent requests -- the standard session-cookie flow for a logged-in client. The jar is origin-agnostic (every stored cookie is sent on every request from this client), which matches the common single-origin HttpClient(base_url=...) session.

Example:

with HttpClient("https://api.example.com")
    .with_cookies() as c:
    _ = c.post("/login", '{"u":"a","p":"b"}')  # stores session
    _ = c.get("/me")                            # replays cookie

record_alt_svc

def record_alt_svc(self, origin: String, alt_svc_header: String)

Record an origin's Alt-Svc response header (RFC 7838) into the discovery cache.

Reads self (the cache is interior-mutable behind the :class:AltSvcStore handle), so it composes with the read-self request surface and the transparent auto-record in :meth:send. origin is the host:port the response came from; alt_svc_header is the raw header value. A later request to the same origin then sees a fresh h3 advert via :meth:http3_wire_choice. A clear token evicts the cached entry. Non-h3 adverts are ignored.

http3_wire_choice

def http3_wire_choice(self, scheme: String, host: String, port: UInt16) -> Int

The transparent h3-vs-h2 decision for one request.

Consults the prefer_http3 knob + the per-origin Alt-Svc cache and returns an :class:flare.http._client.alt_svc.Http3WireChoice codepoint (HTTP_3 or HTTP_2_OR_LOWER). QUIC support is compiled in, so the only gates are the scheme (https only) and whether h3 is preferred or freshly advertised. The runtime send path falls back to h2/h1 on any QUIC dial failure regardless of this result.

send_chunked

def send_chunked[B: ChunkSource](self, method: String, url: String, mut source: B, content_type: String = "application/octet-stream", body_size: Int = Int(-1), cancel: Cancel = Cancel.never()) -> Response

Send a request whose body is streamed from a ChunkSource using Transfer-Encoding: chunked, without materializing the full body in memory.

For large uploads (multi-MB / multi-GB) this keeps client memory bounded to one chunk in flight: the source's next(cancel) is pulled, framed as a chunked-transfer chunk, and written straight to the socket. The body is never assembled into a single buffer.

This is an explicit bounded-memory path over HTTP/1.1 (cleartext or TLS, ALPN forced to http/1.1); it deliberately does not go through connection pooling, redirects, retries, h2, or h3 (all of which buffer or re-send the body). Use :meth:post / :meth:send for those.

Args
method: HTTP method (e.g. "POST" / "PUT").
url: Target URL (absolute or relative to base_url).
source: A ChunkSource yielding body chunks; pulled to exhaustion (next returns None).
content_type: Content-Type header value. Defaults to application/octet-stream.
body_size: Total body length in bytes when known (sent as Content-Length); negative selects chunked framing.
cancel: Cancellation token checked while the body is pulled.

Raises NetworkError: On connection or I/O failure.

__enter__

def __enter__(var self) -> Self

Transfer ownership of self into the with block.

default

def default() -> Self

Return a client with secure defaults (TLS verification enabled).

get

def get(self, url: String) -> Response

Perform a GET request.

Args
url: The URL to request (http://, https://, or relative path when base_url is set).

Raises NetworkError: On connection or I/O failure. TooManyRedirects: If the redirect limit is exceeded.

get_streaming

def get_streaming(self, url: String) -> HttpStreamResponse

Stream a GET response body without buffering it.

Works on http:// and https:// alike. The wire is chosen the way :meth:send chooses it, with one exception: ALPN settles h2 versus HTTP/1.1 on TLS, and cleartext uses HTTP/1.1 unless :meth:with_h2c asked for prior-knowledge h2c. The Upgrade: h2c handshake is the exception -- a streaming response cannot be re-read if the origin declines the upgrade, so this path stays on HTTP/1.1 rather than guessing.

The response head is parsed before this returns; the body is pulled by :meth:HttpStreamResponse.read_chunk, so a multi-gigabyte download costs one buffer rather than one allocation the size of the body. The connection is exclusive to this response and is not pooled.

Changed in v0.11. This used to be cleartext-only and raised on https://, with get_streaming_tls as its TLS twin pinned to HTTP/1.1. One entry point covers both now and the return type changed from HttpDownload[TcpStream] to HttpStreamResponse.

Args
url: Absolute URL, or a path when the client has a base URL.

Raises NetworkError: On a connect, TLS or read failure. Error: On an unparseable URL.

post_streaming

def post_streaming(self, url: String, body: String) -> HttpStreamResponse

POST a JSON body and stream the response.

The shape server-sent events and JSON-lines APIs want: a small request, then a response read incrementally for as long as the server keeps writing.

Args
url: Absolute URL, or a path when the client has a base URL.
body: Request body, sent as application/json.

Raises NetworkError: On a connect, TLS or read failure.

send_streaming

def send_streaming(self, var req: Request) -> HttpStreamResponse

Send req and stream the response body.

The primitive the other two are written in terms of. Redirects, retries and cookie handling apply only up to the point the head is parsed; after that the body belongs to the caller and cannot be replayed, so nothing re-sends it.

Args
req: The request (ownership transferred). Framing headers on it are ignored -- the writer owns framing.

Raises NetworkError: On a connect, TLS or read failure. Error: On an unparseable URL or an unusable header.

post

def post(self, url: String, body: String) -> Response

Perform a POST request with a JSON string body.

Sets Content-Type: application/json automatically. This is the default for string bodies because virtually every HTTP API that accepts a string payload expects JSON.

Example:

var resp = client.post("https://httpbin.org/post", '{"key": "value"}')
resp.raise_for_status()
Args
url: The target URL (absolute or relative to base_url).
body: The JSON request body as a String.

Raises NetworkError: On connection or I/O failure. TooManyRedirects: If the redirect limit is exceeded.

def post(self, url: String, body: Value) -> Response

Perform a POST request with a json.Value body.

Serialises body to JSON with dumps and sets Content-Type: application/json automatically.

Args
url: The target URL (absolute or relative to base_url).
body: A json.Value to serialise and send.

Raises NetworkError: On connection or I/O failure. TooManyRedirects: If the redirect limit is exceeded.

def post(self, url: String, body: List[UInt8]) -> Response

Perform a POST request with a raw byte body.

No Content-Type header is set automatically; the caller is responsible for setting it via a custom Request if required.

Args
url: The target URL (absolute or relative to base_url).
body: The raw request body bytes.

Raises NetworkError: On connection or I/O failure. TooManyRedirects: If the redirect limit is exceeded.

put

def put(self, url: String, body: String) -> Response

Perform a PUT request with a JSON string body.

Sets Content-Type: application/json automatically.

Args
url: The target URL (absolute or relative to base_url).
body: The JSON request body as a String.

Raises NetworkError: On connection or I/O failure. TooManyRedirects: If the redirect limit is exceeded.

def put(self, url: String, body: Value) -> Response

Perform a PUT request with a json.Value body.

Serialises body to JSON with dumps and sets Content-Type: application/json automatically.

Args
url: The target URL (absolute or relative to base_url).
body: A json.Value to serialise and send.

Raises NetworkError: On connection or I/O failure. TooManyRedirects: If the redirect limit is exceeded.

def put(self, url: String, body: List[UInt8]) -> Response

Perform a PUT request with a raw byte body.

No Content-Type header is set automatically.

Args
url: The target URL (absolute or relative to base_url).
body: The raw request body bytes.

Raises NetworkError: On connection or I/O failure. TooManyRedirects: If the redirect limit is exceeded.

delete

def delete(self, url: String) -> Response

Perform a DELETE request.

Args
url: The target URL.

Raises NetworkError: On connection or I/O failure. TooManyRedirects: If the redirect limit is exceeded.

head

def head(self, url: String) -> Response

Perform a HEAD request.

Identical to GET but the server MUST NOT include a message body in the response (RFC 7231 §4.3.2).

Args
url: The target URL.

Raises NetworkError: On connection or I/O failure. TooManyRedirects: If the redirect limit is exceeded.

patch

def patch(self, url: String, body: String) -> Response

Perform a PATCH request with a JSON string body.

Sets Content-Type: application/json automatically.

Args
url: The target URL (absolute or relative to base_url).
body: The JSON request body as a String.

Raises NetworkError: On connection or I/O failure. TooManyRedirects: If the redirect limit is exceeded.

def patch(self, url: String, body: Value) -> Response

Perform a PATCH request with a json.Value body.

Serialises body to JSON with dumps and sets Content-Type: application/json automatically.

Args
url: The target URL (absolute or relative to base_url).
body: A json.Value to serialise and send.

Raises NetworkError: On connection or I/O failure. TooManyRedirects: If the redirect limit is exceeded.

def patch(self, url: String, body: List[UInt8]) -> Response

Perform a PATCH request with a raw byte body.

No Content-Type header is set automatically.

Args
url: The target URL (absolute or relative to base_url).
body: The raw request body bytes.

Raises NetworkError: On connection or I/O failure. TooManyRedirects: If the redirect limit is exceeded.

send

def send(self, req: Request) -> Response

Send an HTTP request and return the response.

Follows redirects per the configured :class:RedirectPolicy (default: follow all, capped at max_redirects), transparently decodes a compressed body when auto_decompress is on, and replays / captures cookies when the jar is enabled. When :meth:with_retry opted in, the whole exchange is retried on a transient failure per the :class:RetryPolicy.

Args
req: The request to send.

Raises NetworkError: On I/O failure. TooManyRedirects: If the redirect cap is exceeded. Error: On a cross-origin redirect refused by a same_origin_only policy.

Aliases

comptime FLARE_VERSION = String("0.13.0")

The library version, as reported in the default User-Agent.

Bump alongside pixi.toml and recipe.yaml at release.

comptime DEFAULT_USER_AGENT = (String("flare/") + StringSpan(FLARE_VERSION))

Default User-Agent sent when the caller does not set one.

Was the literal "flare/0.1.0" through v0.10, which had been wrong for nine releases because nothing derived it from the version.