cheatah
Module

requests

An HTTP/1.1 client in the spirit of Python's requests — and the first standard-library module written in pure cheatah (requests.purr, compiled by purrc into an importable module). All the protocol logic is cheatah source; it rides on the C++-authored stdlib underneath (socket for TCP, tls for HTTPS, parsers for URL/JSON parsing).

import io
import requests

let r = requests.get("http://example.com/")
if r.ok() { io.print(r.status_code, r.text()) }

# Verbs, query params, custom headers, timeout, redirect budget.
let o = requests.Options({.timeout_ms = 5000})
o.params["q"] = "cheatah"
let s = requests.get("https://example.com/search", o)
io.print(s.ok(), s.header("content-type"))

# POST a JSON body (to_json serializes a flat dict; pass json_body for anything richer).
let body = requests.Options({.json_body = requests.to_json({"side": "buy"})})
let p = requests.post("https://api.example.com/order", body)
p.raise_for_status()          # raises on 4xx/5xx (a no-op otherwise)

# Form data and Basic auth.
let f = requests.Options({.auth_user = "key", .auth_pass = "secret"})
f.data["symbol"] = "SPX"
let q = requests.post("https://api.example.com/quote", f)   # application/x-www-form-urlencoded

Verbs

get, post, put, patch, delete, head, options, plus the generic request(method, url, o). Each takes an optional Options; head returns headers only (empty body). delete works because purrc escapes the C++ keyword in codegen.

Request bodies (

One of, in precedence order: json_body (a pre-serialized JSON string → application/json), data (a dict<str,str>application/x-www-form-urlencoded, percent-encoded), or body (a raw string). Content-Type and Content-Length are added automatically unless you set them yourself. GET/HEAD never carry a body. to_json(dict<str,str>) serializes the common flat-object case (build the string yourself for nested/non-string JSON).

Auth

auth_user/auth_pass add HTTP Basic (Authorization: Basic <base64>). For Bearer tokens or API-key/HMAC schemes, set the Authorization (or any) header yourself — a caller-supplied header is never overridden.

params, headers, timeout_ms (default 30000), max_redirects (default 5), no_redirect, body, data, json_body, auth_user, auth_pass, max_bytes (max response body; default 100 MiB — a hard cap so a hostile/compromised server cannot exhaust memory), insecure (https: skip TLS cert validation; default false = verify), ca_file (https: a PEM CA bundle to trust instead of the system store).

Fields status_code, reason, headers, body, url, error, cookies (parsed from Set-Cookie), history (intermediate responses when redirects were followed). Methods: ok() (2xx and no error), header(name) (case-insensitive), text()/content() (the body), json(out) (typed parse into a struct via the accelerated parsers.json.read), raise_for_status() (raises on 4xx/5xx), is_redirect(), is_permanent_redirect().

A non-2xx status is a completed exchange: error stays empty; only transport failures (DNS/connect/timeout/TLS/malformed) set error and leave status_code 0. Redirects (301/302/303/307/308) are followed by default; 303 (and 301/302 on a POST) continue as GET with the body dropped, matching Python. Header names are stored lowercased. Internally requests opens the TCP fd and (for https) a tls.Conn guard, so a request never leaks its connection even on an error path.

Security notes

  • https:// authenticates the server by default. It rides the cheatah tls client, which validates the certificate chain to a trusted CA, matches the hostname against the certificate SAN, and checks expiry (see the tls README) — so an active man-in-the-middle is refused, not just a passive eavesdropper. For a pinned/controlled peer, set Options.insecure = true to skip validation, or Options.ca_file to trust a specific PEM CA bundle (e.g. a private CA).

  • Response size is capped at Options.max_bytes (default 100 MiB): a server that streams an unbounded body — or declares an oversized Content-Length — fails with an error instead of exhausting memory.

  • Malformed framing never crashes. A non-numeric/overflowing/negative Content-Length or a malformed status line, and an overflowing chunk size, set error; they do not raise.

  • Cross-host redirects drop credentials. On a redirect to a different host, Basic-auth (auth_user/auth_pass) and any Authorization/Cookie header are stripped before the next hop, so secrets scoped to the original host are never sent to another origin. (Custom auth headers like X-Api-Key that you set yourself are your responsibility across hosts.) The caller's Options is never mutated — the request works on a private copy.

Deviations from Python

  • json() takes a struct (r.json(out)) and uses the accelerated schema-typed reader, rather than returning a dynamic object. A struct-free dynamic json() for ad-hoc navigation is planned once parsers.json gains .purr-navigable accessors.

  • text and content are identical — cheatah strings are byte-based, so there is no separate decoded-text vs bytes distinction.

  • Redirect opt-out is no_redirect (not allow_redirects). cheatah zero-initializes structs, so a true-by-default allow_redirects bool would silently become false on any hand-built Options. Instead redirects are followed by default (the zero value) and you set .no_redirect = true to stop at the 3xx — the same behavior as Python's allow_redirects=False.

  • params iterate in unspecified order (dict<str,str>). If you must sign an exact query string (HMAC), put the query in the URL rather than in params.

  • One connection per request (Connection: close); no keep-alive/Session yet.

Not yet supported

Session/keep-alive/connection reuse, retries, multipart/files=, streaming/iter_content, proxies, and explicit TLS/certificate configuration.

Functions

fn auto has_control_bytes(builtins::Value auto &&text) source#

Does text contain a byte that would break out of the line it is written on?

The request is built by concatenating a request-target and header values into a CRLF-framed message, so a CR or LF reaching either one lets a caller inject headers or split the request entirely. That matters most when the value is not the caller's own: a URL taken from a fetched document or a Location header is attacker-controlled data, and nothing else on the path re-checks it. Refusing here means no consumer of this module can be made to emit a forged request, whatever it was handed.

Parameters
text

a request-target or header value about to be written to the wire.

Returns

true when text carries CR or LF and must not be sent.

Complexity

O(n) over the input length.

Allocation

none.

fn auto to_json(builtins::Value auto &&fields) source#

Serialize a flat string->string dict as a JSON object — the common json= case.

For nested or non-string JSON, build the string yourself and pass it as json_body.

Parameters
fields

the name -> value pairs.

Returns

a JSON object string ({"k":"v",…}).

Complexity

O(total characters).

Allocation

allocates the result.

fn request · 2 overloads
auto request(builtins::Value auto &&method, builtins::Value auto &&url, builtins::Value auto &&o)source#
static auto request(builtins::Value auto &&method, builtins::Value auto &&url)source#

Perform an HTTP request, following up to max_redirects 3xx hops unless no_redirect.

On a redirect to a DIFFERENT host, Basic-auth credentials and any Authorization/Cookie header are stripped before the next hop, so secrets are never leaked to another origin. Never raises for network conditions: every failure comes back as a Response with error set (and status_code 0). Redirects (301/302/303/307/308) follow absolute and host-relative Location targets, recording each hop in the returned Response's history; 303 (and 301/302 on a POST) switch the method to GET and drop the body, matching Python. Set o.no_redirect = true to return the 3xx response directly.

Parameters
method

the HTTP method ("GET", "POST", …).

url

the absolute http(s)://host[:port]/path[?query] URL.

o

per-request options; defaults to a 30 s timeout and 5 redirect hops (redirects followed unless no_redirect).

Returns

the final Response — check ok(), then status_code/headers/body.

Complexity

one full exchange (request_once) per hop, at most 1 + max_redirects hops.

Allocation

allocates each hop's request/response buffers, the recorded history, and a private copy of o (so redirect-time credential stripping never mutates the caller's).

Concurrency

blocking, with every hop's socket I/O bounded by timeout_ms; no shared state — concurrent requests from separate threads are independent.

fn get · 2 overloads
auto get(builtins::Value auto &&url, builtins::Value auto &&o)source#
static auto get(builtins::Value auto &&url)source#

HTTP GET.

Parameters
url

the URL.

o

per-request options.

Returns

the final Response.

Complexity

one request plus any redirects.

Allocation

request/response buffers.

fn post · 2 overloads
auto post(builtins::Value auto &&url, builtins::Value auto &&o)source#
static auto post(builtins::Value auto &&url)source#

HTTP POST.

Parameters
url

the URL.

o

per-request options (body via json_body/data/body).

Returns

the final Response.

Complexity

one request plus any redirects.

Allocation

request/response buffers.

fn put · 2 overloads
auto put(builtins::Value auto &&url, builtins::Value auto &&o)source#
static auto put(builtins::Value auto &&url)source#

HTTP PUT.

Parameters
url

the URL.

o

per-request options (body via json_body/data/body).

Returns

the final Response.

Complexity

one request plus any redirects.

Allocation

request/response buffers.

fn patch · 2 overloads
auto patch(builtins::Value auto &&url, builtins::Value auto &&o)source#
static auto patch(builtins::Value auto &&url)source#

HTTP PATCH.

Parameters
url

the URL.

o

per-request options (body via json_body/data/body).

Returns

the final Response.

Complexity

one request plus any redirects.

Allocation

request/response buffers.

fn delete_ · 2 overloads
auto delete_(builtins::Value auto &&url, builtins::Value auto &&o)source#
static auto delete_(builtins::Value auto &&url)source#

HTTP DELETE.

Parameters
url

the URL.

o

per-request options.

Returns

the final Response.

Complexity

one request plus any redirects.

Allocation

request/response buffers.

fn head · 2 overloads
auto head(builtins::Value auto &&url, builtins::Value auto &&o)source#
static auto head(builtins::Value auto &&url)source#

HTTP HEAD (headers only, no body).

Parameters
url

the URL.

o

per-request options.

Returns

the final Response (empty body).

Complexity

one request plus any redirects.

Allocation

request/response buffers.

System testRequestsSys.Head
fn options · 2 overloads
auto options(builtins::Value auto &&url, builtins::Value auto &&o)source#
static auto options(builtins::Value auto &&url)source#

HTTP OPTIONS.

Parameters
url

the URL.

o

per-request options.

Returns

the final Response.

Complexity

one request plus any redirects.

Allocation

request/response buffers.