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
end

This 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) -> Any

The 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!")
end
PureHTTP2.serve_with_handler!Function
serve_with_handler!(handler, conn::HTTP2Connection, io::IO; max_frame_size::Int = DEFAULT_MAX_FRAME_SIZE) -> Nothing

Drive 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))
end

Lifecycle

  1. Read and validate the 24-byte client preface. Throws ConnectionError(PROTOCOL_ERROR) on invalid or truncated preface.
  2. Write the server preface (SETTINGS frame) to io.
  3. Frame loop: read a frame header + payload, enforce max_frame_size (throws ConnectionError(FRAME_SIZE_ERROR) on violation), call process_frame, write any response frames back to io.
  4. After each process_frame call, scan conn.streams for streams whose headers_complete && end_stream_received is true and that have not yet been dispatched. For each such stream, construct a Request and a Response, invoke handler(req, res) inside a try block, and emit the finalized response frames (normal return) or a RSTSTREAM with `INTERNALERROR` (on handler throw).
  5. Exit cleanly on transport EOF (read returns fewer bytes than requested) or when the connection enters the CLOSED state (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 Response as 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
end

See also: Request, Response, serve_connection! (the low-level counterpart).

source

Request

PureHTTP2.RequestType
Request

Read-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.

source
PureHTTP2.request_methodFunction
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).

source
PureHTTP2.request_pathFunction
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).

source
PureHTTP2.request_authorityFunction
request_authority(req::Request) -> Union{String, Nothing}

Return the :authority pseudo-header of the request, or nothing if the request did not carry one.

source
PureHTTP2.request_headersFunction
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.

source
PureHTTP2.request_headerFunction
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.

source
PureHTTP2.request_bodyFunction
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.

source
PureHTTP2.request_trailersFunction
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.

source

Response

PureHTTP2.ResponseType
Response

Write-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, default 200. Written by set_status!. Serialized as the :status pseudo-header during finalization.
  • headers::Vector{Tuple{String, String}} — Application response headers (excluding :status). Written by set_header!. Multiple calls with the same name append rather than replace.
  • body::Vector{UInt8} — Accumulated response body bytes. Written by write_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.

source
PureHTTP2.set_status!Function
set_status!(res::Response, code::Integer) -> Response

Set 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.

source
PureHTTP2.set_header!Function
set_header!(res::Response, name::AbstractString, value::AbstractString) -> Response

Append 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.

source
PureHTTP2.write_body!Function
write_body!(res::Response, bytes::AbstractVector{UInt8}) -> Response
write_body!(res::Response, str::AbstractString) -> Response

Append 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.

source

Error handling

When the handler throws an exception, serve_with_handler! catches it — the exception is never rethrown to the caller. The server then:

  1. Logs @warn "handler threw" stream_id=… exception=(err, bt) so you retain the full backtrace.
  2. Emits a RST_STREAM frame with error code INTERNAL_ERROR on the affected stream.
  3. Marks the associated Response as finalized so no further mutator calls take effect.
  4. 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")
end

The 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

  • Request is immutable, but the backing HTTP2Stream is not thread-safe. Concurrent access to the same Request from multiple tasks is UNSAFE in v0.4.0.
  • Response is mutable. Concurrent mutations from multiple tasks are UNSAFE in v0.4.0.
  • Both req and res are 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
end

Run the server and hit it with curl in streaming mode:

curl -N --http2-prior-knowledge http://127.0.0.1:8787/ticks

You 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.flushMethod
Base.flush(res::Response) -> Response

Emit 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
end

Non-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! + flush works 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): throws ArgumentError. This only happens if the Response was constructed outside serve_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.

source

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.headersbefore 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)
end

write_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 read n bytes from the request body incrementally, complementing the buffered request_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 & transportserve_connection! (the low-level entry point) and the optional TLS/ALPN backends.
  • Clientopen_connection! (the client-role counterpart of serve_with_handler!).