Networking
Dragon's networking stack is layered the way you would expect. At the bottom is socket, a thin set of wrappers over the BSD socket syscalls. Above that, http.client speaks HTTP/1.1 over a TCP stream. Above that sit the conveniences: the http package root is the request API you should reach for first (get/post/Client), urllib.request adds Python-parity urlopen, and urllib.parse handles the URL and query-string bookkeeping that every HTTP client needs. The modules compose top to bottom: http and urllib.request are written in terms of http.client, which is written in terms of socket.
One property runs through the whole stack and is worth stating up front: the I/O is colorless and scheduler-aware. The socket calls present a blocking API - send returns when the bytes are queued, recv returns when data arrives - but underneath, when a call would block, it yields the current green thread to the scheduler instead of parking the carrier OS thread. Thousands of in-flight connections share a handful of OS threads; off a green thread the same calls fall back to poll(), so nothing breaks. You write straight-line blocking code; the runtime makes it scale. And because Dragon's await is colorless, the async def verbs at the top of the stack impose nothing on their callers - there is no sync/async split to maintain anywhere below.
This chapter covers the client side. The HTTP server - routing, request parsing, the App/Router API - lives in its own chapter, Web Application; the minimal hand-rolled servers below exist only to give the client examples something to talk to.
http - the request API
from http import get, post is the way most programs should make HTTP requests. Every verb (get, post, put, patch, delete, head, options) is an async def returning a Response, so one definition serves both straight-line and concurrent use:
from http import get, post, Response
r: Response = await get("https://api.example.com/users?page=2")
print(r.status) # 200
print(r.header("content-type")) # application/json
print(r.text)
payload: dict[str, Any] = {"name": "Ada"}
created: Response = await post("https://api.example.com/users", json=payload)
a: Task[Response] = get("https://api.example.com/users/1") # two requests
b: Task[Response] = get("https://api.example.com/users/2") # in flight
first: Response = await a
second: Response = await b
A Response is fully buffered: the connection is drained and closed inside the verb, so a Response never owns a socket - not even one stranded in a dropped Task (see Ownership for what happens to a dropped task's result). Its surface: status, reason, text, url, the as-received headers dict plus case-insensitive header(name, default), ok() (status < 400), json() -> Any (the boxed decode tier), and raise_for_status(), which raises HTTPStatusError on 4xx/5xx and returns the response otherwise so it chains:
from http import get, Response, HTTPStatusError
try {
user: Response = (await get("https://api.example.com/me")).raise_for_status()
print(user.text)
} except HTTPStatusError as e {
print(f"request failed: {e.status} {e.reason} for {e.url}")
}
Request bodies are mutually exclusive typed keywords: body: str (raw, wins), json: dict[str, Any] (serialized, Content-Type: application/json), and data: dict[str, str] (form-encoded). params: dict[str, str] appends to the query string; headers: dict[str, str] adds or overrides per call. An empty json dict sends no body - send a literal empty object as body="{}" with an explicit Content-Type header.
Client carries shared defaults - a base_url prefix and headers sent with every call (per-call headers win) - and is with-scoped:
from http import Client, Response
auth: dict[str, str] = {"Authorization": "Bearer token"}
with Client("https://api.example.com", headers=auth) as api {
users: Response = await api.get("/v1/users")
gone: Response = await api.delete("/v1/users/9")
}
Two v1 limits, stated honestly: redirects are not followed (r.status tells you what the server said), and there is no per-request timeout yet. Both the verbs and Client open one connection per request.
urllib.parse - URLs and query strings
urllib.parse is pure computation: no sockets, no I/O, just RFC 3986 string surgery. It is the one module here you can exercise without a network, and the one you will reach for most often. Import the functions you need:
from urllib.parse import quote, unquote, quote_plus, unquote_plus
Percent-encoding
quote(s: str, safe: str = "/") percent-encodes a string for use in a URL path. The RFC 3986 unreserved set (A-Z a-z 0-9 - . _ ~) is always left verbatim; the safe argument names additional characters to leave alone (default /, so path separators survive). unquote(s: str) reverses it. Bytes outside ASCII are UTF-8 encoded first, then each byte is escaped as %HH with uppercase hex.
from urllib.parse import quote, unquote
print(quote("a b/c?d")) # a%20b/c%3Fd
print(quote("a b/c?d", "")) # a%20b%2Fc%3Fd (slash now escaped)
print(unquote("a%20b%2Fc")) # a b/c
quote_plus(s: str, safe: str = "") is the form-encoding variant: spaces become + and the default safe set is empty, so / is escaped too. unquote_plus(s: str) reverses it, turning + back into a space. This is the pair you want for application/x-www-form-urlencoded data.
from urllib.parse import quote_plus, unquote_plus
original: str = "hello world & more=stuff"
encoded: str = quote_plus(original)
print(encoded) # hello+world+%26+more%3Dstuff
print(unquote_plus(encoded)) # hello world & more=stuff
Building and parsing query strings
urlencode(query: dict[str, str]) turns a dict into a &-joined query string, percent-encoding both keys and values with quote_plus:
from urllib.parse import urlencode
params: dict[str, str] = {"q": "dragon lang", "page": "2"}
print(urlencode(params)) # q=dragon+lang&page=2
Differs from Python. Dragon's
urlencodetakes adict[str, str]only - there is nodoseqargument and no list-of-pairs input. A key cannot repeat, because a Dragon dict cannot hold a duplicate key. To emit a repeated parameter, build the string yourself.
Going the other way, parse_qsl(qs, keep_blank_values=False) returns the pairs in order as a list[tuple[str, str]], and parse_qs(qs, keep_blank_values=False) groups them into a dict[str, list[str]] so a repeated key collects all its values:
from urllib.parse import parse_qsl, parse_qs
items: list[tuple[str, str]] = parse_qsl("a=1&b=2&a=3")
for kv in items {
print(f"{kv[0]} -> {kv[1]}")
}
# a -> 1
# b -> 2
# a -> 3
grouped: dict[str, list[str]] = parse_qs("a=1&b=2&a=3")
for v in grouped["a"] {
print(v)
}
# 1
# 3
By default a key with no value (b= or a bare c) is dropped, matching CPython. Pass True for keep_blank_values to retain them with an empty-string value:
from urllib.parse import parse_qsl
for kv in parse_qsl("a=1&b=&c", True) {
print(f"[{kv[0]}]=[{kv[1]}]")
}
# [a]=[1]
# [b]=[]
# [c]=[]
Splitting and joining whole URLs
urlsplit(url: str) cracks a URL into its components and returns a SplitResult with fields scheme, netloc, path, query, and fragment. The result has a geturl() method that reassembles the original. urlparse(url: str) is the same but additionally peels a rarely-used ;params segment off the path, returning a ParseResult with the extra params field.
from urllib.parse import urlsplit, SplitResult
sr: SplitResult = urlsplit("https://example.com:8080/path/to?q=1#frag")
print(f"scheme={sr.scheme} netloc={sr.netloc} path={sr.path}")
# scheme=https netloc=example.com:8080 path=/path/to
print(sr.query) # q=1
print(sr.fragment) # frag
print(sr.geturl()) # https://example.com:8080/path/to?q=1#frag
urljoin(base, ref) resolves a reference against a base URL (RFC 3986 ยง5.3), and urldefrag(url) splits off the #fragment, returning a tuple[str, str] of the URL and the fragment:
from urllib.parse import urljoin, urldefrag
print(urljoin("http://host/a/b", "/x")) # http://host/x
frag: tuple[str, str] = urldefrag("http://h/p#section")
print(f"{frag[0]} | {frag[1]}") # http://h/p | section
Differs from Python.
urlsplit/urlparsereturn plain classes with public attributes and ageturl()method - not the named-tuple-with-extra-properties CPython returns, so there is noport/hostname/usernamedecomposition on the result and no tuple indexing.urljoindoes the structural merge but does not normalize../.path segments, sourljoin("http://host/a/b/c", "../d")yieldshttp://host/a/b/../drather than collapsing the dot segment.
socket - TCP and UDP sockets
socket wraps the BSD socket syscalls behind three classes. It is deliberately object-oriented rather than mirroring CPython's single socket.socket() factory: a server uses TcpListener, a connection (either end) is a TcpStream, and datagrams go through UdpSocket.
TcpStream.open(host: str, port: int) is a static method that resolves the host, connects, and returns a connected stream. On it you call send(data: str) -> int, recv(size: int) -> str, and close() -> int. For binary protocols there are send_bytes(data: bytes) and recv_bytes(size: int) -> bytes on the same scheduler-yielding path. A TcpStream is a context manager, so with closes it for you.
On the server side, construct TcpListener(host, port), then call bind(), listen(backlog), and accept() -> TcpStream. gethostbyname(host: str) -> str resolves a name to a dotted IPv4 string (a literal IP passes through unchanged):
from socket import gethostbyname
print(gethostbyname("127.0.0.1")) # 127.0.0.1
Here is a complete loopback echo. Note the ordering: the listener binds and listens on the main thread before anything connects, and only the blocking accept() runs on a fired vthread. That guarantees the socket is listening before the client dials it.
from socket import TcpListener, TcpStream
# Bind + listen first so the socket is ready before the client connects.
listener: TcpListener = TcpListener("127.0.0.1", 8765)
listener.bind()
listener.listen(1)
def serve() -> None {
conn: TcpStream = listener.accept()
data: str = conn.recv(1024)
conn.send(f"echo:{data}")
conn.close()
}
server: Task[None] = fire serve()
with TcpStream.open("127.0.0.1", 8765) as client {
client.send("hello")
print(client.recv(1024)) # echo:hello
}
server.join()
listener.close()
UdpSocket(host, port) rounds out the module for datagrams, with bind(), sendto(data, host, port) -> int, recvfrom(size) -> str, and close().
Differs from Python. There is no single
socket.socket()object withconnect/bind/send/recvmethods and a family/type argument - Dragon splits the roles intoTcpListener,TcpStream, andUdpSocket; the method names themselves (bind,listen,accept,send,recv,shutdown) match Python's. Sockets are IPv4-only (AF_INET); there is no IPv6, nosocket.gethostname(), and no address-family/socket-type constants - the variants are the three classes. EveryTcpStreamsetsTCP_NODELAYin its constructor, so request/response protocols do not eat a Nagle/delayed-ACK stall.
http.client - HTTP/1.1 requests
http.client speaks HTTP/1.1 over a TcpStream. The shape matches CPython's http.client: construct a connection, call request, then getresponse.
HTTPConnection(host: str, port: int) creates a (not-yet-connected) connection. request(method, url, body, headers) sends the request line plus headers and an optional body; all four arguments are required - pass "" for an empty body and an empty dict[str, str] for no extra headers. getresponse() -> HTTPResponse reads and parses the reply.
The HTTPResponse exposes status: int, reason: str, and headers: dict[str, str] as fields, plus read() -> str (the body, returned once - a second read() yields ""), getheader(name, default="") -> str (case-insensitive), and getheaders() -> list[tuple[str, str]].
from socket import TcpListener, TcpStream
from http.client import HTTPConnection, HTTPResponse
# A one-shot HTTP server to answer the request below.
listener: TcpListener = TcpListener("127.0.0.1", 8767)
listener.bind()
listener.listen(1)
def serve() -> None {
conn: TcpStream = listener.accept()
conn.recv(4096)
conn.send("HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\npong")
conn.close()
}
server: Task[None] = fire serve()
http_conn: HTTPConnection = HTTPConnection("127.0.0.1", 8767)
no_headers: dict[str, str] = {}
http_conn.request("GET", "/ping", "", no_headers)
r: HTTPResponse = http_conn.getresponse()
print(f"{r.status} {r.reason}") # 200 OK
print(r.getheader("Content-Type")) # text/plain
print(r.read()) # pong
http_conn.close()
server.join()
listener.close()
Differs from Python.
requesthas no default arguments - CPython lets you callconn.request("GET", "/")with body and headers defaulted, but in Dragon you must pass all four (""and{}). The connection always sendsConnection: closeandgetresponse()reads until EOF, so there is noContent-Length/chunked early-exit yet and a connection is single-use (open a freshHTTPConnectionper request).read()takes noamtargument - the full body is buffered. There is anHTTPSConnectionwith the same shape, backed by anssl.SSLSocket; see Cryptography and Hashing for the TLS layer it builds on.
urllib.request - high-level HTTP
urllib.request sits on top of http.client and adds the two conveniences most client code wants: a one-call urlopen and automatic redirect following (up to five 3xx hops, resolving relative Location headers against the previous URL). It returns the underlying http.client.HTTPResponse, so you inspect status/headers/read() exactly as above.
urlopen(target: str) -> HTTPResponse issues a GET for a URL string. For anything beyond a plain GET, build a Request(url, data, method) - its add_header(key, value) extends the headers, and get_method() defaults to POST when data is non-empty and GET otherwise - then hand it to urlopen_request(req).
from socket import TcpListener, TcpStream
from urllib.request import urlopen, urlopen_request, Request
from http.client import HTTPResponse
# Server that answers two requests, then exits.
listener: TcpListener = TcpListener("127.0.0.1", 8768)
listener.bind()
listener.listen(2)
def serve(n: int) -> None {
i: int = 0
while i < n {
conn: TcpStream = listener.accept()
conn.recv(4096)
conn.send("HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello")
conn.close()
i = i + 1
}
}
server: Task[None] = fire serve(2)
r1: HTTPResponse = urlopen("http://127.0.0.1:8768/")
print(f"{r1.status}: {r1.read()}") # 200: hello
req: Request = Request("http://127.0.0.1:8768/submit", "x=1", "POST")
req.add_header("User-Agent", "Dragon/0.2")
r2: HTTPResponse = urlopen_request(req)
print(f"{r2.status}: {r2.read()}") # 200: hello
server.join()
listener.close()
Differs from Python.
Request(url, data, method)takes three positional arguments - there are noheaders=/origin_req_host=keywords; build the object and then calladd_header. The dispatch is split:urlopentakes astr, while aRequestgoes throughurlopen_request(CPython overloads a singleurlopenover both). Onlyhttp://is supported -urlopen("https://...")raisesValueErrorbecause the high-level layer is not yet wired to TLS (usehttp.client.HTTPSConnectiondirectly for HTTPS).datais astr, notbytes.
At a glance
| Module | Import | Key entry points | Notes |
|---|---|---|---|
urllib.parse | from urllib.parse import quote, urlencode, urlsplit | quote/unquote/quote_plus/unquote_plus, urlencode, parse_qs/parse_qsl, urlsplit/urlparse, urljoin, urldefrag | Pure, no I/O; urlencode is dict-only; urljoin does not normalize .. |
socket | from socket import TcpListener, TcpStream, UdpSocket | TcpStream.open/send/recv, TcpListener.bind/listen/accept, UdpSocket, gethostbyname | IPv4 only; three role classes, not one factory; scheduler-yielding I/O; with closes |
http.client | from http.client import HTTPConnection, HTTPResponse | HTTPConnection.request/getresponse, HTTPResponse.read/getheader/status | request needs all four args; Connection: close, one request per connection; HTTPSConnection for TLS |
urllib.request | from urllib.request import urlopen, Request | urlopen, urlopen_request, Request/add_header | Follows up to 5 redirects; http:// only; data is str |
With bytes flowing across the wire, the next concern is the machine those bytes run on - spawning subprocesses, reading the environment, and exiting with a status the shell can test. That is Processes and the OS.
