Module `http`
Sommaire
- Constants
- Client HTTP
- Verbes basiques
- request() avec options
- Object Response
- Serveur HTTP
- Mode synchrone (single-thread)
- Mode asynchrone (channel-based)
- Objets et threads
- Object Request
- En-têtes de réponse
- Multipart
- Cookies
- Streaming (Chunked)
- Server-Sent Events
- Refus du streaming
- Auth helpers
- serve() : helper one-shot
- Limitations
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_bodyproduit 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_recvne lance pas de thread accept en arrière-plan : il interroge la queue interne detiny_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
Requestsur laquellerespond()a été appelé lèverequest 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
Serverou 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.