-
Notifications
You must be signed in to change notification settings - Fork 0
Phase 29 urequests plan
Part of the Amiga port design log.
Convenience client built on top of Phase 9 (socket) and Phase 28
(ssl). Same shape as upstream micropython-lib's requests:
import urequests
r = urequests.get("https://www.python.org/")
print(r.status_code)
print(r.text[:200])
r.close()Shipped in five steps over commits 3455810e4 (Step 1),
de03cc93b (Steps 2–4), and the doc-flip in this section.
End-to-end verified under Amiberry against
www.example.com (HTTP + gzip), httpbin.org (HTTP +
chunked + POST echo for both json= and data=dict), and
www.python.org (HTTPS, 48 887 B of HTML through the Phase 28
TLS stack).
Phase 29 is pure-Python frozen onto the variants that already
ship socket and ssl (standard, 68020fpu, 68040); the
minimal variant — which gates MICROPY_PY_AMIGA_SOCKET off,
therefore also MICROPY_PY_AMIGA_SSL — gets nothing.
urequests.get / post / put / delete / patch / head-
Responsewith.status_code,.reason,.headers,.text,.content,.json(),.close() - HTTP/1.0 wire format (
Connection: close), HTTP/1.1 read tolerance -
https://via the existingssl.SSLContextsetup (CERT_REQUIRED+set_default_verify_paths()against the AmiSSL trust store) - Optional gzip decompression via the existing
deflatemodule - POSTing
data=(urlencoded),json=(auto-serialised),headers=overrides
- Persistent / keep-alive connections — needs HTTP/1.1 keep-alive state-machine, deferred
- Sessions / cookies — deferred
- Streaming uploads /
multipart/form-data— deferred - HTTP/2, async — deferred (no asyncio yet)
- HTTPS hostname verification customisation (default-only)
- Authentication helpers beyond a caller-supplied
Authorizationheader
Inherits the Phase 28 AmiSSL ↔ modern-CDN issue
(Phase 28): urequests.get against
TLS-1.3-eager hosts (Cloudflare, GitHub) will succeed the handshake
then fail the write. Direct origins that still negotiate TLS 1.2 by
default work.
In more detail: against modern HTTPS servers with short
request-read timeouts (Cloudflare's edge, GitHub's API, etc.)
urequests.get will complete the TLS 1.3 handshake then raise
OSError: TLS: ... Broken pipe because the 68k CPU can't finish
the handshake and write the request before the server-side
timeout fires. Per the AmiSSL maintainer
(jens-maus/amissl#111)
this is a processor-speed limitation, not an AmiSSL or
Cloudflare bug — AmiSSL is OpenSSL, and the Amiga's CPU just
isn't fast enough at modern TLS. Direct origins with looser
timeouts (www.python.org, etc.) work cleanly.
This section is the step-by-step ship plan — how the work was chunked into landable, individually-testable PRs.
Step 1: skeleton (HTTP/1.0 GET) → Step 2: chunked TE + gzip
↓
Step 3: HTTPS via SSLContext
↓
Step 4: write methods + kwargs
↓
Step 5: freeze + docs + tests
| # | Step | Output | On-target smoke test |
|---|---|---|---|
| 1 ✅ | Core Response + request("GET", ...) over plain HTTP. URL parsing, status line, headers, fixed-length body via Content-Length. |
urequests.py shipping get, head, request, Response. No HTTPS, no chunked, no compression. |
urequests.get("http://www.example.com/") returns status 200 and a reasonable .text
|
| 2 ✅ | Chunked Transfer-Encoding decoder + Content-Encoding: gzip decompression via the existing deflate module. |
Response.content handles both inline. |
One host that serves chunked (httpforever.com), one that serves gzip. |
| 3 ✅ | HTTPS via ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + verify_mode = CERT_REQUIRED + set_default_verify_paths(). SSLContext built once per call. |
urequests.get("https://www.python.org/") returns 200. Inherits the Phase 28 Cloudflare TLS 1.3 limitation. |
www.python.org, end-to-end, full HTML body. |
| 4 ✅ |
post / put / delete / patch methods. data= (str/bytes urlencoded), json= (auto-serialised → application/json), headers= overrides. |
All HTTP verbs land. POSTing JSON to https://httpbin.org/post round-trips. |
Verbs against httpbin.org (or local TLS echo). |
| 5 ✅ | Freeze into manifest.py, gate behind the existing MICROPY_PY_AMIGA_SSL (-equivalent) Make flag, doc updates. |
import urequests works after a clean variant build. Phase 29 row flips to ✅. |
import urequests under the on-device runner + tests/extmod/* untouched-by-skip-list verification. |
Each step is small (50–150 LOC of Python plus tests). Step 4 is the only place the API surface grows materially.
-
ports/amiga/modules/urequests.py:-
class Response:-
__init__(sock)— keeps the raw socket, init buffer, headers dict, encoding. -
_read_line()/_read_raw(n)— byte-level reads with a local buffer (avoid onerecvper byte). -
_read_status_line(),_read_headers()— parse HTTP/1.x response head. -
contentproperty — read body usingContent-Length, fall back to "read until EOF" if header absent. Cache result; close socket once cached. -
textproperty — decodecontentas UTF-8 (lossy fallback to?for non-UTF-8 bytes, matching OoZe — MicroPython'sdecode()doesn't supporterrors=replace). -
json()— decode thenjson.loads. -
status_code,reason,headers,encoding="utf-8",close().
-
-
def request(method, url, *, headers=None):- URL parsing: split scheme, host (with optional
:port), path. - Reject non-
http://for now (Step 3 addshttps://). - DNS via
socket.getaddrinfo, connect, send<METHOD> <PATH> HTTP/1.0\r\nplus mandatoryHost:/Connection: close/User-Agent: MicroPython-Amiga/<ver>. Optional caller headers added unmodified. - Build
Responsefrom the connected socket and return it.
- URL parsing: split scheme, host (with optional
- Convenience wrappers
get,head(post/put/delete/patcharrive in Step 4).
-
-
urequests.get("http://www.example.com/")→.status_code == 200,.textcontains<title>Example Domain</title>. -
urequests.head("http://www.example.com/")returns headers, empty body. - 4xx / 5xx still return a
Responsewith the right code (no exception raised) — caller decides how to react.
-
Response._read_chunked()— readchunk-size CRLF chunk-data CRLF ... 0 CRLF CRLF. Handles the;extension separator (drop trailing chunk extensions) and trailers. - Wire up
Response.content:- If
Transfer-Encoding: chunked→_read_chunked(). - Else if
Content-Length: N→_read_raw(N)(Step 1 behaviour). - Else → read-until-EOF.
- If
- If
Content-Encoding: gzip(ordeflate), decompress throughdeflate.DeflateIO(io.BytesIO(raw), deflate.GZIP).read().
- A server that's known to chunk (
http://httpforever.com/or a local Pythonpython3 -m http.serverreturning chunked responses). - A gzip-serving endpoint: e.g.
http://www.python.org/withAccept-Encoding: gzipin our default request.
deflate must be in our build. MICROPY_PY_DEFLATE is set under
MICROPY_CONFIG_ROM_LEVEL_EXTRA_FEATURES (our level). Confirm
import deflate works on the standard variant before relying
on it; if absent, either flip the config or skip the gzip path
and document.
- In
urequests.request(), when the URL ishttps://:Single context per call (cheap on AmiSSL — Phase 28 measured ~316 KB of system memory the first time, ~125 KB per subsequent session; not worth caching at the urequests layer).import ssl ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) ctx.verify_mode = ssl.CERT_REQUIRED ctx.set_default_verify_paths() s = ctx.wrap_socket(s, server_hostname=host)
- Use
SSLSocket.write/read(our Phase 28 stream protocol surface) for I/O on the wrapped socket.
-
urequests.get("https://www.python.org/")→ 200 OK, HTML body. -
urequests.get("https://example.com")against a Cloudflare-fronted host — known to fail with the server-timeout-on-slow-CPU issue inherited from Phase 28; documented but not gated out. See the Known limitation above.
- Verb wrappers:
post,put,delete,patch(plusoptionsif cheap). -
request(method, url, *, data=None, json=None, headers=None):- If
jsonis supplied:data = json.dumps(json_obj), auto-setContent-Type: application/json. - If
dataisstr, encode to UTF-8 bytes. - If
dataisbytes, accept as-is. - If
datais a dict, urlencode it (application/x-www-form-urlencoded) — small inlineurlencode()helper rather than dragging inurllib.parse. - Set
Content-Length: len(data)and send the body after the empty\r\n.
- If
- Caller's
headersdict wins over our defaults (case-insensitive override): if caller setsContent-Type, don't double-send.
-
urequests.post("https://httpbin.org/post", json={"a": 1})returns 200 with the echoed JSON in.json(). - Headers override:
headers={"User-Agent": "X"}showsX, not our default.
-
ports/amiga/manifest.py:freeze("$(PORT_DIR)/modules", "urequests.py")
- Build the four variants; confirm
urequestsis absent fromminimaland present in the other three. -
docs/amiga.mdPhase 29 status → ✅; remove the "Phase 29 picks up urequests" forward-reference in Phase 28 design. -
docs/amiga-testing.md— add a short subsection under "SSL tests" pointing at urequests as the recommended way to do HTTPS GETs. -
tests/ports/amiga/test_urequests_smoke.py— a small on-device smoke that hitshttp://www.example.com/andhttps://www.python.org/and prints the first response line + content length.
-
import urequests; urequests.get("http://www.example.com/").text[:80]on the on-device runner against thestandardbinary.
-
HTTP/1.0 wire form. We send
HTTP/1.0withConnection: closeso we can do "read until EOF" without managing keep-alive state. Servers are happy to downgrade. HTTP/1.1 keep-alive is a Step 6 follow-up if it ever lands. -
Response buffering.
_read_lineand_read_rawshare abytearray-backed buffer to avoid onerecvper byte (the OoZe reference does this; without it, response parsing on the Amiga is noticeably slow because everyrecv()round-trips intobsdsocket.library). -
Error semantics. Network failures (DNS lookup,
connect,recv) raiseOSErroras the socket layer would; HTTP errors do not raise — caller checksstatus_code. -
Timeouts. Not in Step 1–5. Socket-level timeout via
socket.settimeoutwould be the natural Step 6. -
Heap pressure.
Response.contentmaterialises the whole body into a singlebytes. For a 1 MB download on theminimal-ish heap that means ~1 MB of GC heap pressure (split heap can grow but it's noticeable). Document; don't pre-optimise. -
No
Sessionobject. Cookies, persistent connections, and custom adapters all rest on aSession. Not in scope.
ports/amiga/modules/urequests.py — frozen pure-Python module
ports/amiga/manifest.py — freeze rule
tests/ports/amiga/test_urequests_smoke.py — on-target smoke (Amiberry, AmiSSL installed)
All three shipped variants (standard, 68020fpu, 68040)
include it; minimal does not ship socket/ssl, so it gets
nothing.
All five steps shipped 2026-05-31. Verified end-to-end under Amiberry in a single boot session across five live endpoints:
| Test | Result |
|---|---|
urequests.get("http://www.example.com/") |
200, 528 B body (gzip decoded transparently) |
urequests.get("http://httpbin.org/get") |
200, 274 B JSON |
urequests.get("https://www.python.org/") |
200, 48 887 B HTML through Phase 28 TLS |
urequests.post("http://httpbin.org/post", json={"hello": "amiga", "n": 42}, headers={"X-Test": "phase29"}) |
200, JSON echoed, X-Test override survived round-trip |
urequests.post("http://httpbin.org/post", data={"name": "amiga 1200", "ram_kb": 2048}) |
200, form echoed, Content-Type: application/x-www-form-urlencoded auto-set |
The HTTPS path turned up a real gap in modssl.c: SSLSocket
exposed read / write (stream protocol) only — no
send / recv aliases. Calling s.send(...) after
ctx.wrap_socket(...) raised AttributeError. Added a two-line
alias in ssl_socket_locals_dict_table pointing send /
recv at the same mp_stream_write_obj / mp_stream_read_obj
the stream-protocol entries use, so SSLSocket is now drop-in
compatible with the BSD-socket idiom modsocket.c exposes.
The frozen urequests.py ships in all four variants (the
manifest's freeze("$(PORT_DIR)/modules") rule is
unconditional). On minimal it imports cleanly but the
constructor calls fail at the implicit import socket step —
correct behaviour, since minimal deliberately omits
MICROPY_PY_AMIGA_SOCKET. No variant carve-out needed.