HTTP client
HttpClient keeps a pool of warm HTTP/1.1 connections behind one API. The
implementation is configuration:
HttpImpl | What it is | Use it when |
|---|---|---|
Native (default) | ergon-io’s own client on a Driver: each request is one TLS record, each response is parsed in place | latency matters: on the same interrupt-driven path (epoll-spin), faster at the median than the source-built libcurl (measured results) |
Curl (feature curl) | libcurl, linked in-process, driven without blocking | the endpoint needs redirects, proxies, HTTP/2, other auth schemes or compression |
- Same code for both: switching is a configuration change.
- Linux only (like the
Driver). - One level down,
HttpConnis a single keep-alive connection on your ownDriver(the API reference has it).
Example
The handler gets every result, keyed by request id:
#[derive(Default)]
struct Seen {
ok: Vec<(u64, u16, String)>,
errors: Vec<(u64, Sent, String)>,
}
impl ClientHandler for Seen {
fn on_response(&mut self, request: u64, r: &Response<'_>) {
self.ok.push((
request,
r.status,
String::from_utf8_lossy(r.body).into_owned(),
));
}
// `Sent::No`: the server cannot have seen it; `Sent::Maybe`: it may
// have run (reconcile an order by its client order id).
fn on_error(&mut self, request: u64, sent: Sent, error: Error) {
self.errors.push((request, sent, error.to_string()));
}
}
Native:
/// The native implementation: `recv-busy`, connections opened in the
/// background.
fn native(lab: &Lab) -> R<HttpClient> {
let mut cfg = HttpClientConfig::new(HttpImpl::Native);
cfg.pool = 2; // warm connections, one request in flight each
// A pod with one CPU has no core for the connect helper: opt in to
// opening on the loop there (without this, `new` returns `Error::Cpu`).
cfg.driver.inline_connect_fallback = true;
let client = HttpClient::new(&cfg, lab.target("")?, lab.client_cfg.clone())?;
Ok(client)
}
libcurl:
/// libcurl (feature `curl`): the same API. Without the feature, `new`
/// returns `Error::Unsupported`.
fn curl(lab: &Lab) -> R<HttpClient> {
let mut cfg = HttpClientConfig::new(HttpImpl::Curl);
cfg.pool = 2; // the most connections libcurl may use at once
cfg.curl_ca_file = Some(lab.ca_file()?.display().to_string()); // libcurl's trust store
let client = HttpClient::new(&cfg, lab.target("")?, lab.client_cfg.clone())?;
Ok(client)
}
One request, the same code for both:
/// One GET, the same code for both implementations: wait for a free
/// connection, send, then poll until the answer.
fn get(client: &mut HttpClient, path: &str) -> R<(u16, String)> {
let mut seen = Seen::default();
let end = Instant::now() + Duration::from_secs(10);
while client.idle() == 0 {
client.poll(&mut seen)?; // the pool connects in the background
if Instant::now() > end {
return Err("no connection".into());
}
}
let id = client.request("GET", path, &[], &[])?; // returns at once
while seen.ok.is_empty() && seen.errors.is_empty() {
client.poll(&mut seen)?; // one non-blocking pass
if Instant::now() > end {
return Err("no answer".into());
}
}
let (got, status, body) = seen.ok.pop().ok_or_else(|| format!("{:?}", seen.errors))?;
assert_eq!(got, id);
Ok((status, body))
}
(From io/tests/client.rs. cargo test -p ergon-io runs get through
the native client against a local TLS server on Linux; with
--features curl, through libcurl too. lab is that server: in your code
use Target::new("api.binance.com", 443, "") and
tls::client_config(tls::webpki_roots())?.)
Rules
- One request in flight per connection. All busy:
Error::Busyat once, no hidden queue. Each pooled connection is one more socket the loop polls: read many connections before sizingpool. - A timed-out request closes its connection (
request_timeout_ms, 5 s): HTTP/1.1 cannot drop one request on a live connection. The pool opens a replacement. Both implementations report it asError::Timeout("http request"). - A failed request says whether it was sent:
on_error(request, sent, error).Sent::No: the request never reached the kernel whole, so the server cannot have seen it. Safe to send again.Sent::Maybe: it may have run. Look an order up by its client order id; never send it again blindly.
- libcurl takes no method but GET, HEAD, OPTIONS and DELETE
(
Error::CurlResend) unlesscurl_allow_non_idempotent_methodsis set. Why: libcurl sends a request again, up to 5 times, when the reused connection it went out on dies before any reply byte, so an order could be placed twice (io/tests/client.rsshows it happening). The native client never re-sends: send orders through it. - Connections open in the background (
background_connect, on by default): the handshake runs off the loop, unless the driver’sinline_connect_fallbackis set. A failed connect goes toClientHandler::on_connect_errorand is retried with backoff. - Reconnects spread out: a dropped connection reopens after a random
delay of up to 100 ms (the limit doubles per failed connect, up to 5 s),
so connections that drop together do not reconnect together.
reconnect_seedfixes the delays (tests). - A venue’s connect limit (native only): with
connect_gate,newopens nothing and every connect waits for the gate passed topoll_gated;pollthen refuses. A refusal does not grow the backoff. Give the pool aBudgetbucket of its own: venues’ connect limits count WebSocket connects. buf_size(1 MiB) is each native connection’s receive buffer: it must hold the largest response.- Responses:
Content-Lengthor chunked bodies. The body inon_responseborrows the client’s buffer: copy what you keep. - libcurl:
curl_drivepickscurl_multi_performon every poll (default) orcurl_multi_socket_action; both never block. Compared once on Azure at 1,000/s:curl_multi_performwas 1–6 µs ahead at the median in both rounds and had the higher max (data). Trust comes fromcurl_ca_fileor the system store, not from the rustls configuration.
Signing
Signer signs request parameters as Binance’s signed endpoints expect,
without allocating. HttpClient::signed_request appends the timestamp and
the signature itself; signed_request_at takes the timestamp from you
(your clock, or replay time).
| signer | use it for |
|---|---|
Signer::hmac_sha256 | signed REST requests |
Signer::ed25519_pkcs8 | log-on, once per session; the private key never leaves the machine. About 80 times HMAC’s cost: measure before putting it on a hot path |
Cost per signature: request signing.
Checked against Binance’s documented example:
/// Binance "SIGNED endpoint" example (HMAC-SHA256, parameters in the query).
const SECRET: &str = "NhqPtmdSJYdKjVHjA7PZj4Mge3R5YNiP1e3UZjInClVN65XAbvqqM6A7H5fATj0j";
const PARAMS: &str =
"symbol=LTCBTC&side=BUY&type=LIMIT&timeInForce=GTC&quantity=1&price=0.1&recvWindow=5000";
const TS: u64 = 1_499_827_319_559;
const SIGNATURE: &str = "c8db56825ae71d6d79447849e617115f4a920fa2acdcab2b053c4b2838bd6b71";
#[test]
fn hmac_matches_binance_documented_example() -> R {
let signer = Signer::hmac_sha256(SECRET.as_bytes());
assert_eq!(signer.kind(), SignatureKind::HmacSha256);
let mut out = Vec::new();
auth::append_signed_params(&mut out, PARAMS, TS, &signer);
let expected = format!("{PARAMS}×tamp={TS}&signature={SIGNATURE}");
assert_eq!(String::from_utf8(out)?, expected);
Ok(())
}
(From io/tests/auth.rs; cargo test -p ergon-io runs it on every
platform.)
- Set the API key once as a header:
Target::with_header("X-MBX-APIKEY", key).
Measured results
Every figure, with its max: HTTP clients (1 connection, x86) and the fair run (pool of 1 and 20, Graviton).
- Native against libcurl, same interrupt path: native is ahead at the median on Azure and AWS. Its p99 and max are lower on Azure, not on AWS.
HttpClientwith a pool of 1 was within 1 µs of a rawHttpConnat the median and p99; one of its rounds had a higher max.- reqwest parks its thread between requests and trailed both on Azure.
Many connections
Measured on Graviton with a pool of 20 on one loop, one request in flight
(fair run):
uring-sqpoll and the interrupt backends lead, per_queue is behind them
and plain recv-busy further behind.
- Count the pool with every other socket the thread polls: feeds,
order connections, other pools. Each
HttpClienthas its ownDriver, so its poll adds to theirs. Then pick: choosing a backend. - Set it with
HttpClientConfig::driver, for exampleConfig::parse("recv-busy,per_queue=true")?.
Measure it on your own two hosts with io/benches: one run per backend,
and --pool 1 as the reference.
cargo build --release --manifest-path io/benches/Cargo.toml --bin replay_server --bin http_bench --bin analyse
B=io/benches/target/release
# sender: a keep-alive HTTP server; copy cert-http.pem to the receiver.
# It spins one thread per connection: run it unpinned, with more free CPUs than connections.
sudo $B/replay_server --listen 0.0.0.0:9445 --cert-out cert-http.pem --mode http --busy-poll 50
# receiver, loop on isolated CPU 2: 20 pooled connections, one request in flight, 10,000/s
sudo $B/http_bench --client client --backend recv-busy --pool 20 --server SENDER:9445 --ca cert-http.pem \
--sni lab.local --cpu 2 --rate 10000 --duration 60 --out res/http20-recv-busy
# then the same with: --backend recv-busy --per-queue (out res/http20-per-queue),
# --backend epoll-spin (res/http20-epoll-spin), --backend uring-sqpoll --sqpoll-cpu 3 (res/http20-sqpoll)
$B/analyse --warmup-s 2 res/http20-*
- On AWS, set the NIC on the node first (node-level):
ethtool -C <nic> adaptive-rx off rx-usecs 0, with the NIC’s interrupts on a core of their own, as in the fair run. - Compare p50, p99 and max per backend; repeat in reversed order.