ngx_http_ffi_client is an OpenResty/APISIX-oriented Nginx C module that will expose a LuaJIT FFI HTTP client API.
The project goal is to let Lua code issue outbound HTTP/1.1 requests through a C implementation that integrates with Nginx/OpenResty event handling, TLS, timers, buffers, and keepalive connection management.
The current implementation target is HTTP/1.x only:
- Lua-facing request/response API for APISIX plugins.
- Nonblocking execution inside OpenResty worker processes.
- HTTP/1.1 over HTTP and HTTPS.
- Keepalive connection pooling.
- Buffered and incremental (streaming) response reads.
- HTTP/1 parsing and framing through the vendored pure-C
llhttpparser. - LuaJIT FFI bindings in
lib/resty/ngx_http_ffi_client.lua.
Two entry points, both on resty.ngx_http_ffi_client.
request_uri(opts) — one shot: connect, send, read the whole response, then
give the connection back to the pool when keepalive is set, or close it.
local client = require "resty.ngx_http_ffi_client"
local res, err = client.request_uri({
scheme = "http", host = "127.0.0.1", port = 1984,
method = "GET", path = "/hello",
headers = { ["X-Trace"] = "1" },
keepalive = { pool_size = 30, idle_timeout = 60000 },
})
-- res.status / res.headers / res.body / res.trailersnew() — a stateful client object that owns its connection across calls,
shaped like lua-resty-http:
local httpc = client.new()
httpc:set_timeouts(2000, 2000, 2000)
assert(httpc:connect({ scheme = "https", host = "example.com", port = 443 }))
local res = assert(httpc:request({ method = "GET", path = "/events" }))
-- res.status and res.headers are in; the body has not been read yet
local reader = res.body_reader
while true do
local chunk = reader() -- one slice, as it arrives off the socket
if not chunk then break end
-- ...
end
-- or res:read_body() to get the whole body as a string
httpc:set_keepalive() -- back to the pool, or httpc:close()request{ preread_body = true } selects the buffered fast path instead: C reads
the whole body and resumes the coroutine once, with res.body ready. Use it for
bounded bodies; a stream read with body_reader is the right call for SSE and
very large responses.
res.headers and res.trailers follow the lua-resty-http contract.
- Names are kept as the peer sent them, and a lookup matches without regard to
case, with
_reading as-.res.headers["Content-Type"],res.headers["content-type"]andres.headers.content_typeall reach the same value. - A name that arrives more than once comes back as an array of its values, in
the order received, which is how
Set-Cookieis read. A name that arrives once is a plain string. - A request header may be given as an array too: each value is sent as its own header line, so a response table can be passed straight back.
The method and every header name must be an RFC 9110 token, a header value
must be free of control characters, and the path and host must be free of
whitespace and control characters. A method of GET /evil or a header name of
X: injected is refused before anything reaches the wire rather than writing a
request line or a header the caller never asked for.
set_keepalive(timeout, pool_size) follows the cosocket contract that
lua-resty-http forwards to.
timeoutis how many milliseconds the connection may sit idle in the pool. It defaults to60000, the cosocket'slua_socket_keepalive_timeout, and0asks for a connection that never expires on its own.pool_sizesizes the pool. A pool is sized once, when it is created, and keeps that size for the life of the worker: the firstpool_sizeto reach a given pool name wins and later ones are ignored, as with the cosocket. The pool is created by whichever comes first, aconnect{ pool_size = ... }or theset_keepalivethat parks the first connection in it.connectwithout apool_sizeleaves the sizing toset_keepalive, which falls back to30.- A full pool makes room by closing its least recently used connection.
The one-shot request_uri{ keepalive = { pool, pool_size, idle_timeout } }
resolves the same defaults and shares the same pools, so a pool name reaches the
same connections from either entry point.
This project is at an early implementation stage. The protocol direction is settled, but the full production behavior is still being built.
-
The HTTP/1 implementation strategy is settled. The client should borrow a stable, efficient, security-sensitive protocol parser instead of growing a larger hand-written HTTP parser in this repository. The selected HTTP/1 parser is the vendored pure-C
llhttpcode undersrc/llhttp/.llhttpis the recommended parser because it fits this module's constraints: it is C code that can be compiled into the Nginx addon without adding another language runtime; it is sans-io, so Nginx remains the only event-loop and transport owner; it is already used by Node.js core and undici for HTTP/1 parsing; it covers HTTP/1 framing rules such asContent-Length, chunked bodies, trailers, keepalive, and EOF framing; and the local parser benchmark shows near parity with the current hand-written C path. This lets the project reuse a mature HTTP/1 state machine while keeping timers, buffers, TLS, connection lifecycle, and keepalive pooling in Nginx/OpenResty code. -
llhttpis the default parser backend. The repository now builds thellhttpresponse parser by default. The initial hand-written parser remains available only as an explicit fallback and comparison baseline withNGX_HTTP_FFI_CLIENT_USE_LLHTTP=0. Keeping both build paths is a migration mechanism, not a long-term plan to maintain two independent HTTP/1 parsers. CI runs both the defaultllhttpbackend and the explicit hand-written fallback so the migration boundary stays covered. -
llhttpis the HTTP/1 parsing boundary for new work. New HTTP/1 parsing and framing work should be designed aroundllhttp. The defaultllhttpresponse parser benchmarks at near parity with the hand-written C path (see benchmark/llhttp-poc.md). -
The next HTTP/1 work stays in this stack. Incremental response delivery is in: the stateful object resumes at the headers and pulls the body one
llhttpspan at a time, so SSE and large bodies stream. Response framing is in as well, on both the streaming and the one-shot path:HEADresponses,204and304are bodyless whateverContent-Lengththey announce.HEADis matched case-sensitively, aslua-resty-httpdoes.- Chunked trailers are parsed but kept out of
res.headers; they are reported separately asres.trailers, filled once the body has been read to the end. - A response with neither
Content-Lengthnor chunked encoding is framed by the connection close, and that connection never goes back to the pool. A close inside a declaredContent-Lengthis still a truncated read. - Every
llhttp_set_lenient_*flag stays off. Strict mode is what rejects the request-smuggling shapes:Content-Lengthtogether withTransfer-Encoding, a conflicting repeatedContent-Length, and a chunk size that is not a plain hex number. The request side always frames withContent-Length, so a caller-suppliedTransfer-Encodingis dropped rather than put on the wire next to it. One request gets one response: a second message arriving behind the first is refused instead of merged into it. - Timeouts report
timeoutand a failed connect reports the socket error (connection refusedand the like), matching what the cosocket hands tolua-resty-httpcallers.
The client writes
Host,ConnectionandContent-Lengthitself, and a caller may override them the waylua-resty-httpallows. Their values feed the request prologue, so each is emitted once:Hostreplaces the one derived from the peer, for virtual hosting and for talking to an IP with a namedHost.Connectiongoes on the wire as given. A value asking to close also keeps that connection out of the pool, so the wire and the pool agree.Content-Lengthis accepted when it matches the body and refused when it disagrees.lua-resty-httpsends a mismatched length as given; here that would leave the connection out of sync with the peer, so it is an error.Transfer-Encodingis still dropped, since honoring it needs a streamed request body (#29).
The hand-written fallback accepts content-length and close framing and rejects
Transfer-Encodingoutright, so the chunked and trailer cases int/011-framing.trun on thellhttpbackend only.An interim
1xxresponse is consumed and the parser starts over on the response behind it, so a100 Continueor a103 Early Hintsnever reaches the caller as the answer and never strands the final response on a pooled connection. The headers of an interim response are dropped with it. A101is the exception: the connection stops speaking HTTP after it and this client has nothing to hand it over to, so it is refused. mTLS is future client work in this same C/Nginx module, handled at the transport layer rather than by changing the protocol scope. -
HTTP/2 and HTTP/3 are out of scope for the current project stage. They should not shape the current HTTP/1 API. If they are added later, they will likely need separate APIs, wrappers, and implementation choices rather than a direct reuse of the HTTP/1 client contract.
In short: the current goal is to make the HTTP/1 client stable, efficient, and
safe by building on llhttp and Nginx/OpenResty primitives first. Broader
protocol work is intentionally deferred.
The local benchmark pins the target OpenResty worker to one CPU core and
compares three request paths: a no-upstream baseline, the C FFI client, and
resty.http. The latest 15-second local runs used wrk2 with a saturated target
worker.
| wrk2 connections | no-upstream baseline | C FFI client | resty.http |
C FFI / resty.http QPS |
outbound cost ratio (resty.http / C FFI) |
|---|---|---|---|---|---|
10 |
83998.04 QPS |
32831.68 QPS |
16123.19 QPS |
2.04x |
2.70x |
100 |
96155.73 QPS |
38582.72 QPS |
14665.62 QPS |
2.63x |
3.72x |
The C FFI path delivers substantially higher end-to-end throughput than
resty.http, and the baseline-derived estimate shows resty.http spending
about 2.70x to 3.72x as much outbound client CPU time as the C FFI path in
these local runs. See
benchmark/README.md for the benchmark topology,
reproduction notes, and detailed measurements.
Design notes and implementation plans used during development live under docs/superpowers/ and are intentionally ignored by git.