feat: default to HTTP/2 transport on Node#815
Conversation
Flip the `http2` client option default from `false` to `true`, so the SDK uses its native `node:http2` multiplexing transport by default on Node.js instead of one HTTP/1.1 connection per request. Web/Deno/Bun are unaffected (platform fetch already speaks h2), and a user-supplied `fetch` still wins. Opting out: pass `http2: false` for the HTTP/1.1 `node-fetch` transport. To keep existing `httpAgent` users working, a bare `httpAgent` (with no explicit `http2`) keeps the client on HTTP/1.1 and emits a one-time warning; pass `http2: false` to select HTTP/1.1 explicitly and silence it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
CodeAnt AI is reviewing your PR. |
Thanks for using CodeAnt! 🎉We're free for open-source projects. if you're enjoying it, help us grow by sharing. Share on X · |
|
CodeAnt AI finished reviewing your PR. |
…essions Making HTTP/2 the default surfaced two behaviors of the H2 transport that only mattered once non-opt-in clients used it: - `createH2Fetch` returned a bespoke `H2Response`, so `.asResponse()` no longer satisfied `instanceof Response` — breaking the generated resource tests that assert the raw response is a node-fetch `Response`. Adapt the internal `H2Response` to a real node-fetch `Response` at the fetch boundary (streaming preserved: the web `ReadableStream` body is consumed as a Node `Readable`). - Pooled H2 sessions kept the event loop alive, so a short script (and Jest) would hang on exit instead of terminating like the HTTP/1.1 transport. Unref idle sessions and ref them only while a request is in flight. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… default pool Addresses PR review: - Move the HTTP/2 transport-selection logic and one-time warnings out of the generated `src/index.ts` into a handwritten `src/lib/http2-transport.ts` helper (`resolveHttp2Fetch`). The generated file keeps only a one-line call-site hook, following the existing `makeHttp2Fetch` / `H2FetchOptions` pattern, so regeneration stays clean. - Share a single default HTTP/2 pool across all clients instead of building a new pool per `new Runloop()`. Short-lived clients no longer leak H2 sessions. Explicit `H2FetchOptions` still get a dedicated pool (intentional tuning). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
CodeAnt AI is running Incremental review |
Thanks for using CodeAnt! 🎉We're free for open-source projects. if you're enjoying it, help us grow by sharing. Share on X · |
|
CodeAnt AI Incremental review completed. |
Addresses PR review: - Clarify the `src/lib/http2-transport.ts` header: explain that transport selection matters only on Node (its default `node-fetch` speaks HTTP/1.1), while browsers/Deno/Bun already negotiate HTTP/2 via the platform fetch. - Clarify the `http2` option JSDoc: "Node.js only" distinguishes it from other JS runtimes, where the option has no effect. - Remove stale undici references now that the H2 transport is native `node:http2`: the `makeHttp2Fetch` shim docs (Node = `node:http2`, arg is H2FetchOptions), the web/Deno no-op comments, the "Replaces undici" note, and the undici-based rationale in the README requirements. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
CodeAnt AI is running Incremental review |
Thanks for using CodeAnt! 🎉We're free for open-source projects. if you're enjoying it, help us grow by sharing. Share on X · |
|
CodeAnt AI Incremental review completed. |
Per review: no compatibility layer — just make the behavior and reasoning clear. A Node `http.Agent` configures HTTP/1.1 socket pooling, which has no equivalent in HTTP/2's multiplexed model (H2 knobs live on `H2FetchOptions`), so `httpAgent` has no effect on the H2 transport. Spell this out in the README HTTP/2 section and in the `resolveHttp2Fetch` doc. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
james-rl
left a comment
There was a problem hiding this comment.
This looks good to me.
Some trivial nits:
- in
tests/index.test.ts:105references a variable that doesn't exist any more src/lib/h2-transport/index.ts:69has status 101 in NULL_BODY_STATUSES but it's unreachable on HTTP/2src/lib/h2-transport/index.ts:89uses Object.defineProperty for url but omitswritable: true
…or, stale comment) Per review on #815: - Drop `101` from NULL_BODY_STATUSES — Switching Protocols is an HTTP/1.1 upgrade status and can't occur over HTTP/2, so it was unreachable. - Add `writable: true` to the `Response.url` property descriptor. - Fix a stale comment in tests/index.test.ts that referenced the pre-refactor `useHttp2` / `makeHttp2Fetch(...)` shape; it's now `resolveHttp2Fetch(options)`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Thanks @james-rl! All three nits fixed in 31661f3:
tsc + lint clean; h2-transport (80) and http2 unit tests still green. |
Why HTTP/2 (performance motivation)
On Node.js the previous default transport (
node-fetchover HTTP/1.1) opens a new TCP + TLS connection per concurrent request — HTTP/1.1 can't multiplex, so a burst of N in-flight requests means up to N handshakes and, once the socket pool is saturated, requests queue waiting for a free connection. That connection setup and queueing lands squarely in the tail latency.The SDK's
node:http2transport multiplexes many concurrent requests as streams over a small shared pool of connections: one handshake amortized across many requests, no per-request connection setup, and no pool-exhaustion queueing. Runloop SDK traffic is bursty and highly concurrent (agents firing many devbox operations in parallel), which is exactly the workload HTTP/2 multiplexing is built for. Bulk transfers stay at parity because the H2 session's flow-control window is sized so large streams aren't RTT-bound.End-to-end benchmark (dev)
Measured against
devfrom this branch (Node v24), same client config with only thehttp2flag flipped. Indicative single-run results from a shared environment — compare like-for-like, not as absolute targets.Request handling under high concurrency (primary)
The SDK's own load harness —
loadtest/loadtest.ts(from #798) — fires manydevboxes.createcalls concurrently at a deliberately nonexistent blueprint (bp_nonexistent_loadtest_00000). Every request fails fast server-side (all HTTP400, zero devboxes created), which isolates client + server request handling from actual provisioning — i.e. purely "can the stack absorb a burst of requests at once." Run here at 2,000 concurrent requests (reduced from the harness default; the sandbox FD limit is 4096),http2set explicitly per pass:400400Under a 2,000-request burst, HTTP/1.1 (
node-fetch) opens a socket per request and serializes behind connection setup + pool limits, so latency balloons into multi-second territory; HTTP/2 multiplexes the whole burst over a small connection pool and clears it in under a second. This is the workload the default flip is aimed at.Concurrent lightweight reads (secondary)
A smaller micro-benchmark firing
Nconcurrentdevboxes.list({ limit: 1 })reads (warmup excluded, 0 failures) — a lighter endpoint where server processing dominates the median, so the win shows in the tail:Concurrent large downloads (throughput parity)
8 × 10 MB via
downloadFile, md5-verified (exercises the H2 flow-control window):Bulk throughput is at parity (slightly ahead here), with all payloads byte-identical — HTTP/2 is not a regression for large transfers.
Summary
Makes HTTP/2 the default transport on Node.js. The SDK's native
node:http2transport multiplexes many concurrent requests over a small shared pool of TLS connections instead of opening one connection per request. Previously HTTP/2 was opt-in (http2: true); this flips the default fromfalse→true.fetchalready speaks HTTP/2, and the H2 shim is a no-op there.fetch: always wins overhttp2.http2: falseselects the HTTP/1.1node-fetchtransport.Transport resolution
httpAgentonly applies to the HTTP/1.1 path — the H2 pool manages its own connections. To avoid silently changing behavior for existing users who pass anhttpAgent, resolution is:http2httpAgentfalsetruehttpAgentalso setH2FetchOptionsfetchNo existing
httpAgentuser is silently moved off HTTP/1.1: they get a one-time notice and can drophttpAgentto adopt H2 or passhttp2: falseto silence it.Shared connection pool
All clients using the default transport share a single HTTP/2 pool, so short-lived clients (e.g. one per request) don't each open — and leak — their own H2 sessions. Explicit
H2FetchOptionsstill get a dedicated pool, since that's deliberate tuning.Transport correctness
Making H2 the default surfaced two behaviors of the transport that only mattered once non-opt-in clients used it, both fixed here:
Responsecontract — the H2 fetch now returns a standardnode-fetchResponse, so.asResponse()satisfiesinstanceof Responselike the HTTP/1.1 transport does. Streaming is preserved (the H2 body is consumed as a NodeReadable).unref'd (andref'd only while a request is in flight), so a short Node script or test process exits normally instead of hanging on open sockets.Code organization
The transport-selection logic and its one-time warnings live in a handwritten helper,
src/lib/http2-transport.ts. The generatedsrc/index.tskeeps only a one-line call-site hook, so regeneration stays clean.Docs
Rewrote the README HTTP/2 transport section for H2-as-default and the
http2: falseopt-out, and corrected stale copy that still described the previous undici-based transport (it's nativenode:http2).Release
Ships as a minor version bump;
http2: falseis the documented opt-out.Test plan
tsc --noEmitclean.http2: falseopt-out, dedicated pool forH2FetchOptions, and both one-time warnings.jestsuite against the mock server: 880 passed, 6 skipped, including alltests/api-resources/**and the h2-transport suite; the process exits promptly.🤖 Generated with Claude Code