Module `http`

Client HTTP et serveur léger.

  • Client : ureq 3 (sync, blocking, TLS via rustls)
  • Serveur : tiny_http (sync, single-request ou async via thread + channel)
import('http')

response = http.get("https://example.com")
print(response.status)  # 200

Le client et le serveur sont indépendants. Tu peux les utiliser séparément ou ensemble (par exemple un proxy qui reçoit côté serveur puis re-envoie côté client).

Constants

Attribut Valeur Description
http.PROTOCOL "rust" Marqueur module natif
http.VERSION "0.2.0" Version semver du module
http.Request "http.Request" Type marker (réservé)
http.Response "http.Response" Type marker (réservé)

Client HTTP

Verbes basiques

http.get(url)             # → Response
http.post(url, body)      # → Response ; body est optionnel
http.put(url, body)       # → Response ; body est optionnel
http.delete(url)          # → Response

Exemples :

# ⇒ GET simple
r = http.get("https://httpbin.org/get")
print(r.status)           # 200
print(r.body)             # "{ ... }"

# ⇒ POST avec body
r = http.post("https://httpbin.org/post", "name=cat")
print(r.status)           # 200

Les statuses 4xx et 5xx remontent comme Response (pas comme exception). Les erreurs réseau, URL invalides ou timeouts lèvent une exception Catnip.

request() avec options

http.request(method, url, opts)

method et url sont des strings, opts est un dict ou nil.

Clé Type Défaut Description
headers dict[str, str] {} Headers à envoyer
body str "" Body de la requête (string)
timeout float aucun Timeout global en secondes
max_body int 33554432 Limite de lecture du body de réponse (bytes ; 32 MB)
r = http.request("POST", "https://api.example.com/items", dict(
    headers=dict(("Content-Type", "application/json")),
    body='{"name": "cat"}',
    timeout=5.0,
))

Un body de réponse > max_body produit une erreur de lecture explicite, pas un body tronqué silencieux. Avant la 0.0.9 c'était l'inverse, et c'était dangereux.

timeout doit être un nombre fini de secondes. Une valeur infinie, nan ou hors de portée d'une durée lève ; une valeur négative vaut zéro. Même règle pour Server.recv_timeout().

Object Response

Attribut Type Description
status int Code HTTP (200, 404, etc.)
headers dict[str, str] Headers reçus (noms lowercase)
body str Body lu intégralement (UTF-8 lossy)
Méthode Retour Description
.json() any Parse body comme JSON. Lève en cas de JSON invalide
r = http.get("https://api.github.com/repos/anthropics/claude-code")
data = r.json()
print(data['stargazers_count'])

Le parser JSON préserve la précision : entiers > 2^46 deviennent BigInt, u64::MAX reste exact. Les floats restent floats, null devient nil.

Serveur HTTP

Mode synchrone (single-thread)

server = http.Server("127.0.0.1:8080")

while (true) {
    req = server.recv()        # bloquant
    if (req == nil) { break }  # close()
    req.respond("Hello", 200, "text/plain")
}

Méthodes du Server :

Méthode Retour Description
.recv() `Request nil`
.try_recv() `Request nil`
.recv_timeout(seconds) `Request nil`
.close() nil Arrête recv(). Joint le thread async si démarré
.addr str Adresse réelle ("127.0.0.1:42587" pour port 0)

Le mode try_recv ne lance pas de thread accept en arrière-plan : il interroge la queue interne de tiny_http. Si tu veux un vrai event loop sans bloquer, prends le mode async ci-dessous.

Mode asynchrone (channel-based)

start() lance un thread accept qui drain les requêtes dans un channel mpsc. recv_async() pop sans bloquer.

server = http.Server("127.0.0.1:0")  # port 0 = OS choisit
server.start()

# Event loop principal
while (running) {
    req = server.recv_async()
    if (req != nil) {
        handle(req)
    }
    do_other_work()
}

server.close()  # join le thread proprement
Méthode Retour Description
.start() nil Démarre le thread accept. Idempotent
.recv_async() `Request nil`

Si tu laisses le Server sortir de scope sans appeler close(), le thread est unblock et joint automatiquement. Le port est libéré aussi.

En pratique, appelle close() explicitement quand tu sais que tu as fini : ça rend les fins de programme plus prévisibles que de compter sur le GC.

Objets et threads

Un Server, une Request, une Response et un writer chunked vivent aussi longtemps que la valeur qui les désigne, et se lisent depuis n'importe quel thread — c'est ce que demande le pattern ci-dessus, où le thread qui fait recv_async() n'est pas celui qui répond. Deux propriétés en découlent :

  • un objet créé dans un thread survit à la fin de ce thread ; ce qui le libère est la disparition de la valeur, pas celle du thread ;
  • une valeur qui a déjà été consommée ne désigne plus rien : une Request sur laquelle respond() a été appelé lève request already consumed or invalid handle, jamais l'objet de quelqu'un d'autre.

Deux threads peuvent donc tenir la même Request : le premier qui répond gagne, le second reçoit cette erreur.

Object Request

Attributs :

Attribut Type Description
url str URL path ("/foo?bar=1")
method str "GET", "POST", etc.
headers dict[str, str] Headers entrants (noms lowercase)
cookies dict[str, str] Cookies parsés depuis le header Cookie:

Méthodes :

Méthode Retour Description
.body(max) str Lit le body brut comme string
.multipart(max) list[dict] Parse multipart/form-data. Voir ci-dessous
.respond(body, status, ct, headers) nil Envoie une réponse simple
.start_chunked(status, ct, headers) Chunked Démarre une réponse chunked
.start_sse(headers) Chunked Démarre une réponse SSE (text/event-stream)

respond(), start_chunked() et start_sse() consomment la requête (utilisable une seule fois).

body() et multipart() lisent au plus 32 Mo par défaut, et lèvent au-delà au lieu de tronquer — même règle et même défaut que le max_body du client. Ici le pair est celui qui s'est connecté, donc la taille est la sienne : sans plafond, une seule requête fait enfler le processus d'autant qu'elle veut envoyer (mesuré à 207 Mo de résident pour 200 Mo annoncés et envoyés). Passer une limite explicite pour accepter plus gros : req.body(100 * 1024 * 1024).

status doit tenir dans 100..=599 et ct ne peut porter que du texte ASCII visible et des tabulations. Les deux valeurs partent telles quelles sur le fil : un status hors bornes y écrivait une ligne de statut inventée, et un retour à la ligne dans ct terminait le champ, donc ce qui suivait devenait des en-têtes — et après une ligne vide, un corps de réponse entier. Les deux lèvent maintenant, sans consommer la requête. Les octets au-delà de l'ASCII sont refusés aussi : la RFC 9110 les tolère mais demande aux émetteurs de ne pas en produire, et laisse chaque client libre de les lire ou de les rejeter — un type de contenu est de l'ASCII (RFC 6838 §4.2). Même règle pour le content_type de serve(), dont le port doit par ailleurs tenir dans 0..=65535.

Un argument du mauvais type ne prend plus la valeur par défaut : respond("x", "oops") répondait 200, un timeout donné comme chaîne partait sans échéance et un en-tête non textuel était retiré de la requête sans rien dire. Absent et nil valent le défaut, le reste lève.

Ce qu'un programme écrit dans un en-tête est ce que le client lit. La seule question ouverte était de savoir qui décidait où l'en-tête s'arrêtait.

En-têtes de réponse

Le dernier argument des trois méthodes est un dict str -> str d'en-têtes libres :

req.respond("bienvenue", 200, "text/plain", dict(
    ("Set-Cookie", "session=abc; HttpOnly; SameSite=Lax"),
    ("Access-Control-Allow-Origin", "*")
))

Les noms sont vérifiés aussi strictement que les valeurs : un nom doit être un token (RFC 9110 §5.6.2), donc sans deux-points ni espace ni retour à la ligne — un nom porte une réponse forgée aussi bien qu'une valeur. Trois noms sont refusés plutôt qu'écrits :

Nom refusé Raison
Content-Type il a son propre argument ; deux sources rendraient le résultat dépendant d'un ordre invisible
Content-Length contredirait le cadrage de la réponse
Transfer-Encoding idem

Les autres en-têtes que le module pose par défaut ne sont pas des refus mais des défauts : donner Cache-Control remplace le no-cache de start_chunked() au lieu de s'ajouter à côté, si bien que le client n'a jamais deux valeurs à départager.

Multipart

Pour parser un upload multipart/form-data côté serveur :

req = server.recv()
parts = req.multipart()

for part in parts {
    print(part['name'], part['filename'], part['content_type'])
    # part['data'] est bytes (préserve le binaire)
}

Chaque part contient :

Clé Type Description
name str Nom du champ (Content-Disposition)
filename `str nil`
content_type `str nil`
data bytes Contenu brut du part

Le parser respecte RFC 7578 : boundary ancré sur les delimiter lines (pas de split sur des bytes intérieurs au payload), noms d'headers et paramètres case-insensitive.

Pas de version client (envoyer du multipart) pour l'instant. C'est faisable manuellement en construisant le body

  • le Content-Type: multipart/form-data; boundary=... à la main, mais une API dédiée viendra si besoin.

Cookies

req.cookies est un dict { name: value } parsé depuis le header Cookie:. Plusieurs headers Cookie: sont fusionnés.

req = server.recv()
session_id = req.cookies['session']

Pas de gestion des attributs (path, domain, expires) côté lecture : c'est juste le format envoyé par le client.

Envoyer un cookie passe par le dict d'en-têtes de respond(), start_chunked() ou start_sse() :

req.respond("connecté", 200, "text/plain", dict(
    ("Set-Cookie", "session=abc; HttpOnly; SameSite=Lax")
))

Le module n'assemble pas la valeur pour toi : les attributs (Path, Domain, Expires, Max-Age) s'écrivent à la main, comme côté lecture. Ce qui est vérifié, c'est que la valeur reste dans un en-tête — voir En-têtes de réponse.

Streaming (Chunked)

start_chunked() et start_sse() retournent un Chunked writer pour les réponses streamées (chunked transfer encoding HTTP/1.1).

req = server.recv()
stream = req.start_chunked(200, "text/plain")
stream.send_chunk("Hello ")
stream.send_chunk("World")
stream.end()
Méthode Retour Description
.send_chunk(data) nil Envoie un chunk. Les chunks vides sont ignorés
.send_event(data, event_type?) nil Envoie un event SSE. Multi-lignes split en data: séparés
.end() nil Envoie le terminator. Auto-appelé sur drop

Server-Sent Events

req = server.recv()
stream = req.start_sse()

stream.send_event("hello")
# Wire: "data: hello\n\n"

stream.send_event('{"x": 1}', "update")
# Wire: "event: update\ndata: {\"x\": 1}\n\n"

stream.send_event("line1\nline2")
# Wire: "data: line1\ndata: line2\n\n"

stream.end()

Un événement se termine à la ligne vide, et la spécification compte \n, \r et \r\n comme des fins de ligne. Les trois deviennent donc une ligne data: de plus — le client les rejoint avec un retour à la ligne, et un \r isolé ne peut pas voyager tel quel, le format ne sait pas l'exprimer. L'event_type, lui, occupe une seule ligne : une fin de ligne y est refusée, sans quoi elle ouvrirait des champs, voire des événements entiers, que l'appelant n'a pas écrits.

Refus du streaming

start_chunked() et start_sse() lèvent une erreur si la combinaison requête/status est protocole-invalide :

  • HEAD : ne doit pas avoir de body — utilise respond() avec body vide
  • HTTP/1.0 : pas de chunked encoding — utilise respond()
  • Status 1xx, 204, 304 : interdit d'avoir un body (RFC 7230 §3.3)

Le code applicatif peut alors retomber sur respond() pour ces cas particuliers.

Auth helpers

http.basic_auth(user, password)   # → "Basic <base64(user:password)>"
http.bearer(token)                # → "Bearer <token>"

À utiliser dans opts.headers.Authorization :

r = http.request("GET", "https://api.example.com/me", dict(
    headers=dict(("Authorization", http.bearer("abc123"))),
))

serve() : helper one-shot

Sert un contenu statique, ouvre le navigateur, attend une requête, répond, retourne.

http.serve("<h1>Hello</h1>", 0, nil, true)
# port 0 → OS choisit
# content_type nil → auto-détecté (text/html, image/svg+xml, text/plain)
# open_browser true → ouvre le navigateur sur l'URL

Pratique pour debug, preview, ou afficher un graphe SVG. Pas adapté à un vrai serveur (single-request).

Limitations

  • Pas de HTTP/2 (ureq sync supporte uniquement HTTP/1.1)
  • Pas de WebSocket
  • Pas de multipart côté client
  • Body de réponse buffered en mémoire (max 32 MB par défaut, override via max_body)
  • Le serveur traite une requête à la fois ; pour le concurrent, lancer plusieurs Server ou utiliser le mode async + thread pool côté application

Le périmètre du module reste "pratique pour scripts et exemples". Pour un serveur HTTP production il vaut mieux sortir du runtime Catnip et utiliser un crate Rust dédié (axum, actix) -- ou intégrer Catnip comme handler à l'intérieur.