Skip to content

feat(nodejs): upgrade undici to v8 - #96061

Merged
trunk-io[bot] merged 15 commits into
masterfrom
claude/undici-v8-upgrade-3a8d46
Sep 14, 2026
Merged

trunk-io[bot] merged 15 commits into
masterfrom
claude/undici-v8-upgrade-3a8d46

Conversation

@robbie-c

@robbie-c robbie-c commented Sep 7, 2026 •

Copy link
Copy Markdown
Member

Problem

  • Making concurrent HTTP/2 requests to one origin behave correctly on undici 7 takes a custom dispatcher built on undici internals (#95957). undici 8 handles the established-session case out of the box. Only the burst to a cold origin still needs help, and that no longer needs undici internals.
  • undici 7 runs one stream per session, never closes an idle session, and fails the request after a server GOAWAY with SocketError: HTTP/2: "GOAWAY" frame received with code 0. The new tests in this PR reproduce all three on 7.24.8.
  • APNs push already uses HTTP/2 on undici 7 in production, so a GOAWAY from Apple fails a push today and its idle sessions never close.
  • The 7.x line still gets releases (7.29.1 on 2026-09-04), but only security fixes and small HTTP/2 correctness patches were backported. One session per origin, GOAWAY replay and idle session reaping exist only in 8.x.
  • #95947, in the merge queue on undici 7, opts the session replay image fetch lane into HTTP/2; APNs push is the other caller. Once both land, that lane gets idle session reaping and the per-origin session cap from this PR.

Changes

  • Requests to an HTTP/2 origin now share one session per origin, up to the stream limit the origin advertises, and a request after a server GOAWAY moves to a new session.
  • An idle HTTP/2 session now closes after EXTERNAL_REQUEST_KEEP_ALIVE_TIMEOUT_MS (10 s), the same way an idle HTTP/1.1 socket does.
  • A caller can keep a session idle longer with 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.
  • A burst to a cold origin now shares one session. Each HTTP/2 dispatcher has a per-origin cold-start gate: the first request is the probe, and the rest wait for its response headers, which means the origin's SETTINGS frame has arrived. An origin that negotiates HTTP/1.1 still fans out after the probe. A held request keeps its own deadline, because its abort signal is created before the hold. When a probe fails, the next waiter probes again, and a request that throws before undici sends it releases the probe. The counter node_request_cold_start_gate_total{event} records probes, held requests and failed probes.
  • Plain http:// targets still go through the CONNECT tunnel when a proxy is set. undici 8 tunnels only https:// targets by default and would send other targets to the proxy as absolute-form requests, a path the proxy handles differently, so the dispatcher sets proxyTunnel.
  • The proxy path keeps undici's connect timeout. The configured 3 s would have cut the production budget for a tunnel handshake from 10 s, and a dependency upgrade is not the place to change it.
  • The HTTP/2 dispatchers get their own per-origin cap, 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.
  • A bad http2IdleTimeoutMs (not a finite number from 1 to 2^31-1, or a ninth distinct value) fails once, as InvalidRequestError, instead of burning the retry budget. A bad EXTERNAL_REQUEST_H2_CONNECTIONS stops the process at startup, because undici would otherwise read it late and treat 0 as unbounded.
  • Internal service traffic stays on HTTP/1.1. undici 8 offers HTTP/2 in ALPN by default, so the internal agent now sets allowH2: false explicitly, as the secure agent already did. Third-party requests keep HTTP/1.1 unless the caller sets allowH2.
  • Every server closes the shared request agents during shutdown, after its services stop, so in-flight requests settle and open sockets and HTTP/2 sessions end before the process exits. A warning names the agents still open when the 5 s grace period ends.
  • Mechanical: the lockfile, and a textStream stub on the Segment executor's hand-built response, which undici 8's Response type 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"} and cdp_http_request_timing_retried_ms for 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;
Loading

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;
Loading

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.

undici 8 change Since Effect here
The TLS handshake offers HTTP/2 by default (allowH2: true) 8.0 Every dispatcher sets allowH2 explicitly. Only APNs and the image fetch lane opt in.
One HTTP/2 session carries requests up to the origin's SETTINGS_MAX_CONCURRENT_STREAMS. undici 7 ran one stream per session. 8.4 (#5362), POST bodies in 8.5 (#5391) Concurrent requests to one origin share a session. EXTERNAL_REQUEST_H2_CONNECTIONS bounds the cold burst that happens before the SETTINGS frame arrives.
A server GOAWAY replays the refused streams once on a new session. undici 7 failed the next request. 8.6 to 8.10.1 (#5473, #5453, #5740) Apple's connection rotation no longer fails a push. 8.10.1 is required, because 8.10.0 closed the pool while replays were pending.
An idle HTTP/2 session closes after keepAliveTimeout. undici 7 never closed one. 8.5 (#5406) Sessions to customer origins close after 10 s. APNs asks for an hour through http2IdleTimeoutMs.
A refused HTTP/2 stream retries once 8.10 (#5598) Bounded retry, no change needed.
An idle HTTP/1.1 socket is validated before reuse 8.5 Security fix for keep-alive pools. No change needed.
The preferH2 connect option lists HTTP/2 first in ALPN 8.4 (#5327) Not set. Documented on allowH2.
HTTP/2 settings move under an h2Options namespace 8.10 (#5498) Not used.
The dispatch handler API is renamed (onRequestStart, onResponseStart, onResponseData, onResponseEnd, onResponseError) and the legacy wrappers are removed 8.0 nodejs/ defines no custom dispatcher, interceptor or handler.
The global dispatcher moves to a v2 symbol, with Dispatcher1Wrapper as the bridge 8.0 Nothing calls setGlobalDispatcher. Every request passes its dispatcher.
Blob and File bodies must be real instances 8.0 No request sends a Blob body.
The Response type gains textStream 8.x The Segment executor's hand-built response gains a stub.
Node 22.19 or later is required 8.0 .nvmrc pins 24.13.
Diagnostics channels keep their message shapes 8.0 @opentelemetry/instrumentation-undici 0.28 keeps working. The HTTP/2 client publishes sendHeaders in the same string form as HTTP/1.1.
Errors keep the brand-based instanceof 8.0 The custom request errors already extend Error, so instanceof checks keep working.

8.10.1 is the newest release older than the 7-day minimumReleaseAge. 8.10.2 is a security release for interceptors, BalancedPool and WebSocket, none used here, and is blocked until 2026-09-11.

How did you test this code?

  • request-http2.test.ts now runs each case on a fresh module and closes its agents afterwards, so the cases pass in any order.
  • Five new cases in that file, run against undici 7.24.8 as well as 8.10.0:
    • one session for every request, including the concurrent pair, and one fewer CONNECT tunnel (fails on 7.24.8 with two sessions);
    • a burst of six requests to a cold origin lands on one session with the pool cap at 4 (fails without the gate, with four sessions);
    • a burst to an origin that negotiates HTTP/1.1 still opens more than one connection after the probe;
    • a burst to an origin that refuses connections rejects every request instead of leaving the held ones waiting;
    • a plain http:// target through the proxy shows up as a CONNECT tunnel (fails without proxyTunnel with a timeout, because the proxy never sees a CONNECT);
    • cold-start-gate.test.ts pins 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);
    • the request after a server GOAWAY succeeds on a new session (fails on 7.24.8 with the SocketError above);
    • an idle session closes after the keep-alive timeout (fails on 7.24.8, where the session stays open);
    • a caller's http2IdleTimeoutMs session outlives the default one on the same origin;
    • closeSharedAgents closes open sessions and proxy tunnels well inside the keep-alive timeout (passes on both).
  • request-streamed.test.ts: an http2IdleTimeoutMs of 0, -1, 1.5, NaN or 2^31 is refused before a socket opens.
  • Ran locally: the three request suites, cdp-fetch, the Segment executor and the push notification suites, and five consecutive runs of the HTTP/2 suite. typescript:check is clean apart from the unbuilt @posthog/replay-anonymizer package in this checkout.
  • Not run: the Kafka, Postgres and ClickHouse-backed suites, which CI runs. Not run: a real APNs endpoint or a production proxy.

Automatic notifications

  • Publish to changelog?

Docs update

None. No doc describes the request helper settings; http2IdleTimeoutMs is 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-team round then found the undici 8 proxyTunnel default 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 install re-resolved unrelated vite variants; added http2IdleTimeoutMs after 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

robbie-c and others added 2 commits September 7, 2026 13:55
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>
@robbie-c robbie-c self-assigned this Sep 7, 2026
@trunk-io

trunk-io Bot commented Sep 7, 2026 •

Copy link
Copy Markdown

😎 Merged successfully - details.

@github-actions

github-actions Bot commented Sep 7, 2026 •

Copy link
Copy Markdown
Contributor

🤖 CI report

✅ Trunk lane — non-backend lane

This 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) — clean

New 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) — clean

New 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 .agents/skills/writing-code-comments/SKILL.md for the house rules.

Files with the most added comment lines:

File Comment lines Added lines
nodejs/src/common/utils/request.ts 27 180
nodejs/src/common/utils/cold-start-gate.ts 15 89
nodejs/src/common/utils/request-http2.test.ts 10 180
nodejs/src/common/config.ts 4 8
nodejs/src/common/utils/cold-start-gate.test.ts 4 64
nodejs/src/cdp/services/messaging/push-notification.service.ts 2 4
nodejs/src/common/utils/request-streamed.test.ts 2 17

This check does not block merging. It updates on every push and clears when the share drops.

✅ Bundle size — no change

Uncompressed size of every built .js bundle, compared against the base branch.

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 budget

How 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 import() / React.lazy chunks are not counted.

Root Eager (shipped) Δ vs base Budget
entry (logged-out pages, app bootstrap)
src/index.tsx
1.54 MiB · 22 files no change ████████░░ 83.6% of 1.84 MiB
logged-out boot: index + App + bootApp (preloaded by every page, including /login)
src/index.tsx + src/scenes/App.tsx + src/scenes/bootApp.ts
3.51 MiB · 610 files no change █████████░ 87.1% of 4.03 MiB
authenticated shell (every logged-in page)
src/scenes/AuthenticatedShell.tsx
8.29 MiB · 2,693 files no change █████████░ 87.2% of 9.51 MiB

🟢 node_modules/monaco-editor/ stays out of src/index.tsx
🟢 src/lib/components/ActivityLog/describers stays out of src/index.tsx
🟢 [object Object] stays out of src/index.tsx
🟢 [object Object] stays out of src/index.tsx
🟢 node_modules/monaco-editor/ stays out of src/index.tsx + src/scenes/App.tsx + src/scenes/bootApp.ts
🟢 src/layout/navigation-3000/navigationLogic.tsx stays out of src/index.tsx + src/scenes/App.tsx + src/scenes/bootApp.ts
🟢 src/scenes/dashboard/dashboardLogic.tsx stays out of src/index.tsx + src/scenes/App.tsx + src/scenes/bootApp.ts
🟢 src/lib/lemon-ui/LemonMarkdown/ stays out of src/index.tsx + src/scenes/App.tsx + src/scenes/bootApp.ts
🟢 src/lib/components/RichContentEditor/ stays out of src/index.tsx + src/scenes/App.tsx + src/scenes/bootApp.ts
🟢 src/lib/components/CodeSnippet/ stays out of src/index.tsx + src/scenes/App.tsx + src/scenes/bootApp.ts
🟢 src/taxonomy/core-filter-definitions-by-group.json stays out of src/index.tsx + src/scenes/App.tsx + src/scenes/bootApp.ts
🟢 node_modules/monaco-editor/ stays out of src/scenes/AuthenticatedShell.tsx
🟢 src/lib/components/ActivityLog/describers stays out of src/scenes/AuthenticatedShell.tsx
🟢 [object Object] stays out of src/scenes/AuthenticatedShell.tsx
🟢 src/scenes/session-recordings/player/sessionRecordingPlayerLogic.ts stays out of src/scenes/AuthenticatedShell.tsx
🟢 [object Object] stays out of src/scenes/AuthenticatedShell.tsx
🟢 [object Object] stays out of src/scenes/AuthenticatedShell.tsx

Largest files eagerly shipped from src/index.tsx
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 →

⚠️ 1 flaky test:

  • 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!

@trunk-io

trunk-io Bot commented Sep 7, 2026 •

Copy link
Copy Markdown

Static Badge   Static Badge   Static Badge

View Full Report ↗︎ ⋅ Docs

robbie-c and others added 2 commits September 7, 2026 14:21
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>
@robbie-c
robbie-c marked this pull request as ready for review September 7, 2026 13:52
@robbie-c
robbie-c requested a review from a team as a code owner September 7, 2026 13:52
…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>
Comment thread nodejs/src/common/utils/request.ts Outdated
@greptile-apps

greptile-apps Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor
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

Comment thread nodejs/src/common/config.ts Outdated
robbie-c and others added 9 commits September 7, 2026 15:05
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>
@trunk-io
trunk-io Bot merged commit 84822ae into master Sep 14, 2026
212 checks passed
@trunk-io
trunk-io Bot deleted the claude/undici-v8-upgrade-3a8d46 branch September 14, 2026 13:48
@deployment-status-posthog

deployment-status-posthog Bot commented Sep 14, 2026 •

Copy link
Copy Markdown

Deploy status

Environment Status Deployed At Workflow
dev ✅ Deployed 2026-09-14 14:51 UTC Run
prod-us ✅ Deployed 2026-09-14 15:09 UTC Run
prod-eu ✅ Deployed 2026-09-14 15:05 UTC Run

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants