HTTP/2 (RFC 9113) support built on nghttp2, running alongside the existing HTTP/1.x layer. Both protocols share the same HttpRouter, middleware chain, HttpRequest, and HttpResponse types — application code is unchanged.
#include "http/http_server.h"
// HTTP/2 is enabled by default — no extra setup needed.
// The same HttpServer handles both HTTP/1.x and HTTP/2.
HttpServer server("0.0.0.0", 8080);
server.Get("/hello", [](const HttpRequest& req, HttpResponse& res) {
res.Status(200).Json(R"({"protocol":"h2 or h1"})");
});
server.Start();Test with curl:
# Cleartext h2c (prior knowledge)
curl --http2-prior-knowledge http://localhost:8080/hello
# TLS h2 (requires TLS config)
curl --http2 https://localhost:8080/helloHTTP/2 connections are established via two mechanisms:
During the TLS handshake, the server advertises h2 and http/1.1 via ALPN (Application-Layer Protocol Negotiation). The client selects its preferred protocol. If h2 is selected, the connection enters HTTP/2 mode immediately after the handshake completes.
Client TLS Handshake Server
|-- ClientHello (ALPN: [h2, http/1.1]) -------->|
| Server selects "h2" |
|<---- ServerHello (ALPN: h2) ------------------|
| TLS complete |
|-- Client Preface (magic + SETTINGS) --------->|
|<---- Server Preface (SETTINGS) ---------------|
| HTTP/2 established |
For cleartext connections, the client sends the 24-byte HTTP/2 connection preface (PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n) as its first bytes. The server detects this preface and enters HTTP/2 mode.
Client TCP Connection Server
|-- "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n" ------->|
| + SETTINGS frame |
| Server detects preface → HTTP/2 mode |
|<---- Server Preface (SETTINGS) ---------------|
| HTTP/2 established |
If the first bytes don't match the HTTP/2 preface, the connection falls through to HTTP/1.x handling.
Note: The HTTP/1.1 Upgrade mechanism (Upgrade: h2c) is not supported — it is deprecated in RFC 9113.
| Component | Header | Role |
|---|---|---|
Http2Session |
include/http2/http2_session.h |
nghttp2 session wrapper (pimpl), stream management, flood protection |
Http2Stream |
include/http2/http2_stream.h |
Per-stream state: request accumulation, response lifecycle |
Http2ConnectionHandler |
include/http2/http2_connection_handler.h |
Per-connection HTTP/2 state machine, bridges reactor to nghttp2 |
ProtocolDetector |
include/http2/protocol_detector.h |
ALPN + preface-based protocol detection |
Http2Config |
include/config/server_config.h |
HTTP/2 tunables (max_concurrent_streams, window size, etc.) |
Layer 5: HttpServer (unchanged — handles both protocols)
Layer 4: HttpRouter (unchanged — receives HttpRequest/HttpResponse)
Layer 3: HttpConnectionHandler ←OR→ Http2ConnectionHandler
(HTTP/1.x path) (HTTP/2 path)
↓
Http2Session (nghttp2 wrapper)
├── Http2Stream[1]
├── Http2Stream[3]
└── Http2Stream[n]
ProtocolDetector (routes to the correct handler)
Layer 2: TlsContext + TlsConnection (ALPN negotiation)
Layer 1: ConnectionHandler, Channel, Dispatcher (reactor core)
Both protocol paths converge at HttpRouter — routes, middleware, and handlers work identically regardless of HTTP version.
Client sends → epoll_wait → ConnectionHandler::OnMessage (read until EAGAIN)
HTTP/2 request → [TLS: SSL_read, ALPN check]
→ HttpServer::HandleMessage
→ ProtocolDetector routes to Http2ConnectionHandler
→ Http2ConnectionHandler::OnRawData
→ nghttp2_session_mem_recv2(session, data, len)
→ on_begin_headers_callback → creates Http2Stream
→ on_header_callback → populates HttpRequest
→ on_data_chunk_recv_callback → appends body
→ on_frame_recv_callback (END_STREAM) →
dispatches through HttpRouter
→ handler(request, response)
→ nghttp2_submit_response2()
→ nghttp2_session_mem_send2(session) → response bytes
→ ConnectionHandler::SendRaw()
HTTP/2 multiplexes multiple requests over a single TCP connection using streams. Each stream has:
- A unique odd-numbered ID (assigned by the client)
- Independent state machine: IDLE → OPEN → HALF_CLOSED_REMOTE → CLOSED
- Its own flow control window
The server processes streams concurrently — nghttp2 handles frame interleaving automatically.
struct Http2Config {
bool enabled = true; // Enable HTTP/2 (h2 + h2c)
uint32_t max_concurrent_streams = 100; // Max simultaneous streams per connection
uint32_t initial_window_size = 65535; // Flow control window (64 KB - 1)
uint32_t max_frame_size = 16384; // Max frame payload (16 KB)
uint32_t max_header_list_size = 65536; // Max header block size (64 KB)
};{
"http2": {
"enabled": true,
"max_concurrent_streams": 100,
"initial_window_size": 65535,
"max_frame_size": 16384,
"max_header_list_size": 65536
}
}| Variable | Config Field |
|---|---|
REACTOR_HTTP2_ENABLED |
http2.enabled |
REACTOR_HTTP2_MAX_CONCURRENT_STREAMS |
http2.max_concurrent_streams |
REACTOR_HTTP2_INITIAL_WINDOW_SIZE |
http2.initial_window_size |
REACTOR_HTTP2_MAX_FRAME_SIZE |
http2.max_frame_size |
REACTOR_HTTP2_MAX_HEADER_LIST_SIZE |
http2.max_header_list_size |
RFC 9113 constraints enforced by ConfigLoader::Validate():
max_concurrent_streams>= 1initial_window_size: 1 to 2^31-1max_frame_size: 16384 to 16777215max_header_list_size>= 1
| Attack | Detection | Response |
|---|---|---|
| Rapid Reset (CVE-2023-44487) | RST_STREAM count > 100/10s | GOAWAY(ENHANCE_YOUR_CALM) |
| SETTINGS Flood | SETTINGS count > 100/10s | GOAWAY(ENHANCE_YOUR_CALM) |
| PING Flood | PING count > 50/10s | GOAWAY(ENHANCE_YOUR_CALM) |
| CONTINUATION Flood | Enforced via max_header_list_size | RST_STREAM (by nghttp2) |
Per RFC 9113 Section 8.2.2:
- Forbidden headers rejected:
connection,keep-alive,proxy-connection,transfer-encoding,upgrade - TE header: only
te: trailersallowed (OWS-trimmed, case-insensitive) - Required pseudo-headers (non-CONNECT):
:method,:path, and:schememust be present - CONNECT pseudo-headers:
:method+:authorityrequired;:pathand:schememust NOT be present (checked by presence, not value — an explicit empty:pathis rejected) :authorityvshost: case-insensitive hostname comparison (RFC 3986 Section 3.2.2), exact port match, IPv6 bracket-aware- Trailer validation: pseudo-headers forbidden;
content-length,host,authorization,content-type,content-encoding,content-range, and connection-specific headers rejected per RFC 9110 Section 6.5.1 - 1xx responses: all
status < 200rejected from app-facingSubmitResponse()with RST_STREAM(INTERNAL_ERROR); internal 100-continue usesnghttp2_submit_headersdirectly - Unsupported Expect: rejected with 417 response + RST_STREAM(NO_ERROR) when client side is still open (no END_STREAM on request); clean 417 without RST when request already ended
- Body size limits enforced per-stream via RST_STREAM(CANCEL)
For h2 over TLS:
- TLS 1.2 minimum (already enforced by existing TlsContext)
- ALPN negotiation required (no prior knowledge over TLS)
- AEAD cipher suites recommended (default OpenSSL config satisfies this)
HttpServer::Stop()
1. Existing HTTP/1.x + WS shutdown (WS Close 1001)
2. StopAccepting() — close listen socket, barrier for in-flight accepts
3. For each HTTP/2 connection:
→ Install DrainCompleteCallback (under drain_mtx_)
→ RequestShutdown() → enqueues dispatcher-thread task via RunOnDispatcher
→ On dispatcher: sends GOAWAY(NO_ERROR) via nghttp2
→ If deferred output (backpressure), ResumeOutput() before CloseAfterWrite
→ New streams refused (IsGoawaySent || owner shutdown), existing drain
→ NotifyDrainComplete() when ActiveStreamCount() == 0 AND
output buffer empty AND no deferred nghttp2 frames AND !WantWrite()
→ Re-check after RequestShutdown: if connection closed during setup,
OnH2DrainComplete removes stale entry from drain set
4. NetServer skips draining H2 connections in its CloseAfterWrite sweep
5. WaitForH2Drain() blocks until all drain or shutdown_drain_timeout_sec expires
6. Timeout: ForceClose remaining connections
7. Second drain barrier covers final H2 CloseAfterWrite tasks
The shutdown is fully graceful: GOAWAY carries last_stream_id so clients know which requests to retry, active streams drain with full flow control (WINDOW_UPDATE still processed), and the nghttp2 session is only touched on its dispatcher thread (no cross-thread mutation). A configurable shutdown_drain_timeout_sec (default 30s) bounds the wait.
Drain-complete is transport-level: NotifyDrainComplete() only fires from OnSendComplete() when the transport output buffer is empty (bytes on the wire), not just when nghttp2 has serialized the frames. If ResumeOutput() adds bytes but the buffer was already empty and no write event follows, OnSendComplete re-enters to check drain eligibility.
Shutdown vs peer half-close: IsCloseDeferred() (set on both server shutdown and peer EOF) is NOT used to reject new streams or skip H2 initialization. Only IsGoawaySent() and the owner's IsShutdownRequested() flag (with !IsInitializing() guard) reject new streams. This ensures requests arriving with a peer FIN in the same read batch are still serviced.
If Stop() is called from a dispatcher thread (e.g., a request handler calling HttpServer::Stop()), the H2 drain wait is skipped to avoid deadlock. A warning is logged. This matches the existing ThreadPool::Stop() self-stop safety pattern.
HTTP/2 pseudo-headers are mapped to HttpRequest fields:
| HTTP/2 Pseudo-Header | HttpRequest Field |
|---|---|
:method |
request.method |
:path |
request.url, split into request.path + request.query |
:authority |
request.headers["host"] |
:scheme |
Not stored (informational) |
For HTTP/2 requests, request.http_major = 2 and request.http_minor = 0.
Cookie headers arriving as separate HTTP/2 header fields are concatenated with "; " per RFC 9113 Section 8.2.3.
HTTP/2 request timeouts are enforced per-stream via request_timeout_sec:
- Each stream's creation time is tracked. The connection deadline is set to
oldest_incomplete_start + request_timeout_sec. - When the deadline fires, only the expired stream(s) are RST'd (
RST_STREAM(CANCEL)). Healthy streams on the same connection are unaffected. - The
DeadlineTimeoutCbreturnstrue(keep connection alive) after RST'ing expired streams. - Safety deadline for idle_timeout_sec=0: After resetting all streams, if no active streams remain and
idle_timeout_secis disabled, a safety deadline ofrequest_timeout_secis armed to prevent the connection from staying open forever. This only fires when the connection is truly idle (no active streams) — it never tears down healthy sibling streams. - Rejected streams (e.g. 417 half-open) are included in both deadline calculation and
ResetExpiredStreams, ensuring they don't escape timeout enforcement or consumemax_concurrent_streamsslots indefinitely. - Once all incomplete/rejected streams are resolved, the deadline is cleared and
idle_timeoutgoverns. - New streams cannot extend the deadline for older stalled streams (the deadline always reflects the oldest incomplete stream).
For TLS connections, the total timeout exposure is up to 2 x request_timeout_sec: one window for the TLS handshake + protocol detection, and a separate window for the first HTTP request. This is intentional — separating handshake and request timeouts is standard (cf. nginx ssl_handshake_timeout vs client_header_timeout). The handshake deadline is set in HandleNewConnection and reset by the protocol handler once it takes over.
SendPendingFrames() stops pulling frames from nghttp2 when the transport output buffer exceeds a high watermark (max(128KB, max_frame_size)). At least one frame is always pulled per call so control frames (SETTINGS ACK, GOAWAY) from the current ReceiveData call are delivered. Once output_deferred_ is set, subsequent calls return immediately until ResumeOutput() clears the flag. This bounds per-connection output buffering and prevents slow peers from causing unbounded memory growth.
Resume happens at two points:
OnSendComplete()(buffer drains to zero): schedules async resume viaRunOnDispatcher().OnWriteProgress()(partial write, buffer below watermark): resumes deferred output at the low watermark so multiplexed streams make progress without waiting for full drain. Uses awrite_progress_callbackfired fromConnectionHandler::CallWriteCb()after each successful partial write.
If the connection is closing (IsClosing()), SendPendingFrames breaks the loop early to avoid wasting CPU serializing frames for a disconnected peer.
- Server push disabled (SETTINGS_ENABLE_PUSH = 0)
- No WebSocket-over-HTTP/2 (Extended CONNECT, RFC 8441)
- No HTTP/2 priority tree optimization (nghttp2 handles basic priority)
- No manual flow control (nghttp2 automatic mode)
- No non-final 1xx API for app handlers (103 Early Hints requires internal submit_headers; SubmitResponse rejects all
status < 200) - Default-port authority normalization deferred (e.g.
example.comvsexample.com:80treated as different)
nghttp2 (v1.64.0) — HTTP/2 C library. Vendored at third_party/nghttp2/. Compiled as C99 objects, linked with C++ code. Hidden behind pimpl pattern in Http2Session — no nghttp2 types in public headers. MIT license.