hanki

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:

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.