HTTP server and typed routes

aether.http.server turns inbound HTTP into actor mail. It is the mirror of HTTP egress: the native capability owns sockets and protocol policy, while guest actors own application routing and responses.

Request path

TCP accept / HTTP reader
  → route lookup
  → one dispatch shard owns connection + in-flight state
  → typed or raw handler mailbox
  → reply opens buffered response, stream, or websocket
  → reader sidecar writes buffered bytes; stream writer handles chunks/websocket

Accept, socket reads, normal buffered-response writes, and streamed body writes perform their blocking I/O on sidecars. Dispatch shards keep mutable connection/stream state in actors and return events to the capability. There are current dispatcher-thread exceptions: streamed-response and websocket response heads, plus some canned status responses, call blocking write_all/flush through the shard's socket half. Those writes are small but can still stall under peer backpressure, so do not treat all server writes as off-dispatch. Sharding changes throughput topology, not the public kinds.

Buffered contract

The baseline handler receives aether.http.server.request, containing the method, path, query, headers, body, and peer address. It has no request-id field; dispatch identity and reply correlation ride the mail envelope. The handler replies with aether.http.server.router_result (HttpRouterResult), whose Response variant carries an aether.http.server.response: status, headers, and a complete body.

The configured max_request_bytes bounds a buffered request body, and max_header_bytes bounds its request line plus headers. There is currently no HTTP-server-specific size cap for a buffered response body: the handler returns a complete Vec<u8>, and the renderer allocates and writes it in full (subject only to broader mail/guest-memory constraints). Use buffered responses for small JSON, HTML, and assets, and use response streaming when response memory needs an explicit bounded window. Large or unbounded uploads require the structural streaming opt-in described below; max_request_bytes applies to the buffered path, not that credit-paced path.

Route registration

Handlers declare interest by mail during wiring and unregister during teardown. A route key includes the method and path pattern. Registration results surface invalid patterns and ownership conflicts explicitly.

Every route holder covers one protocol, HttpRouter: one single row taking aether.http.server.request and replying HttpRouterResult, whose three variants name a buffered response, a stream open, and a websocket accept. The holder's request handler returns it, or returns Pending<HttpRouterResult> and answers later through a held reply (ADR-0243); an unchecked handler does not cover the row. A router that closes while it holds a reply answers 502. An actor registers itself with register_route_self, and the server casts the sender to HttpRouter, refusing one that does not cover it. Another actor is registered by its canonical path, a ProtocolPath<HttpRouter>. Neither form names a kind: every route dispatches as aether.http.server.request.

Two target modes exist:

  • exclusive (default): one live claimant owns the route;
  • shared: multiple instanced handlers opt into one member set and the server spreads requests across live members.

Shared and exclusive claimants cannot silently mix for the same key. Dead members are removed; lifecycle cleanup is part of route ownership, not a caller convention.

#[http::router] derives registration from typed #[route] methods and emits the router's one single aether.http.server.request handler, replying HttpRouterResult; every typed route returns HttpServerResponse. A handler that forwards to a peer is a hand-written HttpServerRequest handler that holds its reply (ADR-0243): it returns Pending<HttpRouterResult>, parks the Held in the forwarded request's context with send_with_context, and answers it from the peer's reply handler after take_context. A held reply whose peer never replies keeps its chain open until the server's 504. The actor must declare depends(HttpServerCapability), because the injected wire registration mails the server; without it, the actor fails to compile at #[http::router]. #[http::router(shared)] opts every generated claim into the shared set. Use shared routes with replicated instances only when handler state and external effects tolerate per-request distribution.

Typed authoring

The typed layer parses a raw request into route parameters, query/body/header extractors, and a handler-specific context. A route method returns a type that can be rendered into the server reply contract. The generated handler first picks the route group whose claim the server picked, by the same rule (route_rank: the longest matching prefix, method-specific over method-agnostic), then tries only that group's templates, so a request the server sent to /a/b is never answered by a looser /a template. A router is one handler, so its describe_component entry and actor_cost row are per router, not per route.

Treat the macros as the public authoring surface and the raw kinds as the portable protocol. When debugging expansion or adding an extractor, read both aether-http's typed.rs and aether-http-derive; behavior may be generated rather than visible in the handler source.

The worked examples are split across Serving HTTP from a component. Verify every operational tool argument against the live MCP schema before copying an old recipe.

Response streaming

A handler opts into streaming by replying HttpRouterResult::Stream, carrying an aether.http.server.response_stream_open. That initial correlated reply declares status and headers. The capability grants a bounded number of chunks through stream_credit; the handler sends at most that many response_chunk messages before waiting for more credit, then sends response_stream_end.

The credit grants go through the route holder's StreamCreditRouter protocol: a handler covers it with a silent handler taking stream_credit. The server casts every route holder to it once, when the route is registered. A holder that replies Stream without covering it gets a 502 instead of a chunked head, because nothing it could take would ever be granted.

ResponseStream captures both the dispatching counterparty and stream_id from the credit message. Later handler methods use that durable handle. They do not assume the singleton server mailbox is the correct return address—sharded dispatch means the actual counterparty matters.

Each chunk is a detached short chain. Settlement of the opening request does not remain held for the full lifetime of a download.

Request streaming

Request streaming is a structural handler opt-in, not the default for every upload. The capability streams an upload to a route holder that covers the RequestStreamRouter protocol: silent handlers for aether.http.server.request_stream_open and request_chunk, and a request_stream_end handler that answers with the buffered HttpServerResponse. The server casts every route holder to it once, when the route is registered, and the reader reads that cast for each request, so no request reads the registry to decide. A holder that does not cover every row receives a buffered body. A streaming handler is still a route holder, so it covers HttpRouter too; a websocket upgrade reaches it as an ordinary request. For an opted-in handler, the capability sends request_stream_open, then chunks only as the handler grants request_credit. The handler learns the stream id at open; request_stream_end marks the last body bytes and retains the reply correlation needed for the response.

Backpressure is bidirectional and explicit. Sending over credit is a protocol violation that can tear down the stream; ignoring credit can park the producer.

Websocket upgrade

An ordinary HTTP upgrade request reaches the selected route. The handler may reply HttpRouterResult::WebSocket, carrying a websocket.accept, or decline with HttpRouterResult::Response; after an accept, complete de-fragmented messages and close events use an explicit stream_id in both directions.

The server delivers those messages and the peer's close through the holder's WebSocketRouter protocol, and paces its outbound messages with stream_credit through StreamCreditRouter, both cast when the route is registered. A holder that accepts without covering both gets a 502 in place of the 101.

WebSocketStream is reply-derived like response streams: it remembers the actual dispatch counterparty. An unknown or already-torn-down id is not rerouted to another connection.

Trust and limits

The native capability is the protocol firewall. Current bounds and remaining responsibilities include:

  • request header and buffered-body sizes, plus websocket frame/message sizes;
  • buffered and streaming in-flight counts;
  • route pattern complexity;
  • response credit and writer queues;
  • an explicit buffered-response body cap if a deployment needs one—the server does not currently provide that configuration;
  • idle/read/write lifetimes;
  • malformed requests and websocket frames.

Guest handlers are trusted to obey the mail-level credit protocol but do not receive raw socket ownership. A route handler should still validate application authorization, content type, and input shape; native parsing is not application permission.

Failure model

SymptomFirst evidence
Port does not bindresolved server config and aether.http.server logs
404/route missregistration result, handler wiring, method/path pattern
Registration conflictexclusive/shared claimant set and live instances
Handler runs but peer hangsreply class, correlation, stream-open/end path
Stream stallscredit ownership and actor logs/cost on handler/shard
Websocket closesupgrade response, frame limits, explicit stream id

Do not debug inbound failures through the egress aether.http.fetch actor; the two capabilities share shapes but not runtime ownership.

Change route and decisions

  • Public kinds: crates/aether-http/src/kinds.rs
  • Typed authoring and handles: crates/aether-http/src/{typed,stream}.rs
  • Runtime: crates/aether-http/src/server/
  • Derives: crates/aether-http-derive/src/lib.rs
  • Integration boundary: crates/aether-http/tests/http_serving.rs
  • ADR-0108, ADR-0128–ADR-0136 (all accepted; read amendments in order)