fix(plugin): cap origin response headers at 64 KiB, not undici's 16 KiB default; v0.30.0 - #67
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces a new configuration option maxResponseHeaderBytes (defaulting to 64 KiB) to control the maximum response header size accepted from the origin, preventing UND_ERR_HEADERS_OVERFLOW errors from Undici. It updates the dispatcher caching mechanism in upstream.js to handle dynamic configuration changes by re-keying and closing superseded agents, and adds comprehensive behavioral tests. I have no feedback to provide as there are no review comments.
…iB default; v0.30.0
`new Agent({})` left `maxHeaderSize` unset, so undici fell back to Node's
`http.maxHeaderSize` (16 KiB). That limit is a header-flood mitigation for servers
accepting untrusted requests — the wrong default for a reverse proxy reading its
own origin. The cap is cumulative across the whole response head, so a Set-Cookie
pile-up plus CSP / Link rel=preload / NEL / Report-To clears it on a single page.
undici's response to exceeding it is `util.destroy(this.socket,
new HeadersOverflowError())` — it tears down the connection rather than rejecting
one request, so the crawler gets a 500 (plus a fresh TLS handshake on the retry)
for a page browsers and the CDN load normally. Observed in production on a
catalog facet URL: `GET /desktop/catalog/womens-clothing.jsp?CN=...` -> 500 in
703ms with `UND_ERR_HEADERS_OVERFLOW`. It is a property of that origin response,
so it fails deterministically for those URLs — not a transient — and the rate
scales with cache misses and passthrough as bot traffic ramps.
Adds `origin.maxResponseHeaderBytes` (default 64 KiB, min 16 KiB) and applies it to
both dispatcher constructions. The plain and staging-pinned Agents are built on
separate branches, so the pinned one would otherwise have kept the 16 KiB default —
a staging deploy 500ing on every large-header page reads as a staging-edge fault,
not a config gap.
undici fixes `maxHeaderSize` at construction and offers no way to change it on a
live Agent, so the option is declared `scope: 'restart'`: config.js reports a live
edit as pending-restart and the running dispatchers keep the value they were built
with. That keeps the hot path — every cache-miss and passthrough fetch — at the
single `!ip` branch it had before, rather than building a cache key and probing a
Map per request to support a reload nobody needs. The unpinned dispatcher is a
lazily-built singleton because the cap is not known at import time; `??=`
short-circuits, so there is no per-request allocation.
Tests assert the behavior against a real server rather than undici's internal
kMaxHeadersSize symbol: a 32 KiB response head succeeds under the default; a fresh
module instance with a 16 KiB cap still overflows (proving the option is wired to
undici, not merely stored); the unpinned dispatcher is built once and ignores a
live cap change (both halves of restart scope); the pinned dispatcher carries the
cap too; and an under-minimum value falls back to the default instead of silently
restoring the 16 KiB failure. config.test.js's restart-path assertion is updated
for the new scoped option.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
a970453 to
907bf49
Compare
|
The approach changed materially after the review above, so flagging it for a re-read rather than letting a stale summary stand. What the previous review saw: a dispatcher cache keyed on What is there now: that is removed. if (!ip) return (agent ??= new Agent(agentOptions()));Rationale: undici fixes Tests were reworked accordingly — the "rebuilds on cap change" test is gone, replaced by one asserting the dispatcher is built once and ignores a live cap change, plus a
|
|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request introduces a new configuration option, maxResponseHeaderBytes, which defaults to 64 KiB. This option configures the undici Agent's maxHeaderSize to prevent connection destruction with UND_ERR_HEADERS_OVERFLOW when origin response headers exceed Node's default 16 KiB limit. The change includes lazy initialization of the undici agents to apply this configuration, along with comprehensive integration tests. The review feedback suggests improving the readability of the test helper by extracting a magic number used in header padding into a named constant with an explanatory comment.
| const per = 1024; | ||
| for (let i = 0; i < Math.ceil(bytes / per); i++) headers[`x-pad-${i}`] = 'a'.repeat(per - 12); |
There was a problem hiding this comment.
The magic number 12 in the repeat count makes it difficult to understand how the header value length is derived. To improve readability and maintainability of this test helper, consider extracting this into a named constant with a comment explaining the calculation.
| const per = 1024; | |
| for (let i = 0; i < Math.ceil(bytes / per); i++) headers[`x-pad-${i}`] = 'a'.repeat(per - 12); | |
| const per = 1024; | |
| // A header name like x-pad-99 is 8 chars. The value length is set to leave some slack. | |
| const valueLength = per - 12; | |
| for (let i = 0; i < Math.ceil(bytes / per); i++) headers['x-pad-' + i] = 'a'.repeat(valueLength); |
There was a problem hiding this comment.
Taken, and made exact rather than approximate. The - 12 was a fudge, and the suggested comment ("leave some slack") would have documented the fudge rather than removed it.
undici counts header name and value bytes — Parser.onHeaderField and onHeaderValue each call trackHeader with their own buffer length — and does not count the : or CRLF delimiters. So budgeting name.length + value.length per header is precisely what the cap sees:
const HEADER_BYTES = 1024;
const name = `x-pad-${i}`;
headers[name] = "a".repeat(HEADER_BYTES - name.length);32 headers now sum to exactly 32768 bytes, so the thresholds the tests assert against (16 KiB, 64 KiB) are exact rather than approximate.
There was a problem hiding this comment.
Code Review
This pull request introduces a new configuration option, maxResponseHeaderBytes, to allow configuring the maximum response header size accepted from the origin, resolving issues where large headers caused connection termination with UND_ERR_HEADERS_OVERFLOW. The undici Agent is updated to apply this limit lazily, and comprehensive tests are added to verify its behavior. The review feedback suggests adding a maximum constraint of 8 MiB to the schema to prevent excessive memory usage, handling potential errors during server listening in tests to avoid hangs, and properly awaiting server closure to prevent resource leaks.
| 'Restart-scoped: undici fixes `maxHeaderSize` when the dispatcher is constructed and offers ' + | ||
| 'no way to change it afterwards, so a live edit is reported as pending-restart and the ' + | ||
| 'running dispatchers keep the value they were built with.', | ||
| { unit: 'bytes', min: 16 * 1024, scope: 'restart' } |
There was a problem hiding this comment.
The description for maxResponseHeaderBytes states that the option is bounded to prevent excessive worst-case memory usage per connection. However, the schema definition only specifies a min constraint and lacks a max constraint. To truly bound the memory usage and prevent accidental misconfiguration (e.g., setting an excessively large value that could lead to Out-Of-Memory issues), consider adding a reasonable max constraint, such as 8 * 1024 * 1024 (8 MiB).
| { unit: 'bytes', min: 16 * 1024, scope: 'restart' } | |
| { unit: 'bytes', min: 16 * 1024, max: 8 * 1024 * 1024, scope: 'restart' } |
References
- Validate and reject unusable or structural configuration overrides at the entry point (where configuration is parsed or applied) by restoring them to safe defaults, rather than adding defensive guards in downstream utility functions.
There was a problem hiding this comment.
Good catch — the description claimed the value was "bounded rather than unlimited" while the schema only declared a min, so the doc was writing a cheque the code did not honour. Added a max.
I went with 1 MiB rather than 8 MiB. No legitimate response head comes close to either, so the ceiling exists purely to catch a typo — a stray factor of a thousand — and a tighter one bounds the worst case eight times better while still leaving 16x headroom above the 64 KiB default. Since the cap is what undici may hold per connection while parsing a head, the multiplier that matters is concurrent connections, which argues for the smaller number.
Also confirmed this lands in the right layer, per your reference: enforceSchemaConstraints already handles max (config.js:215) by warning and restoring the default, so an out-of-range value is rejected at config-apply time rather than guarded downstream. Test now covers both directions plus the inclusive edge.
| res.writeHead(200, headers); | ||
| res.end('ok'); | ||
| }); | ||
| await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); |
There was a problem hiding this comment.
If server.listen fails or encounters an error, the returned Promise will hang indefinitely because there is no error handler. To make the test more robust and prevent potential hangs, listen to the 'error' event and reject the Promise if an error occurs.
| await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); | |
| await new Promise((resolve, reject) => { server.listen(0, '127.0.0.1', resolve); server.once('error', reject); }); |
There was a problem hiding this comment.
Taken. A hung test is much harder to diagnose than a failed one, and EADDRINUSE or a sandbox that forbids binding would both have hung here forever.
Registered the handler before listen so an error emitted synchronously cannot be missed:
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});| try { | ||
| return await fn(origin); | ||
| } finally { | ||
| server.close(); |
There was a problem hiding this comment.
In Node.js, server.close() is an asynchronous operation. Calling it without awaiting its completion can leave the port active or keep pending handles on the event loop, potentially causing resource leaks or side effects in subsequent tests. Consider wrapping it in a Promise and awaiting it to ensure the server is fully closed before proceeding.
| server.close(); | |
| await new Promise((resolve) => server.close(resolve)); |
There was a problem hiding this comment.
Taken — close() is async and this helper is used by four tests, so a lingering handle could leak into the next one. Now await new Promise((resolve) => server.close(resolve)) inside the finally, so teardown completes even when the assertion throws.
…inned dispatchers origin.staging.ip is live-scoped, so a pinned dispatcher can be constructed long after boot. Reading config at that point handed it a cap edited in the meantime while the unpinned singleton kept the boot value — two dispatchers disagreeing, and a pending-restart notice that was only half true. The cap is now captured on first use and reused by every dispatcher built afterwards. Regression test verified to fail without the capture (a pinned dispatcher built after a live drop to 16 KiB overflowed on a 32 KiB head) and pass with it. Also addresses review feedback on the test helper: the magic '- 12' padding fudge is replaced with exact accounting. undici's Parser.onHeaderField/onHeaderValue each call trackHeader with their own buffer length and the ': ' / CRLF delimiters are not counted, so budgeting name.length + value.length per header is precisely what the cap sees. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…r lifecycle Addresses review feedback: - The description claimed the cap was "bounded rather than unlimited" while the schema only declared a min. Adds max: 1 MiB, so the claim is now true and a typo (a stray factor of a thousand) is rejected back to the default instead of becoming an out-of-memory risk multiplied across concurrent connections. 1 MiB rather than the suggested 8 MiB: no legitimate response head approaches either, and the tighter ceiling still leaves 16x headroom over the default while bounding the worst case eight times better. - serverWithHeadBytes now rejects on a listen error instead of leaving the await to hang forever (EADDRINUSE, or a sandbox that forbids binding). A hung test is much harder to diagnose than a failed one. - withServer awaits server.close(), which is async, so a lingering handle cannot leak into the following test. The bounds test now covers both directions and asserts the inclusive edge stays usable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Pushed From review: exact byte accounting in the test helper (the From my own re-read, one substantive bug the reviews did not catch — worth calling out because it made the PR's central claim only half true:
Fixed by capturing the cap once on first use: let capturedMaxHeaderSize;
const agentOptions = () => ({
maxHeaderSize: (capturedMaxHeaderSize ??= config.origin.maxResponseHeaderBytes),
});The regression test was verified to actually guard it — with the capture reverted, a pinned dispatcher built after a live drop to 16 KiB overflows on a 32 KiB head and the test fails; with it, it passes. I checked that rather than assuming, since a test that passes both ways is worse than none.
|
The bug
util/upstream.jsbuilt its dispatcher asnew Agent({}), leavingmaxHeaderSizeunset. undici then falls back to Node'shttp.maxHeaderSize:16 KiB — a header-flood mitigation for servers accepting untrusted requests, which is the wrong default for a reverse proxy reading its own origin's response. Browsers and CDNs allow far more, which is why the page loads fine for a real user and only breaks when Harper proxies it.
The cap is cumulative across the whole response head, not per header:
So no single header needs to be large — a
Set-Cookiepile-up plusCSP/Link: rel=preload/NEL/Report-Toclears 16 KiB on one page. And undici's response is to destroy the socket, not reject a single request, so the crawler gets a 500 and the retry pays a fresh TLS handshake.Observed in production
This is deterministic per URL, not a flake — it's a property of that origin response, so those facet URLs 500 every time they're hit uncached. Once in 24h at current volume only because very little traffic has been proxied so far; the rate scales with cache misses and passthrough as bot traffic ramps.
The fix
origin.maxResponseHeaderBytes(default64 KiB,min16 KiB), documented with the reasoning and the memory trade-off (a bigger cap means more worst-case memory held per connection while a head is parsed — hence bounded, not unlimited).Restart-scoped, deliberately
undici fixes
maxHeaderSizeat construction and offers no way to change it on a live Agent. Rather than work around that, the option is declaredscope: 'restart'—config.jsreports a live edit as pending-restart and the running dispatchers keep the value they were built with.That keeps the hot path (every cache-miss and passthrough fetch) at the single
!ipbranch it had before:An earlier revision keyed the dispatcher cache on
(ip, cap)so a config change took effect live. That bought a reload nobody asked for at the cost of building a cache key and probing aMapon every origin fetch, so it's gone.??=short-circuits, so there's no per-request allocation; the singleton is lazy only because the cap isn't known at import time.Tests
Behavioral, against a real
httpserver — not undici's internalkMaxHeadersSizesymbol, so they survive an undici refactor and prove the thing that actually broke:http.maxHeaderSize)restartPaths()includes the option, guarding the scope declaration itselfconfig.test.js's exact restart-path assertion is updated for the new scoped option.383/383pass;npm run lintandformat:checkclean.Notes
Version 0.30.0 — 0.29.0 is reserved by #66.
🤖 Generated with Claude Code