http
stdlib/extra/http.hk: HTTP/1.1 client and server over extra/net.
A pure-Hanki layer on top of net's blocking TCP sockets. Three pieces:
- Framing:
parse_request/parse_responseturn a complete byte buffer into aRequest/Response;render_request/render_responseturn one back into bytes (aResult, since a header carrying a bare CR/LF is rejected to block injection).read_request!/read_response!/write_response!do the same against a live transport. The readers takeS: ReadStreamand the writer takesS: WriteStream. A full transport or its directional half works. Those bear!because they touch it. Parsing itself is pure: a framing parser performs no I/O, and is callable from a puredef, awhere, or ametablock.- Client:
get!andrequest!open one connection for one request. Client connections start with one absolute 60-second deadline covering resolution, connect, TLS negotiation, write, and response read.Client<S>owns a caller-opened transport and sends several requests over it, saving repeated TCP and TLS handshakes; on aDeadlineStream,set_deadline!resets that budget for the next exchange. Progress never refreshes it, and either transport reports expiry asHttpError.TimedOut. Its framing preservesStream's implementation row and convertsS.ErrorthroughInto; a pure in-memory stream therefore performs noneteffect. - Server:
serve!accepts connections and runs a handler one at a time. True concurrency is the caller's: spawn a per-connection worker actor andmovethe accepted socket in (HANKI.md §15), reading and writing with the framing functions here.
- Client:
v0 wire subset: a response to HEAD, and any response whose status is 1xx, 204 or 304, has no body and is framed as empty whatever its header fields say (RFC 9112 6.3); render_response emits such a status with an empty body and no Content-Length. A reader discards an interim 1xx and reads the head after it, bounded, since RFC 9110 15.2 requires a client to read past one it did not expect; 101 is final, the protocol switch itself. Every other body is delimited by Content-Length or by Transfer-Encoding: chunked, which is decoded here: a size line, that many bytes, repeating to a zero size, extensions dropped and the trailer discarded. It is the one transfer coding an HTTP/1.1 recipient may not skip (RFC 9112 7.1), and the one a server reaches for whenever it cannot know the length up front. Every other coding is refused as UnsupportedTransferEncoding and never handed back with its framing as the body, since that corruption surfaces in whatever parses the payload next and blames itself. A message declaring both a coding and a length is ConflictingFraming, refused outright (RFC 9112 6.3). Responses rendered by this module send Connection: close, matching the one-request server loop. render_request preserves a caller's Connection field and otherwise uses HTTP/1.1's persistent default, which lets Client<S> reuse its transport. The one-shot request! supplies Connection: close itself. Header blocks and bodies are size-bounded, and an over-large one fails with HttpError in place of allocating without limit. The bound is on the decoded size, which leaves chunking no way past it.
A decoded message retains its Transfer-Encoding field: the headers report what arrived, the body is the payload. render_request / render_response drop the field, since they frame by the Content-Length they compute.
Both ends speak TLS. https:// URLs work in get! / request!: the connection is TLS 1.3, or 1.2 for a peer limited to that, the peer is verified against the system trust store, and there is no way to turn that off. The server half is serve_tls!, over a tls.listen! listener. A http:// URL and a serve! listener are still cleartext, and everything on that wire, a bearer token in an Authorization header as much as the body, is readable and alterable by whatever sits on the path.
Which TLS version an https:// request negotiated is absent from Response by design: a Response is the message (status, headers, body), and transport already sits on the URL (Url.tls). A caller that must observe or bound the version opens the connection itself with tls.connect!, asks TlsStream.version!, and speaks HTTP over it with the framing functions here.
The two serve entry points differ only in what they refuse. serve! takes a TcpListener, speaks cleartext, and refuses any bind but loopback, a credential sent to a reachable port travelling in the open. serve_exposed! is the sanctioned way past that refusal for a process behind a terminating proxy. serve_tls! takes a TlsListener and bears no such check: over TLS a reachable bind is the intended deployment.
Header names are case-insensitive: they are stored lower-cased, which keys a parsed Headers in lower case throughout (headers.get("content-type")). A name may repeat, Set-Cookie doing so on nearly every login response, and every value is kept: get reads the first, get_all reads them all.
Method
type Method
Get
Post
Put
Delete
Head
Patch
Options
Other(string)
end
The HTTP request method. Unknown tokens are preserved verbatim in Other and never rejected.
impl Display<Method>
to_string
def to_string(self) -> string
Renders the method as its uppercase HTTP token.
Get.to_string() => "GET"
Delete.to_string() => "DELETE"
Other("PURGE").to_string() => "PURGE"
Headers
opaque Headers
fields: Map<string, List<string>>
end
A message's header fields. HTTP lets a field name repeat, and a name therefore maps to the list of its values in order of appearance and never to one value: collapsing them would lose every Set-Cookie past the first, and would let two Content-Length fields disagree unnoticed.
Field names are case-insensitive (RFC 9110 5.1), and every name-taking method lower-cases its argument. Behaviour depends on it: render_* computes and sets content-length, and under a case-sensitive lookup a caller's Content-Length would survive beside it, two on the wire.
impl Headers
empty
def empty() -> Headers
A header section with no fields.
Headers.empty().names.length => 0
get
def get(self, name: string) -> Option<string>
The first value for name, or None when the field is absent. This is the common case; a field that legitimately repeats wants get_all.
Headers.empty().add("x-a", "1").get("X-A").unwrap_or("?") => "1"
Headers.empty().get("x-a").unwrap_or("?") => "?"
get_all
def get_all(self, name: string) -> List<string>
Every value for name, in order of appearance; empty when absent.
h = Headers.empty().add("Set-Cookie", "a=1").add("set-cookie", "b=2")
h.get_all("SET-COOKIE").length => 2
set
def set(self, name: string, value: string) -> Headers
self with name set to value, dropping any values already there. A computed field wants this, Content-Length being one that must not accumulate; a repeatable one wants add.
Headers.empty().add("x-a", "1").set("X-A", "2").get_all("x-a").length => 1
add
def add(self, name: string, value: string) -> Headers
self with value appended to name's values, keeping any already there. This is how a repeated header line accumulates.
Headers.empty().add("x-a", "1").add("X-A", "2").get_all("x-a").length => 2
names
prop names(self) -> List<string>
Every field name present, in Map iteration order (hash order, never the order they arrived in).
Headers.empty().set("x-a", "1").names.length => 1
Request
struct Request
method: Method
target: string
headers: Headers
body: bytes
end
A parsed request. target is the request-target as received (path plus any query) and no full URL.
Response
struct Response
status: i32
headers: Headers
body: bytes
end
A response. status is the numeric status code; the reason phrase is derived on render and discarded on parse.
Url
struct Url
host: string
port: u16
path: string
tls: bool
end
The pieces of a URL the client needs to open a connection. tls is the scheme: https:// sets it, http:// clears it, and it is what request! routes on. A bool and no scheme string: there are two transports, and a string would invite a third that nothing implements.
Client
opaque Client<S>
transport: S
end
A connection-owning HTTP client over a caller-opened transport. S is concrete: Client<TcpStream> and Client<TlsStream> are distinct types, and a caller supporting both schemes uses the same two-arm connect branch as request!. It is confined to the actor that constructs it because resources nested in values do not cross messages. To hand a connection to a worker, move the bare stream first and construct the client there. The opaque wrapper exposes no transport field; callers use the operations below.
HttpError
type HttpError
Net(sys.NetError)
Tls(sys.TlsFailure)
BadUrl
BadStartLine
BadHeader
HeadersTooLarge
BodyTooLarge
BadContentLength
UnsupportedTransferEncoding(string)
BadChunk
ConflictingFraming
Incomplete
InvalidWriteCount(i64)
BadUtf8
ExposedBind(string)
TimedOut
TooManyInterimResponses
end
Why an HTTP operation failed.
impl Into<sys.NetError, HttpError>
into
def into(self) -> HttpError
Normalize a socket deadline to HTTP's transport-independent TimedOut; preserve every other socket failure as Net. This is the conversion generic stream framing uses when its transport error is abstract.
sys.ConnectionRefused.into().to_string() => "connection refused"
impl Into<sys.TlsFailure, HttpError>
into
def into(self) -> HttpError
Normalize a TLS deadline to HTTP's transport-independent TimedOut; preserve every other TLS failure as Tls.
@no-doctest: constructing a representative failure would restate the native TLS error surface; stream parity covers this conversion
impl Display<HttpError>
to_string
def to_string(self) -> string
The reason, lowercase and without a trailing stop, which fits it inside a larger sentence as well as alone: "serve failed: #{e}". Net forwards the socket's own reason and never restates that a socket was involved.
ExposedBind is the one that says more than what went wrong. Every other variant reports a message that arrived malformed, where naming it is the whole job; that one reports a refusal, and a reader meeting it has usually not thought about the wire yet, and it names both ways forward.
BadUrl.to_string() => "malformed or unsupported-scheme URL"
Net(sys.ConnectionRefused).to_string() => "connection refused"
_Head
struct _Head
head: bytes
rest: bytes
end
The internal split of a head read off the wire: the header block up to and including the blank-line terminator, and any body bytes that arrived in the same read.
maxhead
def _max_head() -> int
Largest header block (request/status line + headers + terminator) read before giving up; bounds the framing buffer against a peer that never sends the blank line.
maxinterim_responses
def _max_interim_responses() -> int
Largest number of interim (1xx) responses a reader discards before a final one. RFC 9110 15.2 requires a client to read past an unexpected interim response, and a peer that never sends a final one must not park the reader.
maxbody
def _max_body() -> int
Largest message body accepted.
maxheaders
def _max_headers() -> int
Largest number of header lines parsed; bounds the header-parse recursion. A chunked body's trailer section is a header block too, and shares it.
maxchunk_line
def _max_chunk_line() -> int
Largest chunk-size or trailer line accepted. A peer that never sends the CRLF ending one would otherwise make the reader buffer without limit.
maxchunksizedigits
def _max_chunk_size_digits() -> int
Largest chunk-size field accepted, in hexadecimal digits. 16 is a 64-bit size, past any body this module would accept anyway; the cap is here so a 4 KiB line of digits cannot be turned into an astronomical bignum before the body cap gets a chance to reject it.
byteat
def _byte_at(b: bytes, i: int) -> i32
The byte at i as an i32 in [0, 255], or -1 past the end.
indexof
def _index_of(b: bytes, target: i32, from: int) -> int
Index of the first byte equal to target at or after from, or -1.
indexof_crlf
def _index_of_crlf(b: bytes, from: int) -> int
Index of the first CRLF at or after from, or -1.
indexof_crlfcrlf
def _index_of_crlfcrlf(b: bytes, from: int) -> int
Index of the first blank-line terminator (CRLF CRLF) at or after from, or -1. Points at the first CR.
trimbytes
def _trim_bytes(b: bytes) -> bytes
Drop leading and trailing spaces and tabs.
_decode
def _decode(b: bytes) -> Result<string, HttpError>
methodof
def _method_of(s: string) -> Method
requestmethod
def _request_method(start_line: bytes) -> Result<Method, HttpError>
The method token (bytes before the first space of the request line).
requesttarget
def _request_target(start_line: bytes) -> Result<string, HttpError>
The request-target (bytes between the first and second spaces).
responsestatus
def _response_status(start_line: bytes) -> Result<i32, HttpError>
The numeric status code from a status line (HTTP/1.1 <code> <reason>). A missing reason phrase is tolerated.
parseheaders
def _parse_headers(head: bytes, from: int, acc: Headers, budget: int) -> Result<Headers, HttpError>
Parse header lines from head starting at byte from, folding into acc. budget counts remaining lines. Stops at the blank line.
_Coding
type _Coding
CodingNone
CodingChunked
CodingUnsupported(string)
end
What a message's Transfer-Encoding field asks of a recipient.
_Framing
type _Framing
FrameLength(int)
FrameChunked
FrameToEnd
end
How a message's body is delimited on the wire.
transfercoding
def _transfer_coding(headers: Headers) -> _Coding
Classify the Transfer-Encoding field. Several header lines are one list, so they are joined before splitting, and identity drops out wherever it sits, being a no-op coding that HTTP/1.1 does not define as a transfer coding at all.
What remains must be chunked alone. RFC 9112 6.1 makes the list ordered, with the last coding the one applied to the payload, but that only says which to strip first: a gzip, chunked body is still gzip once the chunk framing comes off, and this module has no gzip. Reading the last coding alone would also wave through a chunked, gzip head as if plain.
The whole value comes back, and never the offending coding alone: a caller reporting gzip, chunked should see both, and the header is what they would go looking for on the wire.
bodyframing
def _body_framing(headers: Headers) -> Result<_Framing, HttpError>
How the body of a message with these headers is framed, or a refusal.
It reads the header fields alone. Whether the message has a body at all is no header question: a response's status and the method of the request it answers settle that, in _response_framing.
The transfer-coding is settled first, outranking Content-Length (RFC 9112 6.3) and because the failure mode is the worse of the two: a refused length is a loud error, while framing returned as a payload is a 200 with a plausible body that breaks the parser above.
A message declaring both is refused outright and framed by neither (RFC 9112 6.3, the CL.TE request-smuggling shape, CWE-444): the two say different things about where this message ends, and any recipient in the chain is free to believe the other one and read a second message out of this one's body.
_bodiless?
def _bodiless?(status: i32, head_request: bool) -> bool
Whether the message has no body at all. RFC 9112 6.3 settles that by the status code and by the method of the request a response answers, and never by a header field: a 304 may have the Content-Length a 200 would have had, with no body behind it.
head_request says whether the request this response answers was a HEAD. parse_response reads a buffer with no request beside it and passes false: it applies the status rule, and the method rule needs a caller that sent the request.
responseframing
def _response_framing(headers: Headers, status: i32, head_request: bool) -> Result<_Framing, HttpError>
How the body of a response is framed, or a refusal. The header checks run in full first: a bodiless message that declares an unsupported coding, or both a coding and a length, is still refused, since a recipient further along the chain is free to frame it by the other reading and read a second message out of this one's body.
chunksize
def _chunk_size(line: bytes) -> Result<int, HttpError>
The value of one chunk-size line: hexadecimal in either case, with any ;-introduced extensions dropped. Nothing here reads an extension, and RFC 9112 7.1.1 lets a recipient ignore ones it does not recognise.
hexdigit
def _hex_digit(c: i32) -> int
The value of one hexadecimal digit, or -1 for any other byte.
decodechunked
def _decode_chunked(raw: bytes, max_body: int) -> Result<bytes, HttpError>
Decode a chunked body out of a buffer that has all of it: a size line, CRLF, that many bytes, CRLF, repeating until a zero size, then a trailer section ending at a blank line.
The cap applies to the decoded total and is checked as chunks accumulate, which leaves a chunked body unable to spend more than a Content-Length one by arriving in many small pieces. Decoding is byte-level throughout: a multi-byte character may straddle a chunk boundary, and nothing here looks at what the bytes mean.
Trailer fields are discarded. Nothing in this module reads one, and a caller that needs them needs an API that separates them from the header block and no body that grew fields unannounced.
takebody
def _take_body(rest: bytes, framing: _Framing) -> Result<bytes, HttpError>
A request body as its framing delimits it. A Content-Length message with fewer bytes than declared is Incomplete and no shorter body: a framing parser's whole job is to say whether a message ended, and a caller that cannot tell truncation from completion has to reimplement the framing itself. A chunked one that stops mid-chunk is Incomplete for the same reason, and needs it more: without a declared length, truncation is otherwise indistinguishable from a complete small response.
takebody_eof
def _take_body_eof(rest: bytes, framing: _Framing) -> Result<bytes, HttpError>
A response body as its framing delimits it, FrameToEnd meaning everything received (read-until-close).
parse_request
def parse_request(buf: bytes) -> Result<Request, HttpError>
Parse a complete request from a buffer holding the head and body. A buffer holding less than a whole message is Incomplete and no short read, which lets a caller tell "not yet" from "malformed".
whole = "GET /x HTTP/1.1\r\nHost: h\r\n\r\n".to_bytes()
parse_request(whole).map(|q| q.target).unwrap_or("?") => "/x"
parse_request("GET /x HTTP".to_bytes()).map(|q| q.target).unwrap_or("?") => "?"
parse_response
def parse_response(buf: bytes) -> Result<Response, HttpError>
Parse a complete response from a buffer holding the head and body. The reason phrase is discarded, holding no meaning the status code does not.
parse_response("HTTP/1.1 204 No Content\r\n\r\n".to_bytes()).map(|r| r.status).unwrap_or(0i32) => 204i32
_reason
def _reason(status: i32) -> string
_authority
def _authority(u: Url) -> string
host for the default port, host:port otherwise.
headerlines
def _header_lines(k: string, h: Headers) -> string
One line per value, which returns a repeated field to the wire the way it arrived and never as one folded line; Set-Cookie may not be folded.
renderheaders
def _render_headers(h: Headers) -> string
headersafe?
def _header_safe?(s: string) -> bool
A header name or value must not contain a bare CR or LF: a value like "x\r\nX-Injected: 1" would otherwise inject an extra header line (request/response splitting).
headersclean?
def _headers_clean?(h: Headers) -> bool
_without
def _without(h: Headers, name: string) -> Headers
h without name. Inside the module, since Headers is opaque and the one caller is the renderer dropping a field it is about to contradict.
requestheaders
def _request_headers(h: Headers, body: bytes) -> Headers
The request framing headers a renderer owns. Content-Length is computed from the body, and any Transfer-Encoding the caller left on the value goes: rendering both emits the message _body_framing refuses to read, and the body in hand is already the payload and no coded form of it.
responseheaders
def _response_headers(h: Headers, body: bytes, bodiless: bool) -> Headers
The response twin preserves the server's one-request-per-connection contract. Splitting it from requests prevents keep-alive client work from changing serve! as a side effect.
bodiless drops the Content-Length computed from body: the status settles that the message has no body, and a field declaring one would frame a following message out of a body a peer never reads.
defaultuser_agent
def _default_user_agent() -> string
What a request without its own User-Agent sends.
Not cosmetic. A request with no User-Agent is a common bot signal, and what a CDN or WAF answers is a status the caller then misreads: the same request answered 200 with the header and 402 without it, which cost an hour reading as "this host wants payment" (hanki-ahuwk).
hanki/<version> is the conventional shape and what an operator grepping their logs would expect, with no tier and no platform after it: every extra field is fingerprint the caller did not ask to emit, and Headers.set lets one who wants it say so.
methoddefines_content?
def _method_defines_content?(method: Method) -> bool
Whether method's semantics define content, which frames an empty body with Content-Length: 0 even so.
RFC 9110 §8.6: a user agent SHOULD NOT send Content-Length on a request with no content whose method does not anticipate any. An unknown method counts as anticipating it: what it defines is unknowable here, and framing it explicitly is the answer that cannot be misread.
withdefault_agent
def _with_default_agent(h: Headers) -> Headers
h carrying the default User-Agent unless the caller set one.
render_request
def render_request(method: Method, u: Url, headers: Headers, body: bytes) -> Result<bytes, HttpError>
Serialise a request to bytes, filling in Host, User-Agent, where the method's semantics define content, Content-Length, over the caller's headers and dropping any Transfer-Encoding there. A caller's Connection field survives; when absent, HTTP/1.1's persistent default applies. A header name or value carrying a bare CR/LF is rejected as BadHeader (injection guard).
out = match parse_url("http://h/x")
Ok(u) -> render_request(Get, u, Headers.empty(), "".to_bytes()).and_then(|b| parse_request(b)).map(|q| q.target).unwrap_or("?")
Err(_) -> "?"
end
out => "/x"
render_response
def render_response(resp: Response) -> Result<bytes, HttpError>
Serialise a response to bytes, filling in Content-Length and Connection: close over the caller's headers and dropping any Transfer-Encoding there. A status the reader frames as bodiless renders with an empty body and no Content-Length, whatever resp.body or the caller's headers say: the two faces then agree on where the message ends. A header name or value carrying a bare CR/LF is rejected as BadHeader (injection guard).
resp = Response(status=204i32, headers=Headers.empty(), body="".to_bytes())
render_response(resp).and_then(|b| parse_response(b)).map(|r| r.status).unwrap_or(0i32) => 204i32
authorityof
def _authority_of(text: string) -> string
The authority of text as written: what follows // up to the first /, ? or #, or the end.
parse_url
def parse_url(text: string) -> Result<Url, HttpError>
The pieces of an http://host[:port][/path][?query] or https://... URL the client needs: url.parse takes the text apart, and this retains what the transport can open. A missing port defaults to the scheme's (80 or 443) and a missing path to /; a query remains on the path, that being the request target; a fragment is the client's and is not sent. Everything else is BadUrl: any other scheme (the spelling must be http or https), userinfo (user@host) and bracketed IPv6 literals ([::1]), which v0 does not connect to and refuses here in place of at connect!, an empty host, an empty port after : (which RFC 3986 treats as no port; this module treats it as a mistake), and a byte no URL may bear unencoded, a control character would let a crafted URL inject extra request lines, and a space would split the request line.
parse_url("http://a.com/x").map(|u| u.host).unwrap_or("?") => "a.com"
parse_url("http://a.com/x").map(|u| u.path).unwrap_or("?") => "/x"
parse_url("http://a.com").map(|u| u.path).unwrap_or("?") => "/"
parse_url("http://a.com?k=v").map(|u| u.path).unwrap_or("?") => "/?k=v"
parse_url("http://a.com/x#top").map(|u| u.path).unwrap_or("?") => "/x"
parse_url("https://a.com").map(|u| u.host).unwrap_or("!") => "a.com"
parse_url("https://a.com").map(|u| u.port).unwrap_or(0u16) => 443u16
parse_url("https://a.com").map(|u| u.tls).unwrap_or(false) => true
parse_url("http://a.com").map(|u| u.tls).unwrap_or(true) => false
parse_url("ftp://a.com").map(|u| u.host).unwrap_or("!") => "!"
parse_url("http://a.com:/x").map(|u| u.host).unwrap_or("!") => "!"
parse_url("http://a.com:0080/x").map(|u| u.port).unwrap_or(0u16) => 80u16
readhead!
def _read_head!<E: Into<HttpError>>(read_fn: (u32) -> Result<bytes, E> [r], max_head: int, seed: bytes) -> Result<_Head, HttpError> [r]
Read off the stream until the blank-line terminator, splitting the head from any body bytes that arrived with it.
readbody!
def _read_body!<E: Into<HttpError>>(read_fn: (u32) -> Result<bytes, E> [r], already: bytes, content_length: int, max_body: int) -> Result<bytes, HttpError> [r]
Read until already plus further reads reach content_length bytes.
readbody_eof!
def _read_body_eof!<E: Into<HttpError>>(read_fn: (u32) -> Result<bytes, E> [r], already: bytes, max_body: int) -> Result<bytes, HttpError> [r]
Read until the peer closes the connection.
fillline!
def _fill_line!<E: Into<HttpError>>(read_fn: (u32) -> Result<bytes, E> [r], pending: bytes, max_line: int) -> Result<bytes, HttpError> [r]
Read until pending has a whole CRLF-terminated line, which lets a chunk-size or trailer line be read off it. Bounded, since a peer that never sends the CRLF would otherwise be free to make the reader buffer without limit.
_fill!
def _fill!<E: Into<HttpError>>(read_fn: (u32) -> Result<bytes, E> [r], pending: bytes, n: int) -> Result<bytes, HttpError> [r]
Read until pending has at least n bytes. Only ever asked for the two that close a chunk, which bounds the buffer it grows.
readchunked_body!
def _read_chunked_body!<E: Into<HttpError>>(read_fn: (u32) -> Result<bytes, E> [r], already: bytes, max_body: int) -> Result<bytes, HttpError> [r]
Read and decode a chunked body off the stream.
The decoded bytes go straight into the builder while only the unconsumed raw tail is carried along, and pending never grows past one line plus whatever overran the last read. Buffering the whole coded body instead, and handing it to _decode_chunked, would be shorter, and would rebuild that buffer on every 4 KiB read: the O(n²) shape _read_body! was already moved off.
readtrailer!
def _read_trailer!<E: Into<HttpError>>(read_fn: (u32) -> Result<bytes, E> [r], pending: bytes) -> Result<(), HttpError> [r]
Consume the trailer section after the zero-sized chunk: field lines up to a blank one, all discarded. Bounded by the header-line budget, which leaves a peer cannot stream fields forever in place of ending the message.
_Framed
struct _Framed
headers: Headers
framing: _Framing
end
The parsed head of a message: its header fields and how they frame the body.
headfields
def _head_fields(head: bytes) -> Result<Headers, HttpError>
The header fields of a head buffer, bounded by the header budget.
headframing
def _head_framing(head: bytes) -> Result<_Framed, HttpError>
The head of a request and the framing its header fields give its body. No request is bodiless by the status-and-method rule.
headrequest?
def _head_request?(method: Method) -> bool
Whether method is HEAD, whose responses have no body.
readrequest_with!
def _read_request_with!<E: Into<HttpError>>(read_fn: (u32) -> Result<bytes, E> [r]) -> Result<Request, HttpError> [r]
Read one request from a connected stream.
The message is built here and never re-parsed out of a rejoined buffer: a decoded chunked body no longer matches the framing its own head declares, and handing the two back to parse_request would ask it to decode what is already decoded. @no-doctest: reads a connected stream; parse_request is the same message from a buffer, and it has the example
read_request!
def read_request!<S: ReadStream>(s: S) -> Result<Request, HttpError> [net]
Read and frame one request from any readable connected transport. @no-doctest: reads a connected stream; parse_request covers the same message from an in-memory buffer
readresponse_with!
def _read_response_with!<E: Into<HttpError>>(read_fn: (u32) -> Result<bytes, E> [r], method: Method) -> Result<Response, HttpError> [r]
Read one response from a connected stream. @no-doctest: reads a connected stream; see parse_response for the same message from a buffer
readresponse_from!
def _read_response_from!<E: Into<HttpError>>(read_fn: (u32) -> Result<bytes, E> [r], method: Method, pending: bytes, discarded: int) -> Result<Response, HttpError> [r]
One response head, discarding an interim 1xx and reading the one after it. RFC 9110 15.2 requires a client to read past an interim response it did not expect; 101 is not interim, being the protocol switch the exchange ends on. pending is whatever arrived past the previous head and belongs to this one, and discarded bounds a peer that sends interim responses forever.
read_response!
def read_response!<S: ReadStream>(s: S, method: Method) -> Result<Response, HttpError> [net]
Read and frame one response from any readable connected transport. method is the method of the request this response answers, which the framing needs: a response to HEAD has no body whatever its header fields say. @no-doctest: reads a connected stream; parse_response covers the same message from an in-memory buffer
write_response!
def write_response!<S: WriteStream>(s: S, resp: Response) -> Result<(), HttpError> [net]
Write a response to a connected stream. @no-doctest: writes to a connected stream; render_response produces the same bytes, and it has the example
writeall_stream!
def _write_all_stream!<S: Stream[e], S.Error: Into<HttpError>>(s: S, data: bytes) -> Result<(), HttpError> [e]
Internal duplex adapter. Stream is a separate contract; Hanki has no formal supertrait composition for the directional capabilities. Duplex code that also needs close! uses the Stream dictionary directly.
_exchange!
def _exchange!<S: Stream[e], S.Error: Into<HttpError>>(sock: S, wire: bytes, method: Method) -> Result<Response, HttpError> [e]
Write the request and read the response back over an already-connected transport. Generic over S: Stream[e], which is the whole reason that trait exists: the framing is written once, each transport preserves its error, and the caller pays its implementation's row.
impl<S: Stream[e], S.Error: Into<HttpError>> Client<S>
from_stream
def from_stream(transport: S) -> Client<S>
Take ownership of an already-connected stream. The caller opens the transport because plain TCP and TLS have different concrete and error types; this wrapper does not combine them into one connect result. @no-doctest: owns a live network stream
request!
def request!(self, method: Method, u: Url, headers: Headers, body: bytes) -> Result<Response, HttpError> [e]
Send one request over the held connection and leave it open for the next call. The caller must pass a URL for the peer this stream reached; its authority becomes Host, while only the path goes on the request line.
A server may close a persistent connection at any time. The next exchange returns a typed HttpError, Net for an OS refusal or Incomplete when a clean EOF arrives before a response head. This client never reconnects or retries, since replaying a request the server may already have applied is unsafe. @no-doctest: exchanges a request over a live network stream
close!
def close!(self) -> () [e]
Release the held transport. Idempotence follows the Stream contract. @no-doctest: closes a live network stream
impl<S: DeadlineStream> Client<S>
set_deadline!
def set_deadline!(self, timeout_ms: i32) -> () [net]
Replace the transport's one absolute deadline, beginning now. The next request's write and response read share it: progress does not refresh it. Negative values mean an immediate poll. An operation that is already ready can still complete without waiting. @no-doctest: mutates a live network transport
request!
def request!(method: Method, url: string, headers: Headers, body: bytes) -> Result<Response, HttpError> [net]
Send method url with the given headers and body, returning the response. The request is rendered (and its headers validated) before the socket is opened, and a bad URL or header therefore fails without a wasted connection. An https:// URL connects over TLS; the framing after that is identical. This one-shot face asks the peer to close after the response, which also frames a response with no length. Client.request! is the persistent face. @no-doctest: performs a real request; a doctest cannot reach the network, and one that could would be asserting on someone else's server
get!
def get!(url: string) -> Result<Response, HttpError> [net]
Send a GET to url. @no-doctest: performs a network request; needs a live server, cannot assert in a doctest
serveone!
def _serve_one!<S: Stream[r], S.Error: Into<HttpError>>(sock: S, handle: (Request) -> Response [e]) -> () [e, r]
Serve handle over one connection: read the request, answer with the handler's response (or a 400 if the request didn't parse), then close.
TcpListener
TcpListener, or net.TcpListener, is a runtime-managed native resource handle (HANKI.md §4): live native state the per-actor memory manager owns, released when the last handle drops. It is single-owner - never copied, moved across a send - and has no fields of its own, so its methods are its whole surface. A handle is minted by an API that opens one; it is never constructed.
acceptand_serve!
def _accept_and_serve!(l: TcpListener, handle: (Request) -> Response [e]) -> Result<(), HttpError> [net, e]
Accept one connection and serve it, reporting whether to keep going: Ok(()) after a handled connection, Err when the accept itself failed.
serveloop!
def _serve_loop!(l: TcpListener, handle: (Request) -> Response [e]) -> Result<(), HttpError> [net, e]
Accept and serve until an accept fails, returning that failure.
_loopback?
def _loopback?(host: string) -> bool
Is host an address only this machine can reach? 127.0.0.0/8 and ::1, including the IPv4-mapped ::ffff:127.0.0.1 spelling. A wildcard bind (0.0.0.0, ::) is not one: it accepts on every interface.
serve!
def serve!(l: TcpListener, handle: (Request) -> Response [e]) -> Result<(), HttpError> [net, e]
Accept connections on l and run handle for each, one at a time. Returns the accept failure that ended the loop. For concurrency, spawn a worker actor per connection and move the accepted socket into it (HANKI.md §15), framing with the functions above.
Refuses a listener bound anywhere but loopback, with ExposedBind, because this module speaks cleartext: a credential sent to a port the network can reach travels in the open, and a service author who has not thought about that has usually not meant to. Bind 127.0.0.1 and put a TLS-terminating reverse proxy in front. When the exposure is the intended deployment, serve_exposed! is the same loop without the check. @no-doctest: accepts connections until the listener closes; there is no value to assert
serve_exposed!
def serve_exposed!(l: TcpListener, handle: (Request) -> Response [e]) -> Result<(), HttpError> [net, e]
serve! on a listener the network can reach, cleartext and all: a process behind a TLS-terminating proxy, or one bound to 0.0.0.0 inside a container, which is what serve! refuses. Everything the module header says about what is readable on that wire still applies. The name is the acknowledgement. @no-doctest: the same accept loop as serve!, and the same reason
TlsListener
TlsListener, or tls.TlsListener, is a runtime-managed native resource handle (HANKI.md §4): live native state the per-actor memory manager owns, released when the last handle drops. It is single-owner - never copied, moved across a send - and has no fields of its own, so its methods are its whole surface. A handle is minted by an API that opens one; it is never constructed.
acceptandservetls!
def _accept_and_serve_tls!(l: TlsListener, handle: (Request) -> Response [e]) -> Result<(), HttpError> [net, e]
Accept one TLS connection and serve it. The TcpListener twin above, on the encrypted transport: _serve_one! is generic over S: Stream[r], and its associated error converts to HttpError; TlsStream implements that contract, which leaves the framing unrestated here.
Ok(()) continues the loop, Err ends it, and the seam decides which: a rejected peer is Ok(sys.Rejected(_)) and an Err from accept! is the listener itself. Reading the failure's variant instead would end the server on the first client that resets mid-handshake, since that and a dead listening socket are both TlsTransport.
serve_tls!
def serve_tls!(l: TlsListener, handle: (Request) -> Response [e]) -> Result<(), HttpError> [net, e]
Accept TLS connections on l and run handle for each, one at a time. Returns the transport failure that ended the loop.
There is no ExposedBind refusal here, by design: serve! refuses a non-loopback bind because it speaks cleartext, and a credential on a reachable port would travel in the open. Over TLS that reasoning does not apply, a reachable bind being the intended deployment, and the check is absent here in place of serve! losing it. @no-doctest: accepts connections until the listener closes; there is no value to assert
framinglabel
def _framing_label(headers: Headers, status: i32, head_request: bool) -> string
How _response_framing answered, as a comparable label.