User Guide¶
Overview¶
chumicro-requests is a non-blocking HTTP/1.1 client built on chumicro-sockets. HttpClient is the single entry point for every verb — its check(now_ms) / handle(now_ms) methods drive the request forward one tick at a time. An LED keeps blinking on the same board while a request is in flight or mid-timeout against a stalled peer. Two connect phases are the exception: the DNS lookup, and on MicroPython / CircuitPython the TLS handshake, each block the reactor for their duration (see Bring your own transport). The library is single-in-flight today — a second client.get(...) while another request is running raises HttpBusyError.
Getting started with generators¶
The generator surface runs a whole request top to bottom (connect, send, receive, return) with no handle to poll and no on_done callback. A single response = yield from get(...) drives the request under Runner.add_generator, and other runner services keep getting the CPU between yields.
from chumicro_requests.generators import get
from chumicro_runner import Runner
from chumicro_sockets.sockets_factory import connector_factory
transport_factory = connector_factory(radio=wifi.radio)
def fetch_once():
response = yield from get(transport_factory, "http://api.example.com/now")
print(response.status_code, len(response.body))
runner = Runner()
handle = runner.add_generator(fetch_once())
while not handle.done:
now_ms = runner.tick()
runner.wait(now_ms)
chumicro_requests.generators also exposes post, put, patch, delete, and the lower-level fetch(transport_factory, method, url). For a body too big for RAM, stream(...) returns a reader you pull one chunk per yield from (see Streaming large bodies).
Getting started with a service¶
Reach for the HttpClient service when you make repeated requests on one client, or when you want the check / handle shape that drops straight into a Runner alongside other services.
from chumicro_requests import HttpClient
from chumicro_sockets.sockets_factory import connector_factory
from chumicro_timing import ticks_ms
client = HttpClient(transport_factory=connector_factory(radio=wifi.radio))
handle = client.get("http://api.example.com/now", timeout_ms=5000)
while not handle.done:
if client.check(ticks_ms()):
client.handle(ticks_ms())
response = handle.result
print(response.status_code, response.body)
print(response.text) # decoded str
print(response.json()) # parsed JSON
Build your client at startup. The first HttpClient reference imports the client module, so let that one-time cost land on a fresh heap.
POST / PUT / PATCH / DELETE¶
HttpClient exposes a method per verb. Bodies can be raw bytes / str
or a Python object that gets JSON-encoded:
# Raw bytes / str body
handle = client.post("http://api/widgets", body=b"<custom-bytes>")
handle = client.post("http://api/widgets", body="text/plain payload")
# JSON helper — auto-encodes + sets Content-Type: application/json
handle = client.post("http://api/widgets", json={"name": "thing", "qty": 3})
# PUT / PATCH share the same body / json semantics
handle = client.put("http://api/widgets/42", json={"name": "renamed"})
handle = client.patch("http://api/widgets/42", body=b"diff-bytes")
# DELETE is intransitive in v1 — no body parameter
handle = client.delete("http://api/widgets/42")
Caller-supplied headers={"Content-Type": "..."} always wins over the
JSON-helper default. Pass exactly one of body= or json=; passing
both raises ValueError.
Redirects¶
HttpClient follows 301 / 302 / 303 / 307 / 308 redirects
automatically up to a budget. Default cap is 5. Override per-call or
per-client:
# Per-call: don't follow at all (return the 3xx response as-is)
handle = client.get(url, max_redirects=0)
# Per-call: raise the cap
handle = client.get(url, max_redirects=20)
# Per-client default
client = HttpClient(transport_factory=..., default_max_redirects=10)
Method handling follows long-standing browser + RFC 7231 §6.4 rules:
301/302/303switch the next hop to GET with no body307/308preserve the original method and body
response.url reflects the URL of the FINAL hop, not the original
request. If the budget is exhausted before reaching a non-3xx response,
the last 3xx is returned to the caller (matching CPython requests'
default behavior without the raise_for_status() step).
The Location header may be absolute (https://other.com/dest),
absolute-path (/api/v2), or path-relative (trinkets). All three
shapes are resolved against the current URL.
Body framing¶
HttpClient accepts three RFC 7230 body framings transparently:
Content-Length: N— read exactly N bytes (most common case).Transfer-Encoding: chunked— RFC 7230 §4.1 chunked decode. Chunk-extensions and trailer headers are accepted and discarded.Content-Lengthis ignored when chunked is present per §3.3.3.- Neither header — read until the peer closes the connection (HTTP/1.0-style framing).
In all cases response.body returns the decoded bytes (chunks
concatenated for chunked responses). response.text and
response.json() work the same way regardless of framing.
Other Transfer-Encoding values (gzip, deflate, identity
stacked with chunked, etc.) are rejected with HttpProtocolError
in v1 — the caller would otherwise silently get garbled bytes.
Streaming large bodies¶
By default the whole body is buffered in RAM, capped at
max_body_bytes (64 KB). Pass stream=True — on any verb, or on the
generic client.request(method, url, ...) — to consume the body
incrementally instead: firmware images, log pulls, and any payload
bigger than the heap become readable at a fixed RAM cost (the
stream_buffer_size staging window, default 1024 bytes, plus your own
buffer).
handle = client.get("http://host/firmware.bin", stream=True,
timeout_ms=120_000)
buffer = bytearray(512)
view = memoryview(buffer)
# Inside your service's handle(now_ms) — one slice per tick:
if handle.done and handle.error is not None:
report(handle.error) # failed mid-transfer
elif handle.response is not None: # headers are in
count = handle.read_body_into(view)
if count:
flash.write(view[:count])
elif handle.done:
finish() # 0 after done == end of body
The contract:
handle.responseis set as soon as the final hop's headers arrive — beforehandle.done— so you can branch onstatus_code/headersfirst. Itsbodyisb""andstreamedisTrue;.text/.json()raiseHttpError.handle.read_body_into(buffer)copies decoded body bytes into your buffer and returns the count.0means "nothing this tick"; oncehandle.doneisTrue(with noerror),0means end of body.- Backpressure is automatic: when the staging window is full the client stops reading the socket (and reports no poll interest, so a runner parks instead of spinning) until you drain it.
max_body_bytesandWhenOversizeddo not apply to streamed bodies — the staging window is the RAM bound. To enforce your own ceiling, count what you read and callclient.cancel().timeout_mscovers the whole transfer, your reads included — size it for the download, not the round-trip.client.cancel()aborts the request immediately (the handle fails withHttpError), for early exits that shouldn't wait out the timeout.
The generator form wraps the same machinery, one chunk per
yield from:
from chumicro_requests.generators import stream
def download(transport_factory, url, sink):
reader = yield from stream(transport_factory, "GET", url,
timeout_ms=120_000)
if reader.response.status_code != 200:
reader.cancel()
return
buffer = bytearray(512)
view = memoryview(buffer)
while True:
count = yield from reader.read_into(view)
if count == 0:
break
sink(view[:count])
runner.add_generator(download(transport_factory, url, flash.write))
Body decoding¶
Response.body is always raw bytes. Response.text decodes those
bytes using Response.encoding, which is sniffed from the
Content-Type header's charset= parameter (default utf-8).
Override the encoding when a server's Content-Type lies:
Response.json() decodes via text first, then runs json.loads,
so charset overrides apply to JSON responses too.
The transport_factory argument is a callable
(host, port, use_tls) -> SocketConnector — a tick-driven connector,
not a ready socket (see Bring your own transport
below for the connector contract). The bundled
chumicro_sockets.sockets_factory.connector_factory(radio=..., ssl_context=...)
returns one wired to chumicro-sockets. The helper lives in an opt-in
submodule so users with a custom transport never trigger the
chumicro-sockets deploy. Tests pass a factory returning a
chumicro_sockets.testing.FakeSocketConnector (which wraps a
FakeSocket).
Bring your own transport¶
HttpClient does not care which library produces its sockets. The transport_factory you pass is a callable of shape (host: str, port: int, use_tls: bool) -> SocketConnector. The connector advances the TCP connect one tick at a time, but the DNS lookup and, on MicroPython / CircuitPython, the TLS handshake block the reactor for their duration — on a slow or unreachable host that can be seconds, freezing every other runner service, so connect before starting time-critical work. Once connector.state == "ready", the underlying socket must expose the four-method contract:
| Method | Contract |
|---|---|
recv_into(buffer, nbytes) -> int |
Reads up to nbytes into buffer (a memoryview). Raises OSError(EAGAIN \| EWOULDBLOCK) on no data, returns 0 on peer-close, otherwise returns bytes written. |
send(payload) -> int |
Sends payload (a bytes). Raises OSError(EAGAIN \| EWOULDBLOCK) when the send buffer is full, otherwise returns bytes sent (may be partial). |
close() -> None |
Releases the connection. |
setblocking(flag) -> None |
Best-effort. Absence is tolerated. |
chumicro_sockets.connector is one producer. See chumicro_sockets._connector.SocketConnector for the connector contract (tick(now_ms), state, socket, io_*, next_deadline, cancel) — any tick-driven state machine with that surface works as a custom factory.
If you supply your own factory and want chumicro_sockets dropped from the deploy, add a module-level constant to your entrypoint and the chumicro-workspace deployer will filter the default factory out of the import graph:
The constant accepts a family form (the bare stem, matches every chumicro_*.sockets_factory) or an exact dotted path (chumicro_sockets.sockets_factory). An unmatched entry fails the deploy with a typo message rather than silently shipping the default. Calling HttpClient.from_config(...) when chumicro_sockets.sockets_factory is missing — either skipped at deploy time or not installed by circup / mip — raises RuntimeError naming the bypass kwarg.
For the full single-library adoption recipe — your transport, your ticks=, the runner-less drive loop, and host tests with no board — see Standalone integration.
Runner pattern¶
HttpClient.check(now_ms) -> bool and handle(now_ms) -> None satisfy the
runner contract. Drop the client into a Runner alongside an
LED-heartbeat task:
from chumicro_runner import Runner
from chumicro_requests import HttpClient
from chumicro_sockets.sockets_factory import connector_factory
http_client = HttpClient(transport_factory=connector_factory(radio=radio))
runner = Runner([http_client, blink_task])
while True:
runner.tick(ticks_ms())
Memory notes¶
The default 64 KB max_body_bytes cap is sized for the minimum
supported board class (256 KB MCU RAM). Bump it for larger boards if needed; the Response.body
buffer grows up to that cap. The default 1024-byte recv_budget_per_tick
matches chumicro-mqtt's — bytes drained per tick are bounded so concurrent
runner tasks (LED blink, control loop) keep getting CPU time even mid-large-body.
For bodies that shouldn't (or can't) sit in RAM at all, use
stream=True — see Streaming large bodies:
the per-request cost drops to the stream_buffer_size staging window
(default 1024 bytes) regardless of body size.
Platform notes¶
Pure Python, no third-party deps beyond chumicro-sockets and chumicro-timing.
Works identically on CPython, MicroPython, and CircuitPython once the
connection factory is wired up. HTTPS uses the same
chumicro_sockets.sockets_factory.connector_factory(ssl_context=...)
pattern as plain HTTP.
HTTPS heap headroom on minimum-class boards¶
mbedTLS handshake costs heap — bench-tested on Pi Pico W MP with a
raw-stdlib ssl.wrap_socket() against letsencrypt.org:443, the
handshake consumed about 25 KB of heap on top of whatever the app
already had loaded. Headroom matters: an app that's loaded a lot
into RAM (chumicro stack + workspace fixtures + per-example helpers)
can run the handshake out of memory and surface as OSError(12)
(ENOMEM) inside wrap_socket(). If you see ENOMEM on HTTPS, the
two levers are (a) drop unused imports before the request, or (b)
switch to flash deploy mode (chumicro-deploy --mode flash) so the
library bootstrap lives on flash and the heap is free for the
handshake.
TLS context — bring your own CA¶
chumicro_sockets.sockets_factory.connector_factory(ssl_context=...)
accepts an SSL context built via chumicro_sockets.ssl_context_with_ca(pem).
CA-pinning is required
on both supported embedded runtimes — but for different reasons:
- MicroPython doesn't have
ssl.create_default_context()at all; every TLS context must be built explicitly. - CircuitPython has
ssl.create_default_context()(and it builds cheaply — ~80 bytes of heap on a Pi Pico W), but the returned context carries no CAs and hascheck_hostname=False— handshake against any real cert would fail.
So on both runtimes, pass a context with a CA loaded. The CPython "default context loads a 100-200 KB system trust store" intuition doesn't apply — neither MP nor CP bundles a trust store, by design.
Device RTC must be set before TLS¶
mbedTLS CERT_REQUIRED checks the cert validity window against the device
clock. A board with no RTC battery and no NTP boots at 2021-01-01 (or epoch),
which is "before" every modern cert's not_valid_before field — handshake
fails with ValueError("certificate validity starts in the future").
Use chumicro-ntp to set the device clock from a public NTP server before the TLS handshake. Cross-runtime, non-blocking, takes a UDP socket you provide.
Examples¶
| Example | What it shows |
|---|---|
periodic_get.py |
Periodic GET on a real CP/MP board — wifi up, hits a configured URL every N seconds, drives an LED-blink counter to verify the request never blocks the loop. Cross-runtime (CP + MP). |