Server handler
PureHTTP2.jl's high-level server-side entry point. Dispatches an application-level request-handler callback once per completed HTTP/2 request stream so application code never has to touch the frame layer.
This is the server-side companion to the client page. The TLS & transport page documents the low-level serve_connection! entry point that drives the protocol plumbing without a handler hook — use that when you want to inspect individual frames or implement a custom dispatch strategy.
Quick example
The entire server-side logic of an h2c echo server fits in one short function:
using PureHTTP2
using Sockets
function echo_handler(req::Request, res::Response)
body = request_body(req)
ct = something(request_header(req, "content-type"),
"application/octet-stream")
set_status!(res, 200)
set_header!(res, "content-type", ct)
set_header!(res, "content-length", string(length(body)))
set_header!(res, "server", "PureHTTP2.jl-echo-example")
write_body!(res, body)
end
function main(; host = IPv4("127.0.0.1"), port::Int = 8787)
server = listen(host, port)
try
while isopen(server)
sock = accept(server)
@async try
serve_with_handler!(echo_handler, HTTP2Connection(), sock)
finally
close(sock)
end
end
finally
close(server)
end
endThis example is maintained verbatim in examples/echo-handler/server.jl. The examples/echo/ sibling example shows the same demo implemented by driving the frame loop manually — a useful reference if you want to understand exactly what serve_with_handler! abstracts away.
Handler signature
A PureHTTP2.jl handler is any Julia callable accepting two positional arguments:
handler(req::Request, res::Response) -> AnyThe return value is ignored — the server auto-finalizes the response stream when the handler function returns. Handlers should not return a meaningful value; any side effects on res (status, headers, body bytes) become the outgoing response.
Because the handler is the first positional argument to serve_with_handler!, Julia's do-block syntax works:
serve_with_handler!(HTTP2Connection(), sock) do req, res
set_status!(res, 200)
write_body!(res, "Hello from PureHTTP2.jl!")
endPureHTTP2.serve_with_handler! — Function
serve_with_handler!(handler, conn::HTTP2Connection, io::IO; max_frame_size::Int = DEFAULT_MAX_FRAME_SIZE) -> NothingDrive an HTTP2Connection over an arbitrary Base.IO transport AND dispatch handler once per completed request stream. This is PureHTTP2.jl's high-level server-side entry point — the recommended replacement for serve_connection! when writing application code that needs to respond to HTTP/2 requests.
The handler argument is a callable accepting two positional arguments (req::Request, res::Response). It is the first positional argument to serve_with_handler! so Julia's do-block syntax works:
serve_with_handler!(HTTP2Connection(), sock) do req, res
set_status!(res, 200)
write_body!(res, request_body(req))
endLifecycle
- Read and validate the 24-byte client preface. Throws
ConnectionError(PROTOCOL_ERROR)on invalid or truncated preface. - Write the server preface (SETTINGS frame) to
io. - Frame loop: read a frame header + payload, enforce
max_frame_size(throwsConnectionError(FRAME_SIZE_ERROR)on violation), callprocess_frame, write any response frames back toio. - After each
process_framecall, scanconn.streamsfor streams whoseheaders_complete && end_stream_receivedis true and that have not yet been dispatched. For each such stream, construct aRequestand aResponse, invokehandler(req, res)inside atryblock, and emit the finalized response frames (normal return) or a RSTSTREAM with `INTERNALERROR` (on handler throw). - Exit cleanly on transport EOF (read returns fewer bytes than requested) or when the connection enters the
CLOSEDstate (e.g., after a GOAWAY with a non-zero error code).
Error-path contract
When the handler throws an exception, serve_with_handler!:
- Catches the exception — it is never rethrown to the caller.
- Logs
@warn "handler threw" stream_id=... exception=(err, bt). - Emits
RST_STREAM(stream_id, INTERNAL_ERROR)on the affected stream. - Marks the associated
Responseas finalized. - Continues the frame loop — other streams on the same connection continue to be served.
This guarantee means the caller's listen loop (typically while isopen(server); sock = accept(server); @async serve_with_handler!(...); end) is not killed by application bugs.
Concurrency model
Handlers are invoked sequentially in stream-close order by the same task that drives the frame loop. There is no per-stream Task, no write lock on io, and no output queue. A handler that blocks on long-running IO stalls dispatch of other streams on the same connection — this is a deliberate trade-off for implementation simplicity at this milestone, documented in docs/src/handler.md. Per-stream concurrency is a future extension.
Auto-finalization
When the handler returns normally, the server emits the accumulated response frames (HEADERS + DATA(s) + END_STREAM) based on the handler's mutations to res. The handler does NOT need to explicitly signal end-of-stream — the server does it on return.
Forward-compatibility
This milestone ships a buffered-body API only. Incremental-read (Base.read(req, n)) and incremental-write (flush(res)) extensions are named as future additions in docs/src/handler.md under "Future: streaming". Neither exists yet — existing code calling request_body and write_body! will continue to work unchanged when they land.
Example
using PureHTTP2, Sockets
function echo_handler(req::Request, res::Response)
set_status!(res, 200)
set_header!(res, "content-type",
something(request_header(req, "content-type"),
"application/octet-stream"))
write_body!(res, request_body(req))
end
server = listen(IPv4("127.0.0.1"), 8787)
while isopen(server)
sock = accept(server)
@async try
serve_with_handler!(echo_handler, HTTP2Connection(), sock)
finally
close(sock)
end
endSee also: Request, Response, serve_connection! (the low-level counterpart).
Request
PureHTTP2.Request — Type
RequestRead-only view of an incoming HTTP/2 request passed to a handler function by serve_with_handler!. Handlers access the request via the exported accessor functions (request_method, request_path, request_authority, request_headers, request_header, request_body, request_trailers).
The struct's internal fields (conn, stream) are not part of the public API — they are implementation details subject to change. Handler code MUST use the accessor functions instead of direct field access.
A Request is valid only for the duration of the handler call that received it. Retaining references past the handler return is undefined behavior because the backing stream may be removed from the connection's internal state afterwards.
Forward-compatibility
This milestone (v0.4.0) ships a buffered-body request_body(req) accessor only. A future milestone may add Base.read(req::Request, n::Integer) -> Vector{UInt8} for incremental body reads before ENDSTREAM. Existing code calling `requestbodywill continue to work unchanged when that extension lands — the two modes will be documented as mutually exclusive perRequest` instance.
PureHTTP2.request_method — Function
request_method(req::Request) -> Union{String, Nothing}Return the :method pseudo-header of the request, or nothing if the request did not carry one (malformed).
PureHTTP2.request_path — Function
request_path(req::Request) -> Union{String, Nothing}Return the :path pseudo-header of the request, or nothing if the request did not carry one (malformed).
PureHTTP2.request_authority — Function
request_authority(req::Request) -> Union{String, Nothing}Return the :authority pseudo-header of the request, or nothing if the request did not carry one.
PureHTTP2.request_headers — Function
request_headers(req::Request) -> Vector{Tuple{String, String}}Return a fresh copy of the full request header list, including pseudo-headers (:method, :path, :scheme, :authority) in the order the client sent them. Handler mutation of the returned vector is safe and does not affect the connection's internal state.
PureHTTP2.request_header — Function
request_header(req::Request, name::AbstractString) -> Union{String, Nothing}Return the value of the first header named name on the request, or nothing if the header is not present. Name lookup is case-insensitive.
PureHTTP2.request_body — Function
request_body(req::Request) -> Vector{UInt8}Return the full request body as a byte vector. Returns UInt8[] if the request had no body. This accessor is buffered — it returns the complete body accumulated on the stream as of the moment the handler was invoked (i.e., after END_STREAM was received from the peer).
A future milestone may add an incremental-read companion Base.read(req::Request, n::Integer) for streaming handlers; see the Request docstring and docs/src/handler.md for the forward-compatibility contract.
PureHTTP2.request_trailers — Function
request_trailers(req::Request) -> Vector{Tuple{String, String}}Return a fresh copy of the trailing header list (RFC 9113 §8.1). Returns an empty vector if the client did not send trailers. Trailers and leading headers are kept in separate lists — use request_headers for leading headers.
Response
PureHTTP2.Response — Type
ResponseWrite-accumulator for the outgoing HTTP/2 response passed to a handler function by serve_with_handler!. Handlers build the response by calling the exported mutator functions (set_status!, set_header!, write_body!). The server finalizes the response (emits HEADERS + DATA frames + END_STREAM) when the handler returns.
Fields
status::Int— Response status code, default200. Written byset_status!. Serialized as the:statuspseudo-header during finalization.headers::Vector{Tuple{String, String}}— Application response headers (excluding:status). Written byset_header!. Multiple calls with the same name append rather than replace.body::Vector{UInt8}— Accumulated response body bytes. Written bywrite_body!. Emitted as one or more DATA frames during finalization.
Internal fields (conn, stream_id, finalized) are not part of the public API.
Auto-finalization
When the handler function returns normally, the server emits the accumulated response frames and sets finalized = true. After finalization, mutator calls are no-ops that log @warn. If the handler throws an exception, the server resets the affected stream with INTERNAL_ERROR instead — see serve_with_handler! for the full error-path contract.
Forward-compatibility
This milestone (v0.4.0) ships a buffered-write write_body!(res, bytes) accessor only. A future milestone may add flush(res::Response) for incremental response-body emission between write_body! calls. Existing buffered handlers will continue to work unchanged when that extension lands.
PureHTTP2.set_status! — Function
set_status!(res::Response, code::Integer) -> ResponseSet the response :status pseudo-header to code. No validation is performed — any integer is accepted. Returns res for chaining.
After the response HEADERS have been emitted on the wire (either by a prior flush(res) call or by the server's finalize path), this function becomes a no-op that logs @warn "Response headers already on the wire". Handlers that want to mutate the status must do so BEFORE the first flush.
PureHTTP2.set_header! — Function
set_header!(res::Response, name::AbstractString, value::AbstractString) -> ResponseAppend an application header to the response. Multiple calls with the same name append rather than replace — HTTP/2 allows repeated headers (e.g., Set-Cookie). Do NOT use this function for the :status pseudo-header; use set_status! instead. Returns res for chaining.
After the response HEADERS have been emitted on the wire (either by a prior flush(res) call or by the server's finalize path), this function becomes a no-op that logs @warn "Response headers already on the wire". Handlers that want to mutate response headers must do so BEFORE the first flush.
PureHTTP2.write_body! — Function
write_body!(res::Response, bytes::AbstractVector{UInt8}) -> Response
write_body!(res::Response, str::AbstractString) -> ResponseAppend bytes to the response body buffer. The accumulated body is emitted as one or more DATA frames during finalization. For the AbstractString overload, the string is converted via codeunits(String(str)).
Returns res for chaining.
Error handling
When the handler throws an exception, serve_with_handler! catches it — the exception is never rethrown to the caller. The server then:
- Logs
@warn "handler threw" stream_id=… exception=(err, bt)so you retain the full backtrace. - Emits a
RST_STREAMframe with error codeINTERNAL_ERRORon the affected stream. - Marks the associated
Responseas finalized so no further mutator calls take effect. - Continues the frame loop — other streams on the same connection keep being served normally.
This guarantee means your listen loop — typically
while isopen(server)
sock = accept(server)
@async try
serve_with_handler!(my_handler, HTTP2Connection(), sock)
finally
close(sock)
end
end— survives application bugs. One handler throwing does not kill the connection for other in-flight requests, and it does not kill the @async task for subsequent requests on the same connection.
If you want a different error-path response (for example, a 500 Internal Server Error HEADERS frame with a JSON body), catch the exception inside the handler and set the response manually:
function my_handler(req::Request, res::Response)
try
do_some_work(req)
catch err
set_status!(res, 500)
set_header!(res, "content-type", "application/json")
write_body!(res, "{\"error\":\"$(sprint(showerror, err))\"}")
return
end
set_status!(res, 200)
write_body!(res, "ok")
endThe server only resets the stream with INTERNAL_ERROR when the handler throws out of its body — catching internally lets you shape the wire response however you like.
Concurrency
Handlers are invoked sequentially in stream-close order by the same task that drives the frame read/write loop. There is no per-stream Task, no write lock on the transport, and no output queue. If multiple streams on one connection become complete within the same frame-processing batch (e.g., interleaved streams 1 and 3), they are dispatched in ascending stream-ID order.
Trade-off: blocking handlers stall the connection
A handler that blocks on long-running I/O — a synchronous database query, an HTTP.get, a sleep — stalls the entire connection's frame loop until it returns. Other streams on the same connection do not make progress until then.
This is a deliberate simplicity trade-off at v0.4.0. For typical HTTP/2 servers where each connection multiplexes a handful of requests from one client, the blocking is rarely observable. If you need concurrent per-stream dispatch, you can spawn your own Task from within the handler and return immediately — with the important caveat that you MUST finish writing the response before the handler returns, because the server finalizes the stream on return. Calling set_status!, set_header!, or write_body! on res from a Task that outlives the handler body is undefined behavior (the response is already on the wire).
A future milestone may add opt-in per-stream Task dispatch as a pure addition — existing handler code will continue to work unchanged.
Cross-connection concurrency
Multiple serve_with_handler! calls on different connections may run concurrently from different tasks — there is no global state, no shared lock. The standard pattern is one @async serve_with_handler!(...) per accepted connection in your listen loop.
Invariants on req and res sharing
Requestis immutable, but the backingHTTP2Streamis not thread-safe. Concurrent access to the sameRequestfrom multiple tasks is UNSAFE in v0.4.0.Responseis mutable. Concurrent mutations from multiple tasks are UNSAFE in v0.4.0.- Both
reqandresare valid only for the duration of the handler call that received them. Retaining references past the return has undefined behavior — the server may remove the backing stream from the connection's internal state immediately after finalization.
Streaming
Mid-handler response-body streaming is live as of v0.5.0 via the new Base.flush(::Response) primitive. Handlers call flush(res) to push currently-accumulated body bytes to the wire as HTTP/2 DATA frame(s) immediately, without waiting for the handler function to return. The accumulated buffer is cleared after each flush, and subsequent write_body! calls start filling a fresh buffer — a natural rhythm for streaming handlers is write_body! → flush → compute/sleep → write_body! → flush → ...
This unblocks use cases that cannot be expressed with the buffered-only handler shape:
- Server-Sent Events (SSE) feeds that push updates every second
- Long-running computations that emit progress before completion
- Chunked downloads where the server yields bytes incrementally
- gRPC server-streaming methods (once PureHTTP2.jl grows a gRPC adapter in a future milestone)
Quick streaming example
using PureHTTP2
using Sockets
function sse_tick_handler(req::Request, res::Response)
if request_path(req) != "/ticks"
set_status!(res, 404)
set_header!(res, "content-type", "text/plain; charset=utf-8")
write_body!(res, "Not Found\n")
return
end
set_status!(res, 200)
set_header!(res, "content-type", "text/event-stream")
set_header!(res, "cache-control", "no-cache")
set_header!(res, "server", "PureHTTP2.jl-sse-example")
for i in 1:5
write_body!(res, "data: tick $i\n\n")
flush(res) # push this event to the wire NOW
sleep(1.0)
end
endRun the server and hit it with curl in streaming mode:
curl -N --http2-prior-knowledge http://127.0.0.1:8787/ticksYou will see data: tick 1 through data: tick 5 arrive one per second, not all-at-once after 5 seconds. The -N flag disables curl's output buffering so each line prints as soon as it arrives. This example is maintained verbatim at examples/sse/server.jl.
Base.flush — Method
Base.flush(res::Response) -> ResponseEmit the currently-accumulated response body as HTTP/2 DATA frame(s) immediately, without waiting for the handler function to return. The first call to flush(res) on a response also emits the response HEADERS frame carrying the current res.status and res.headers list — this is the "lazy HEADERS" commit.
After a successful flush, res.body is empty and subsequent write_body! calls start filling a fresh buffer. Multiple flush/write cycles are the intended usage pattern for streaming handlers:
serve_with_handler!(HTTP2Connection(), sock) do req, res
set_status!(res, 200)
set_header!(res, "content-type", "text/event-stream")
for i in 1:5
write_body!(res, "data: tick $i\n\n")
flush(res) # push this event to the wire NOW
sleep(1.0)
end
endNon-terminal emission
flush(res) NEVER sets END_STREAM on its emitted frames. The END_STREAM marker is emitted exclusively by the server's finalize path when the handler function returns. This keeps the semantics simple: flush = "commit some bytes", handler return = "close the stream". A handler that calls flush and then returns emits a separate zero-length DATA frame with END_STREAM as the terminal marker — negligible (9 bytes) overhead, wire-legal per RFC 9113 §6.1.
Lazy HEADERS → mutator freeze
Once flush(res) has emitted HEADERS on the wire, set_status! and set_header! become no-ops that log a @warn "Response headers already on the wire". Handlers that need to mutate the response headers must do so BEFORE the first flush. write_body! remains functional — it appends to the now-emptied buffer for the next flush.
Error path under streaming
If the handler throws after having flushed one or more chunks, the wire sequence becomes HEADERS + DATA(chunk-1) + ... + RSTSTREAM(INTERNALERROR). Bytes already on the wire cannot be rolled back — this is inherent to streaming, not a bug. The connection itself survives (other streams continue to be served), matching the M8 error-path contract.
Edge cases
- Empty-body first flush: emits HEADERS only (no DATA frame). Subsequent
write_body!+flushworks normally. - Empty-body subsequent flush: total no-op — no HEADERS re-emission, no DATA frame, no
@warn. - Flush on a finalized response: no-op with
@warn "Response already finalized; flush is a no-op". - Flush on a response with no attached transport (
res.io === nothing): throwsArgumentError. This only happens if theResponsewas constructed outsideserve_with_handler!; normal handler code never reaches this branch.
Forward compatibility
This method has a single positional argument. Future releases may add an optional keyword argument (e.g., end_stream::Bool=false) as a pure addition without breaking any existing call site.
A companion read-side primitive Base.read(req::Request, n::Integer) for incremental request-body reads remains reserved as a forward-compat extension point — not shipped in v0.5.0. See docs/src/handler.md "Future: request-side streaming" for the status.
Returns res for chaining.
Lazy HEADERS emission
The first call to flush(res) on a given response emits the response HEADERS frame carrying the current res.status and res.headers — before the DATA frame for the flushed body. Subsequent flushes emit DATA frames only; HEADERS are not repeated. This is the only wire-legal ordering per RFC 9113 §8.1 (DATA must follow HEADERS on any stream).
Once HEADERS are on the wire, set_status! and set_header! become no-ops that log @warn "Response headers already on the wire; … is a no-op". Handlers that want to mutate status or headers must do so before the first flush:
function streaming_handler(req::Request, res::Response)
# ✓ OK: mutate status + headers before any flush
set_status!(res, 200)
set_header!(res, "content-type", "text/plain")
write_body!(res, "chunk-1")
flush(res) # HEADERS + DATA emitted here
# ✗ NO-OP: HEADERS already on the wire, this logs a @warn
set_header!(res, "x-late", "too late")
write_body!(res, "chunk-2")
flush(res) # DATA only (no HEADERS repeat)
endwrite_body! continues to work after a flush — it appends to the now-emptied buffer so the next flush emits the next DATA frame.
Error path under streaming
The Error handling contract is unchanged: if the handler throws, serve_with_handler! catches the exception, logs @warn "handler threw", and emits RST_STREAM(INTERNAL_ERROR) on the affected stream.
The one clarification for streaming: if the handler has already flushed one or more chunks before throwing, the wire sequence becomes:
HEADERS(:status=200) + DATA(chunk-1) + … + DATA(chunk-N) + RST_STREAM(INTERNAL_ERROR)This is valid HTTP/2. Clients observe a truncated response with an explicit abort signal. Bytes already on the wire cannot be rolled back — this is inherent to streaming, not a bug. The connection itself survives (other streams on the same connection continue to be served normally).
Future: request-side streaming
The write side of streaming ships in v0.5.0 via Base.flush(::Response). The read side — incremental request-body reads before END_STREAM — is still reserved as a forward-compat extension point for a follow-up milestone:
Base.read(req::Request, n::Integer) -> Vector{UInt8}will readnbytes from the request body incrementally, complementing the bufferedrequest_body(req)accessor that ships today.
When it lands, existing handlers that call request_body(req) will continue to work unchanged — the new method is a pure addition, not a replacement. Handlers that want incremental reads will opt in by calling Base.read instead.
See also
examples/echo-handler/— the worked example sourced for this page.examples/echo/— the low-level counterpart that drives the frame loop manually.- TLS & transport —
serve_connection!(the low-level entry point) and the optional TLS/ALPN backends. - Client —
open_connection!(the client-role counterpart ofserve_with_handler!).