Audience: Contributor · Status: stable · Verified-against: qbm-http @ qb 2.6.0 (C++20 default, C++23 supported)
How raw bytes off a qb-io socket become a qb::http::Request or qb::http::Response: the llhttp-backed qb::http::Parser, the qb::protocol::http framing layer that drives it, and the security limits that gate every message.
Prerequisites: Core concepts for Request/Response/Headers, and the message body deep dive for qb::http::Body over qb::allocator::pipe<char>. See also: Advanced topics for pipelining, WebSocket for the post-upgrade frame protocol, and the doc map README.md.
This is the HTTP/1.1 parsing internals. You almost never call any of it directly — the server router, the async client functions, and http1::Client all sit on top of it. Read this when you are writing a custom transport, debugging a framing edge case, or need to understand exactly which messages the parser rejects and why.
Two types do the work, both in the 1.1/protocol/ headers:
qb::http::Parser<MessageType>(1.1/protocol/base.h) — a thin C++ wrapper around the vendored llhttp C parser. It owns parser state and the in-progress message.qb::protocol::http::base<IO_Handler, Trait>(same file) — aqb::io::async::AProtocolthat frames a byte stream into discrete messages, withserver<IO_Handler>andclient<IO_Handler>specializations (1.1/protocol/server.h,1.1/protocol/client.h).
HTTP/2 and HTTP/3 do not use this parser. HTTP/2 framing lives under 2/protocol/ (HPACK + RFC 9113 frames, see HTTP/2 protocol specifics); HTTP/3 framing is delegated to nghttp3 under 3/protocol/ (see HTTP/3). This page is HTTP/1.1 only.
This module is a compiled library, not header-only — the parser templates are header-defined, but the framework links the vendored llhttp static library (qbm/http/not-qb/llhttp) into qbm::http. See Core concepts for the integration recipe.
Parser wraps llhttp, the parser that powers Node.js, vendored under qbm/http/not-qb/llhttp and built as a STATIC library target named llhttp with a PUBLIC include directory.
# qbm/http/CMakeLists.txt — llhttp is a build dependency of qbm-http,
# not a header you pull in yourself.
add_subdirectory(not-qb/llhttp)The vendored copy renames llhttp's public symbols from the upstream llhttp_* prefix to a http_* prefix: the parser handle is http_t, the settings struct is http_settings_s, error codes are http_errno_t, and the entry points are http_init / http_execute / http_resume. These come from <llhttp.h>, pulled in transitively via <http/http.h> and used directly by 1.1/protocol/base.h. The http_method and http_status enums that back qb::http::Method and qb::http::Status are the same llhttp enums (see Core concepts).
llhttp is event-driven: you feed it bytes with http_execute, and it invokes callbacks as it crosses each boundary of the message (message-begin, URL, status, header field, header value, headers-complete, body, message-complete). Parser registers a fixed static const http_settings_s table and translates those callbacks into mutations on the message it is building.
Parser is templated on the message type and lives in namespace qb::http:
#include <http/http.h>
template <typename MessageType> // qb::http::Request or qb::http::Response
struct qb::http::Parser : public http_t {
http_errno_t parse(const char *buffer, std::size_t size); // feed bytes
void reset() noexcept; // clear state for a new message
void resume() noexcept; // continue after the headers pause
[[nodiscard]] MessageType &get_parsed_message() noexcept; // the in-progress message
[[nodiscard]] bool headers_completed() const noexcept;
};MessageType selects the parsing mode through MessageType::type (HTTP_REQUEST or HTTP_RESPONSE), so the same code parses requests on the server and responses on the client. Parser derives from http_t itself, so the llhttp fields it sets — content_length, error_pos, status_code, http_major/http_minor — are members you read directly off the parser after parse() returns.
parse()is incremental. Call it repeatedly with successive byte ranges; the parser keeps state across calls. A return ofHPE_OKmeans "consumed everything, need more data."- The parser pauses at end-of-headers. The
on_headers_completecallback returnsHPE_PAUSEDby design, soparse()returnsHPE_PAUSEDandheaders_completed()flips totruethe moment the header block is complete. This lets the framing layer inspect the headers (content length, transfer encoding, upgrade) before committing to read the body. resume()clears the pause so the nextparse()continues into the body. Call it only afterheaders_completed()is true.reset()returns to a clean state for the next message. It re-initializes llhttp, placement-constructs a freshMessageType(it deliberately avoids assigning into a moved-from message —onMessage()moves the message out before resetting), and clears the body buffer and all bookkeeping.on_message_completereturns1, which surfaces asHPE_CB_MESSAGE_COMPLETE. The framing layer keys off that specific code on the body/resume path (see below).
flowchart TD
F["parse(bytes) — incremental feed"] --> CB["llhttp callbacks:<br/>message-begin · URL / status · header field / value"]
CB --> HC{"headers complete?"}
HC -- "no (HPE_OK, need more)" --> F
HC -- "yes → HPE_PAUSED" --> INSPECT["framing layer inspects headers<br/>content-length · transfer-encoding · upgrade"]
INSPECT --> RES["resume() → parse() continues into the body"]
RES --> MC{"message complete?"}
MC -- no --> RES
MC -- "yes (HPE_CB_MESSAGE_COMPLETE)" --> DONE["onMessage() moves the message out → reset()"]
DONE --> F
Here is the contract exercised directly, taken from the test suite:
#include <http/http.h>
using namespace qb::http;
Parser<Request> parser;
std::string raw = "GET / HTTP/1.1\r\nHost: example.com\r\n\r\n";
// Header-only request: the parser pauses at end-of-headers.
assert(parser.parse(raw.data(), raw.size()) == HPE_PAUSED);
assert(parser.headers_completed());
assert(parser.content_length == 0u); // normalized — see "Body framing" belowAnd fragmented input — the case the framing layer is built around — reassembles correctly:
Parser<Request> parser;
// A single header split across five parse() calls.
parser.parse("GET / HTTP/1.1\r\nX-Cus", 21); // HPE_OK
parser.parse("tom-Hea", 7); // HPE_OK
parser.parse("der: value-", 11); // HPE_OK
parser.parse("part-1", 6); // HPE_OK
parser.parse("part-2\r\n\r\n", 10); // HPE_PAUSED
assert(parser.get_parsed_message().header("X-Custom-Header") == "value-part-1part-2");Parser registers a fixed callback table (static const http_settings_s inline settings); the callbacks that carry meaning are these. You do not call them — they are the bridge between llhttp's event stream and the Request/Response you get back.
| llhttp callback | What Parser does |
|---|---|
on_url |
(requests only) Sets msg.method() from the parser's method, appends URL bytes into an owning buffer, and assigns msg.uri(). Tolerates a URL split across calls. |
on_status |
(responses only) Sets msg.status() from parser->status_code. |
on_header_field / on_header_value |
Accumulate the current name/value pair into owning std::strings; on the next field, push the completed pair into msg.headers(). Multiple values for one name are stored as a vector. |
on_headers_complete |
Flushes the last header pair, sets msg.major_version/minor_version, validates transfer encoding (see below), normalizes the body length, sets msg.upgrade and msg.keep_alive, then returns HPE_PAUSED. |
on_body |
Appends body bytes into an internal qb::allocator::pipe<char> (_chunked), enforcing the chunk and total-body limits. |
on_message_complete |
Sets the content type from the Content-Type header, moves the accumulated body into msg.body().raw(), and returns 1. |
msg.keep_alive is computed here from llhttp's http_should_keep_alive; it is the HTTP/1.x persistence decision and is ignored by HTTP/2 and HTTP/3 (those set stream_id instead — see message_base.h).
Header callbacks overwrite, they do not append across
http_executecalls. A header value that spans twohttp_executecalls would be silently truncated if the framing layer parsed it across two calls. The framing layer guarantees this never happens by re-feeding the whole buffer while the header block is incomplete (next section). Within a singleparse()call, fragmentation is handled correctly.
qb::protocol::http::base<IO_Handler, Trait> is a qb::io::async::AProtocol. A protocol's job in qb-io is to answer one question — does the input buffer hold a complete message yet, and if so how big is it? — via getMessageSize(), and then to hand that message to the I/O component via onMessage(). See the protocol interface for the full contract.
The two specializations are:
namespace qb::protocol::http {
template <typename IO_Handler>
class server : public base<IO_Handler, qb::http::Request> { /* parses requests */ };
template <typename IO_Handler>
class client : public base<IO_Handler, qb::http::Response> { /* parses responses */ };
}Both are wired in by the server/client session types as using protocol = qb::protocol::http::server<IO_Handler>; (and ::client). You normally reach them through qb::http::make_server or http1::make_client, never by hand.
getMessageSize() is called on the hot path every time bytes arrive. It runs the parser against the unconsumed portion of the I/O input pipe (_io.in()) and returns the size of one complete message, or 0 (IProtocol::kNoMessage) when more data is needed.
Phase 1 — headers. While headers_completed() is false, it parses the whole current buffer. If that returns HPE_OK (headers still incomplete), it resets the parser and returns 0, so the next call re-parses the full buffer from scratch. This is the deliberate consequence of the header-callback overwrite rule: each header must be seen in a single http_execute call, so the parser is re-fed the entire buffer on every pass until the header block is complete. It is O(n²) in header bytes for slowly-arriving headers — an intentional correctness-over-speed tradeoff, documented inline. Once the parser pauses (HPE_PAUSED), body_offset is computed from error_pos (llhttp's "where I stopped" pointer) relative to begin().
Phase 2 — HEAD / bodyless responses. On the client side, if the I/O handler exposes http1_response_body_forbidden() and it returns true (a response to a HEAD request, where the server sends headers describing a body that is not actually transmitted), framing stops at the end of the headers and returns the header size. This is detected with a requires-expression, so handlers that do not opt in are unaffected.
Phase 3 — body. With headers in hand:
- Chunked (
Transfer-Encodingpresent):resume()the parser and feed the post-header bytes.HPE_CB_MESSAGE_COMPLETEmeans the final chunk arrived — return the full message size.HPE_OKmeans more chunks are pending — advancebody_offsetand return 0. Anything else marks the protocolnot_ok(). - Content-Length (the common case): the message is complete once
_io.in().size() >= body_offset + content_length. When complete, the body is set as astring_viewover the input buffer and the total size is returned; otherwise return 0 and keep accumulating.
When getMessageSize() returns a non-zero size, qb-io consumes exactly that many bytes and calls onMessage(). If the parser ever marks itself not_ok(), the framework disconnects the connection.
server::onMessage and client::onMessage are the dispatch step. Both are noexcept (they sit on a noexcept AProtocol boundary), so each wraps cookie parsing in a try/catch — a malformed Cookie / Set-Cookie header is logged and ignored rather than escaping to std::terminate. They then move the parsed message into the I/O handler and reset the parser:
void onMessage(std::size_t) noexcept final {
auto &request_obj = this->_http_obj.get_parsed_message();
try {
request_obj.parse_cookie_header(); // may throw — contained here
} catch (const std::exception &e) {
LOG_HTTP_WARN("Failed to parse Cookie header: " << e.what());
} catch (...) {
LOG_HTTP_WARN("Failed to parse Cookie header: unknown exception");
}
this->_io.on(std::move(request_obj)); // hand off to the session/router
this->_http_obj.reset(); // ready for the next message
}Because the message is owned (std::string-backed, not a string_view into the socket buffer), the handler may move it into a long-lived structure — the shared Context, a middleware chain, or a coroutine frame — and it stays valid after the socket read that produced it. The retired server_view / client_view variants used view semantics and could not satisfy that requirement; the qb-io input pipe may memmove or reallocate between reads. See the request context for how ownership flows through the async pipeline.
After _io.on(...), server sessions apply pipelining and keep-alive: while a response is in flight, further requests queue up to max_pipelined_requests (default 128); exceeding the cap disconnects with DisconnectedReason::ByProtocolError. When the parser-computed keep_alive is false (or the session overrides it), the server closes after ResponseTransmitted. See Advanced topics.
on_headers_complete normalizes the body length so downstream framing never waits for an impossible body:
- Header-only requests. llhttp reports
ULLONG_MAX("unknown length") when neitherContent-LengthnorTransfer-Encodingis present. For requests this is normalized tocontent_length = 0(RFC 9112 framing — a request with no framing headers has an empty body). - Bodyless responses. Responses with a
1xx,204, or304status forcecontent_length = 0regardless of any declaredContent-Lengthheader (RFC 9112 §6.3). The test suite asserts this for both304 Not Modifiedand204 No Content. - Reservation. When the body length is known and within
MAX_BODY_SIZE, the body buffer isreserved up front for that many bytes — one allocation instead of repeated growth.
The parser is hardened against framing attacks. The limits live in namespace qb::http::protocol_limits (1.1/protocol/base.h) as compile-time constants, and each is enforced during parsing — a message that exceeds any limit fails with HPE_USER (via http_set_error_reason), which propagates as a parse error and a disconnect.
| Constant | Value | Enforced on |
|---|---|---|
MAX_URL_LENGTH |
8 KB (8192) | request URL/target |
MAX_HEADER_NAME_LENGTH |
1 KB (1024) | each header name |
MAX_HEADER_VALUE_LENGTH |
8 KB (8192) | each header value |
MAX_HEADERS_COUNT |
100 | header count per message |
MAX_CHUNK_SIZE |
16 MB | a single chunk |
MAX_BODY_SIZE |
100 MB | declared Content-Length and accumulated body |
MAX_BODY_SIZE is checked twice: once against the declared Content-Length before any body buffer is reserved (so an oversized declaration is rejected before allocation), and again against the running total as body bytes arrive.
Two framing defenses go beyond size limits, both in on_headers_complete:
- Request smuggling. A message carrying both
Transfer-EncodingandContent-Lengthis rejected ("HTTP Transfer-Encoding with Content-Length is forbidden"). This is the classic TE.CL / CL.TE smuggling vector. - Transfer-Encoding allowlist. Only a single
chunkedtoken is accepted. Any other value —gzip, chunked, a second encoding, anything non-chunked— is rejected ("Unsupported HTTP Transfer-Encoding").
These are exercised directly against Parser:
Parser<Request> parser;
// Oversized URL → rejected (not HPE_OK).
std::string big_url = "GET /" + std::string(protocol_limits::MAX_URL_LENGTH + 1, 'x') + " HTTP/1.1\r\n\r\n";
assert(parser.parse(big_url.data(), big_url.size()) != HPE_OK);
// TE + CL → smuggling defense.
parser.reset();
std::string smuggle = "POST /u HTTP/1.1\r\nTransfer-Encoding: chunked\r\nContent-Length: 3\r\n\r\n";
assert(parser.parse(smuggle.data(), smuggle.size()) != HPE_PAUSED);
// "gzip, chunked" → not on the allowlist.
parser.reset();
std::string bad_te = "POST /u HTTP/1.1\r\nTransfer-Encoding: gzip, chunked\r\n\r\n";
auto err = parser.parse(bad_te.data(), bad_te.size());
assert(err != HPE_OK && err != HPE_PAUSED);Parsing turns bytes into objects; serialization is the inverse, and it is not part of Parser. It is a qb::allocator::pipe<char>::put<> specialization for each message type, declared in request.h / response.h and defined in request.cpp / response.cpp:
template <>
qb::allocator::pipe<char> &
qb::allocator::pipe<char>::put<qb::http::Request>(const qb::http::Request &r);
template <>
qb::allocator::pipe<char> &
qb::allocator::pipe<char>::put<qb::http::Response>(const qb::http::Response &r);These write the request line / status line, the headers, and the body to the output pipe — out() << response_obj; is how a session emits a response. They add a Content-Length header when one is absent and the body is non-empty, and they enforce the same protocol_limits: serializing a message whose body, headers, or estimated wire size exceeds the limit throws std::length_error. See the message body deep dive for how the body is laid out in the pipe.
The HTTP/1.1 parser handles only the opening handshake of a WebSocket connection — the HTTP GET upgrade request and the 101 Switching Protocols response. Once the connection calls switch_protocol with qb::http::ws::protocol<IO_> and it succeeds, the same socket is parsed as RFC 6455 frames by an entirely different protocol (qb::protocol::ws_internal::base), not by qb::protocol::http::base. The framing rules, limits, and state machine for that phase are covered in WebSocket.
- Do not assume header callbacks accumulate across
http_executecalls. They overwrite. The framing layer's re-feed-the-whole-buffer strategy exists precisely so each header is parsed in one shot; if you build your own driver overParser, you must preserve that property or split-header values will be truncated. reset()before reuse, always. AParsercarries the previous message's state until reset. The framing layer resets after everyonMessage(); a hand-rolled loop must too.reset()is also what makes the parser usable after an error.content_length == 0does not mean "noContent-Lengthheader." For requests with no framing headers, and for1xx/204/304responses, the parser normalizes the length to 0 even when a (misleading)Content-Lengthheader was present. Read the body, not the raw header, to know what arrived.- The body is a view until
onMessage. InsidegetMessageSize()the Content-Length body is set as astring_viewinto the I/O input pipe; it only becomes owned whenonMessagemoves the message out. Never hold a reference toget_parsed_message()across aparse()or buffer mutation. - These limits are compile-time.
MAX_BODY_SIZEis 100 MB by default; there is no per-connection runtime knob in this layer. If you need a different ceiling you change the constant and rebuild the module. The HTTP/2 and HTTP/3 layers have their own, separateprotocol_limits. - This is HTTP/1.1 only. None of
Parser,protocol_limits, orqb::protocol::http::baseis involved in HTTP/2 (2/protocol/) or HTTP/3 (3/protocol/) framing. Do not reason about h2/h3 message limits from this page.
- Core concepts —
Request,Response,Headers,Method/Status, and the build/integration recipe. - HTTP message body deep dive —
qb::http::Bodyoverqb::allocator::pipe<char>, chunks, and compression. - Advanced topics and best practices — pipelining, keep-alive, and performance.
- HTTP/2 protocol specifics and HTTP/3 — the other framing layers.
- WebSocket — the RFC 6455 frame protocol the connection switches to after the upgrade.
- The doc map:
README.md.