feat(nodejs): upgrade undici to v8 - #96061
Conversation
undici 8 enables HTTP/2 by default, multiplexes requests on one session, replays requests after a server GOAWAY, and closes idle HTTP/2 sessions after the keep-alive timeout. The internal agent now sets allowH2 false explicitly so internal traffic stays on HTTP/1.1. Servers close the shared request agents during shutdown so open sockets and HTTP/2 sessions end before the process exits. The Segment executor's hand-built response gains the textStream stub that undici 8's Response type requires. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
undici closes an idle HTTP/2 session after keepAliveTimeout, so the 10 s default would reconnect to Apple's push service between sends. Callers pass http2IdleTimeoutMs, which selects an HTTP/2 dispatcher with that keep-alive timeout. APNs asks for an hour. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
😎 Merged successfully - details. |
🤖 CI report✅ Trunk lane — non-backend laneThis PR is assigned to the non-backend lane. It does not run backend Python tests and may merge in parallel with PRs in other lanes. ✅ Duplication (Python) — cleanNew Python code duplication introduced by this branch. Fails at 70+ tokens in app code, or 150+ tokens when both copies live in test files. Advisory while the gate proves itself: extract a shared helper instead of copying. ✅ Duplication (TypeScript) — cleanNew TypeScript code duplication introduced by this branch. Fails at 70+ tokens in app code, or 150+ tokens when both copies live in test files. Advisory while the gate proves itself: extract a shared helper instead of copying. 🚨 Comment density — 12% of added code lines are comments (64 of 548)This section warns when comments are more than 3% of the code lines a PR adds, and alerts above 6%. Before agent-assisted PRs, the typical share was about 2%. Only full-line comments count. Docstrings, generated files, snapshots, migrations, and workflow files are left out. Comments that restate the code, record how the change came about, or narrate the next line add noise for the next reader. Keep the comments that explain a reason the code cannot show, and remove the rest. See Files with the most added comment lines:
This check does not block merging. It updates on every push and clears when the share drops. ✅ Bundle size — no changeUncompressed size of every built Total: 68.54 MiB · no change No file changed by more than 1000 B. Posted automatically by build-bundle-size-report · uncompressed bytes from dist-report ✅ Eager graph — within budgetHow much code each root ships on the eager path — downloaded and parsed before the surface is interactive. Measured from the esbuild output chunks (post-tree-shake, static imports only); lazy
🟢 Largest files eagerly shipped from
|
| Size | File |
|---|---|
| 126.8 KiB | ../node_modules/.pnpm/react-dom@18.3.1_react@18.3.1/node_modules/react-dom/cjs/react-dom.production.min.js |
| 24.6 KiB | ../node_modules/.pnpm/buffer@6.0.3/node_modules/buffer/index.js |
| 6.3 KiB | ../node_modules/.pnpm/react@18.3.1/node_modules/react/cjs/react.production.min.js |
| 4.5 KiB | ../node_modules/.pnpm/@jspm+core@2.1.0/node_modules/@jspm/core/nodelibs/browser/process.js |
| 3.9 KiB | ../node_modules/.pnpm/scheduler@0.23.2/node_modules/scheduler/cjs/scheduler.production.min.js |
| 1.4 KiB | ../node_modules/.pnpm/base64-js@1.5.1/node_modules/base64-js/index.js |
| 1.3 KiB | src/index.tsx |
| 1.3 KiB | src/RootErrorBoundary.tsx |
| 912 B | ../node_modules/.pnpm/ieee754@1.2.1/node_modules/ieee754/index.js |
| 854 B | src/scenes/ChunkLoadErrorBoundary.tsx |
Largest files eagerly shipped from src/index.tsx + src/scenes/App.tsx + src/scenes/bootApp.ts
| Size | File |
|---|---|
| 294.1 KiB | ../node_modules/.pnpm/posthog-js@1.430.3_@types+react@18.3.27_react@18.3.1/node_modules/posthog-js/dist/module.mjs |
| 267.7 KiB | ../node_modules/.pnpm/@posthog+icons@0.38.0_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/@posthog/icons/dist/posthog-icons.es.js |
| 126.8 KiB | ../node_modules/.pnpm/react-dom@18.3.1_react@18.3.1/node_modules/react-dom/cjs/react-dom.production.min.js |
| 103.5 KiB | src/lib/api.ts |
| 81.0 KiB | src/products.tsx |
| 68.5 KiB | src/lib/lemon-ui/icons/icons.tsx |
| 62.4 KiB | src/lib/utils/eventUsageLogic.ts |
| 38.8 KiB | ../node_modules/.pnpm/@dnd-kit+core@6.0.8_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/@dnd-kit/core/dist/core.esm.js |
| 33.9 KiB | ../node_modules/.pnpm/kea@4.0.0-pre.6_patch_hash=139b8d1f1304f9d9da452a9a1244c94ea679dbcb85687d8999563146879fb6f5_react@18.3.1/node_modules/kea/lib/index.cjs.js |
| 33.2 KiB | src/queries/schema/schema-general.ts |
Largest files eagerly shipped from src/scenes/AuthenticatedShell.tsx
| Size | File |
|---|---|
| 294.1 KiB | ../node_modules/.pnpm/posthog-js@1.430.3_@types+react@18.3.27_react@18.3.1/node_modules/posthog-js/dist/module.mjs |
| 267.7 KiB | ../node_modules/.pnpm/@posthog+icons@0.38.0_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/@posthog/icons/dist/posthog-icons.es.js |
| 262.7 KiB | src/taxonomy/core-filter-definitions-by-group.json |
| 153.8 KiB | ../node_modules/.pnpm/re2js@0.4.1/node_modules/re2js/build/index.esm.js |
| 126.8 KiB | ../node_modules/.pnpm/react-dom@18.3.1_react@18.3.1/node_modules/react-dom/cjs/react-dom.production.min.js |
| 103.5 KiB | src/lib/api.ts |
| 99.3 KiB | ../packages/quill/packages/quill/dist/index.js |
| 93.3 KiB | ../node_modules/.pnpm/prosemirror-view@1.40.1/node_modules/prosemirror-view/dist/index.js |
| 90.6 KiB | ../node_modules/.pnpm/@tiptap+core@3.20.6_@tiptap+pm@3.20.6/node_modules/@tiptap/core/dist/index.js |
| 81.0 KiB | src/products.tsx |
Posted automatically by check-eager-graph · sizes are eager output bytes (shipped, post-tree-shake) from the esbuild metafile · part of #32479
✅ Toolbar bundle — eager 2.31 MiB within budget
What the toolbar ships to customer pages, measured from the esbuild output (minified, post-tree-shake). The eager set is the entry plus everything statically imported from it — fetched before any feature runs; deferred chunks load lazily. The eager guardrail is 5.72 MiB. Each output file must also stay below 10 MB, where CloudFront stops compressing it. The module boundary is enforced separately by check-toolbar-graph.
| Metric | Size | Δ vs base | Budget |
|---|---|---|---|
| Eager (shipped) entry + static imports |
2.31 MiB · 18 files | no change | ████░░░░░░ 40.4% of 5.72 MiB |
| Deferred (lazy) | 2.09 MiB · 45 files | no change | n/a — loads on demand |
Loader dist/toolbar.js |
1.1 KiB | no change | █░░░░░░░░░ 5.8% of 19.5 KiB |
Largest eagerly-shipped chunks
| Size | File |
|---|---|
| 765.2 KiB | dist/toolbar/toolbar-app-5K2MCIKI.css |
| 623.3 KiB | dist/toolbar/chunk-chunk-QYX6VXCH.js |
| 484.8 KiB | dist/toolbar/chunk-chunk-VDO5XIQA.js |
| 136.2 KiB | dist/toolbar/chunk-chunk-MLIMNCBC.js |
| 131.8 KiB | dist/toolbar/chunk-chunk-FDH2IBXT.js |
| 72.4 KiB | dist/toolbar/toolbar-app-WBSOOKVG.js |
| 69.0 KiB | dist/toolbar/chunk-chunk-TSAL54PB.js |
| 35.6 KiB | dist/toolbar/chunk-chunk-QT2AX66F.js |
| 21.1 KiB | dist/toolbar/chunk-chunk-U2XAOKAR.js |
| 6.8 KiB | dist/toolbar/chunk-chunk-DV7IWQNF.js |
Posted automatically by check-toolbar-size · sizes are toolbar output bytes (shipped, post-tree-shake) from the esbuild metafile
✅ Dist folder size — no change
Total size of the built frontend/dist folder (all assets), compared against the base branch.
Total: 1470.17 MiB · no change
⚠️ Playwright — 1 flaky
🎭 Playwright report · View test results →
- Can delete a person (chromium)
These issues are not necessarily caused by your changes.
Annoyed by this section? Help fix flakies and failures and it will go green!
undici counts a client as busy until its HTTP/2 session negotiates, so a burst to a cold origin opened one session per request up to the HTTP/1.1 pool size of 500. The HTTP/2 dispatchers now use EXTERNAL_REQUEST_H2_CONNECTIONS, default 4. http2IdleTimeoutMs is validated up front and fails as a non-retriable InvalidRequestError. The proxy path applies the configured connect timeout. Shutdown logs a warning when the grace period ends with requests still in flight. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
undici 8.10.0 closes a pool that still has GOAWAY replays pending, so the next send after a GOAWAY opened a third session. 8.10.1 keeps the pool (nodejs/undici#5740). http2IdleTimeoutMs must now be an integer between 1 and 2^31-1, because Node clamps a longer setTimeout delay to 1 ms, and at most 8 distinct values create dispatchers. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…imit The image fetch lane allows 6 concurrent requests per registrable domain. An origin that falls back to HTTP/1.1 gets one request per connection, so a cap of 4 queued the rest inside undici against the request timeout. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Prompt To Fix All With AI### Issue 1
nodejs/src/common/config.ts:265
**Deployment wiring is missing**
This adds `EXTERNAL_REQUEST_H2_CONNECTIONS`, which controls the HTTP/2 connection cap, but there is no corresponding production deployment configuration. The repository requires new environment variables to be added to `posthog/charts` and configured through `posthog/secrets`. Without that wiring, operators cannot override this setting and production remains fixed at the default of 4. This repository requirement must be satisfied before merging.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.Reviews (1): Last reviewed commit: "fix(nodejs): bump undici to 8.10.1 and b..." | Re-trigger Greptile |
undici's dispatch guard rejects a destroyed HTTP/1.1 or HTTP/2 dispatcher with ClientDestroyedError, and the closed flag does the same for a timeout that has no dispatcher yet. The test pins that all three paths agree. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
undici sizes its pool before ALPN tells it the protocol, so a burst to a cold HTTP/2 origin opened one session per request up to the pool cap. Each HTTP/2 dispatcher now has a per-origin cold-start gate. The first request to a cold origin is the probe. The rest wait for its response headers, which means the origin's SETTINGS frame has arrived, and then multiplex on that session. An origin that negotiates HTTP/1.1 still fans out after the probe. A failed probe releases the held requests. The pool cap now only bounds HTTP/1.1 fallback and the spill past an HTTP/2 stream limit. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
undici 8 no longer tunnels plain http targets through a CONNECT proxy by default, so ProxyAgent now sets proxyTunnel. The proxy path keeps undici's connect timeout, because production has run with it so far. The gate moves to its own module. A request that throws before undici sends it now releases the probe, a held request rejects at its own deadline because the abort signal is created before the hold, and the next waiter probes again when a probe fails. A counter records probes, held requests and failed probes. EXTERNAL_REQUEST_H2_CONNECTIONS is validated at startup, an idle timeout may be any finite positive number, and the shared request mock exports closeSharedAgents. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Problem
SocketError: HTTP/2: "GOAWAY" frame received with code 0. The new tests in this PR reproduce all three on 7.24.8.Changes
EXTERNAL_REQUEST_KEEP_ALIVE_TIMEOUT_MS(10 s), the same way an idle HTTP/1.1 socket does.http2IdleTimeoutMs. APNs asks for an hour, because Apple wants a connection reused for hours to days and treats rapid reconnects as abuse. Same option name as fix(node): reuse one HTTP/2 session per origin #95957, so a caller written against either PR works with the other.node_request_cold_start_gate_total{event}records probes, held requests and failed probes.http://targets still go through the CONNECT tunnel when a proxy is set. undici 8 tunnels onlyhttps://targets by default and would send other targets to the proxy as absolute-form requests, a path the proxy handles differently, so the dispatcher setsproxyTunnel.EXTERNAL_REQUEST_H2_CONNECTIONS(default 8), instead of the HTTP/1.1 fan-out of 500. With the gate it only bounds an HTTP/1.1 fallback origin and the spill past an HTTP/2 stream limit. The default sits above the image fetch lane's 6 requests per registrable domain. PostHog/charts#15245 exposes the setting in the deployment values.http2IdleTimeoutMs(not a finite number from 1 to 2^31-1, or a ninth distinct value) fails once, asInvalidRequestError, instead of burning the retry budget. A badEXTERNAL_REQUEST_H2_CONNECTIONSstops the process at startup, because undici would otherwise read it late and treat 0 as unbounded.allowH2: falseexplicitly, as the secure agent already did. Third-party requests keep HTTP/1.1 unless the caller setsallowH2.textStreamstub on the Segment executor's hand-built response, which undici 8'sResponsetype requires.Note
undici only multiplexes once the origin's SETTINGS frame has arrived. Measured without the gate: six concurrent requests to a cold origin opened six sessions, and a warm burst of six opened none. #95957 releases its held burst on the SETTINGS frame itself, read through an undici internal. The gate here releases on the probe's response headers, one request round trip later, with no internals. That round trip is the cost of a cold origin.
After rollout, watch
push_notification_rescheduled_total{platform="apns"},push_notification_failed_total{platform="apns",reason="network_error"},cdp_http_requests{status="error"}andcdp_http_request_timing_retried_msfor a step change. The 1 h APNs session also needs the egress proxy's CONNECT idle timeout to exceed undici's 60 s HTTP/2 ping interval.Follow-up: bump to 8.10.2 once it clears the release-age window on 2026-09-11. It fixes interceptor, BalancedPool and WebSocket issues, none of which this code uses.
Shutdown order, before:
flowchart LR Services[Services stop] --> Flush[Kafka producers flush] --> Infra[Kafka, Redis and Postgres close] --> Cleanup[additionalCleanup] --> Exit[process.exit] classDef phBlue fill:#1d4aff,stroke:#1d4aff,color:#fff; classDef phYellow fill:#f9bd2b,stroke:#f9bd2b,color:#000; class Services,Flush,Infra,Cleanup phBlue; class Exit phYellow;After:
flowchart LR Services[Services stop] --> Flush[Kafka producers flush] --> Infra[Kafka, Redis and Postgres close] --> Cleanup[additionalCleanup] --> Exit[process.exit] Flush --> Agents[Shared request agents close] --> Cleanup classDef phBlue fill:#1d4aff,stroke:#1d4aff,color:#fff; classDef phYellow fill:#f9bd2b,stroke:#f9bd2b,color:#000; class Services,Flush,Infra,Agents,Cleanup phBlue; class Exit phYellow;What changed in undici 8
Source: the v7 to v8 migration guide and the 8.x release notes. The table lists each change, the release it landed in, and what it means for this code.
allowH2: true)allowH2explicitly. Only APNs and the image fetch lane opt in.SETTINGS_MAX_CONCURRENT_STREAMS. undici 7 ran one stream per session.EXTERNAL_REQUEST_H2_CONNECTIONSbounds the cold burst that happens before the SETTINGS frame arrives.keepAliveTimeout. undici 7 never closed one.http2IdleTimeoutMs.preferH2connect option lists HTTP/2 first in ALPNallowH2.h2OptionsnamespaceonRequestStart,onResponseStart,onResponseData,onResponseEnd,onResponseError) and the legacy wrappers are removednodejs/defines no custom dispatcher, interceptor or handler.Dispatcher1Wrapperas the bridgesetGlobalDispatcher. Every request passes its dispatcher.BlobandFilebodies must be real instancesResponsetype gainstextStream.nvmrcpins 24.13.@opentelemetry/instrumentation-undici0.28 keeps working. The HTTP/2 client publishessendHeadersin the same string form as HTTP/1.1.instanceofError, soinstanceofchecks keep working.8.10.1 is the newest release older than the 7-day
minimumReleaseAge. 8.10.2 is a security release for interceptors,BalancedPooland WebSocket, none used here, and is blocked until 2026-09-11.How did you test this code?
request-http2.test.tsnow runs each case on a fresh module and closes its agents afterwards, so the cases pass in any order.http://target through the proxy shows up as a CONNECT tunnel (fails withoutproxyTunnelwith a timeout, because the proxy never sees a CONNECT);cold-start-gate.test.tspins the gate's contract without a network: hold and release, the next waiter probing after a failure, a held request rejecting on its own signal, and warmth ending at the idle timeout;request-streamed.test.ts: a request that throws before undici sends it releases its origin, so the next request does not wait forever (fails without the release with a 3 s hang);SocketErrorabove);http2IdleTimeoutMssession outlives the default one on the same origin;closeSharedAgentscloses open sessions and proxy tunnels well inside the keep-alive timeout (passes on both).request-streamed.test.ts: anhttp2IdleTimeoutMsof 0, -1, 1.5, NaN or 2^31 is refused before a socket opens.cdp-fetch, the Segment executor and the push notification suites, and five consecutive runs of the HTTP/2 suite.typescript:checkis clean apart from the unbuilt@posthog/replay-anonymizerpackage in this checkout.Automatic notifications
Docs update
None. No doc describes the request helper settings;
http2IdleTimeoutMsis documented at its declaration.🤖 Agent context
Autonomy: Human-driven (agent-assisted)
Claude Code (Fable 5.1), driven by Robbie. Skills invoked:
/writing-tests,/writing-code-comments,/writing-pr-descriptions,/asd-ste100,/qa-team. The QA review (six reviewers) converged on the HTTP/2 pool cap and the unvalidated idle timeout, and found the 8.10.0 Agent GOAWAY bug. The third and fourth commits address those, plus the proxy connect timeout, the shutdown warning and the idle-timeout bounds. The cold-start gate came later, after the question of whether a pool cap alone was sensible behavior; Robbie chose the gate as the cleaner API surface. A second/qa-teamround then found the undici 8proxyTunneldefault change, a probe that could strand its origin when a request threw before undici sent it, and held time outside the caller's timeout; the seventh commit fixes those, keeps undici's connect timeout on the proxy path, and merges master. A test that holds streams open across a GOAWAY was tried and dropped, because it leaked a server session and made the later cases flaky.Decisions across the session: kept HTTP/1.1 as the default for every dispatcher instead of adopting undici 8's HTTP/2 default; trimmed the lockfile to the undici entries after
pnpm installre-resolved unrelated vite variants; addedhttp2IdleTimeoutMsafter finding #95957, so the APNs session does not reconnect at the 10 s default. The duplicate search (gh pr list --search undici) found #95957 and #95947; neither upgrades undici. Nothing in this PR comes from outside public undici releases and issues.🤖 Generated with Claude Code