Transport parity: permanent-error classification, body-less responses, CONTROL_BYTE, producer-failure race, defaultTimeoutMs (#82) - #97
Merged
Wahbeh-Mohammad merged 6 commits intoSep 5, 2026
Conversation
`degradeInboundHeaders`' inbound-value gate read `/[\x00-\x08\x0B-\x1F\x7F]/u`, which skips `\x0A` along with the intended `\x09`. Nothing observable changed — `Headers.addInbound` applies core's own `hasForbiddenInboundValueByte`, which does reject LF, and the `try`/`catch` two lines down records the same drop — so the two gates were redundant and only one of them was right. The class is now `/[\x00-\x08\x0A-\x1F\x7F]/u`, identical to core's. The constant is exported from the module (not from the barrel) so its test can read the character class directly: a test that went through `degradeInboundHeaders` would have passed against the broken class, which is how this survived from Phase 8a to audit #67 / #82. Found by: audit #67 / #82.
`@dexpace/transport-fetch` wrapped every native rejection as `TransportFailureError`, which `retry/classify.ts` reports retryable for being an `IoError`. `Request` accepts any absolute URL, so `ftp://example.com` reached `fetch`, was refused permanently, and spent the caller's whole retry budget re-proving it. `@dexpace/transport-undici` refused the same condition through `TERMINAL_ARGUMENT_CODES`: the two adapters classified one condition oppositely. The decision moves to `@dexpace/transport-shared`'s new `dispatch-classification.ts` — the precedent is `abort-mapping.ts` — as `isPermanentDispatchFailure` plus `toDispatchFailure`, and both adapters call it. It is an allow-list of three positive recognitions, so an unrecognised rejection stays the retryable `TransportFailureError` TRANSPORT-20 makes a MUST: - a terminal argument code on the error or its immediate cause, which is undici's whole dispatcher leg (`UND_ERR_INVALID_ARG`, `UND_ERR_NOT_SUPPORTED`) and Bun's `fetch` (`ERR_INVALID_ARG_VALUE` and friends); - a `TypeError` with no `cause`, which is how undici's `fetch` — Node's global `fetch` — reports argument validation, network failures always carrying one; - a cause naming one of three WHATWG scheme refusals, which is the only way that same `fetch` can report `ftp://` at all. `bad port` is deliberately excluded: port 1 is on WHATWG's blocked list, so TRANSPORT-20's own dead-port probe arrives with that reason and must stay retryable. A `DexpaceError` is passed through unchanged — it was classified at its source. Rows added, red against the unfixed fetch transport and already green against undici: `an unsupported URL scheme fails outside the IoError tree` in the shared suite (asserting `isIoError(e) === false`, which is exactly what the retry engine asks), the Node-runtime twin in `tests/node-conformance/transport.test.mjs` because the two runtimes use entirely different error shapes for it, five unit rows in `transport-fetch`, and twelve in `transport-shared`. Found by: audit #67 / #82.
…apters 204, 304, 205, 101, 103, every HEAD and a 2xx CONNECT can carry no body, and three of the four native combinations the two adapters meet disagreed about how to say so. undici's dispatcher always hands back a `BodyReadable`, so `@dexpace/transport-undici` wrapped an empty stream; Node's `fetch` returns `null` per the spec; Bun 1.3.14's `fetch` returns a live `ReadableStream` for all three (measured 2026-09-05), so `@dexpace/transport-fetch` was reporting the runtime's answer rather than the contract's. `hasNoResponseBody(method, status)` in `@dexpace/transport-shared` is now the rule and both adapters apply it, so `body === null` is a property of the SDK on every runtime. It is the WHATWG shape and the one `http/response.ts:18` already types; the rejected alternative, an empty stream on both, makes a consumer read to learn there is nothing to read. Each adapter releases the native handle it declines to expose — `cancel()` on fetch's, `dump()` on undici's. `Response.close()` is a no-op on a null body, so nobody else would, and an undrained `BodyReadable` holds the pooled connection open until the dispatcher times it out (TRANSPORT-25, SEAM-30). Rows: 204, 304 and HEAD in the shared suite, each asserting `body === null`, the `content-length` the case does or does not justify, and `reasonPhrase` as `undefined`-or-string — the fetch/undici divergence there is D7's ledger row beside §10 item 13 and is not re-ledgered. A GET over the same route is the twin, so nulling a body-less response cannot quietly null an ordinary one. All six were red on both adapters. `tests/node-conformance/transport.test.mjs` gets the runtime-divergent case, red on undici and green on fetch there, which is the asymmetry the Bun rows cannot show. `fixtures.ts`'s `route` passed the 70-line cap, so the three body-less fixtures are their own function. Found by: audit #67 / #82.
When a streaming request-body producer lost the race in `#dispatch`, `send()`
rejected while the native call was still pending, and nothing cancelled it. A
response arriving afterwards was dropped with its body neither read nor
released — TRANSPORT-9's leak, from the request side. `abandon` unwound the
producer; it could not reach the fork.
It could not reach the fork because for a send with no caller signal and no
composed timeout there was none: `forkSignal(undefined)` returned
`{signal: undefined}` and both transports dispatched with no signal at all,
which is exactly the case with nothing left to cancel with. `ForkedSignal.signal`
is now always a live `AbortSignal` — one controller nobody may ever abort, and
indistinguishable to the native client from no signal — and the interface gains
`abort(reason)`. `detach()` latches it, so the new direction cannot become the
SEAM-16 violation the fork's original direction exists to prevent.
Both transports read whether the *caller* aborted before pulling the fork
themselves; reading it after would surface every producer failure as a
`CancellationError`.
`producerFailure` now classifies its own rejection as the retryable
`TransportFailureError`, which is what both catches already produced for it.
That is not cosmetic: the same catch now runs native rejections through a table
that reads a bare `TypeError` as a permanent misconfiguration, and a producer
that threw one would have been mistaken for the wire refusing the request.
`prepareBody`'s buffered branch has classified the same failure at its source
since Phase 8a.
Instrumented rows on both transports — a `FetchLike` and a bring-your-own
`Dispatcher` whose native call resolves 30ms after the producer fails — assert
the dispatched signal is aborted with the producer's error and that the late
response never settles into the send. Both were red. Their twins assert a
delivered response leaves the fork unaborted. `signal-fork.test.ts` gains four
rows for the two-way fork and the latch. The fetch `defaultTimeoutMs` row is
rewritten: "a signal was handed over" no longer discriminates anything, so it
asserts the deadline is honoured instead.
Found by: audit #67 / #82.
`defaultTimeoutMs` was unchecked on both transports and reached `AbortSignal.timeout()` untouched. Node throws `RangeError` on `1.5`, `2**32` and `-1`; Bun 1.3.14 accepts the first two. The same misconfigured transport therefore failed every send on one runtime and used a deadline nobody asked for on the other. It is also the last such path. `RequestOptionsBuilder.timeoutMs` has enforced the integer `1 .. 2**32 - 1` range at its setter since audit #67 / #76, on HTTP-35's reading that a timeout a setter accepted and a transport then refused belongs at the call site. `requireValidDefaultTimeoutMs` in `@dexpace/transport-shared` applies the identical rule with the identical wording, and both factories call it first thing — before `selectDispatchers` allocates, so a refusal cannot leak an `Agent` with no transport to close it through. A `TypeError`, matching the two construction-time refusals `undiciTransport` already raises and asserted the same way. `@throws` on both factories, and the `defaultTimeoutMs` TSDoc now states the range. `TransportCapabilities` gains a required `buildWithDefaultTimeoutMs(value)`: required rather than a flag because §17 assumes every transport has a default (TRANSPORT-5 is written against one), and typed `number` because every value the rows supply legitimately is one. Twelve rows, red on both adapters, plus the in-range twin that proves narrowing did not reject a legitimate default, plus the Node-runtime case — that one matters because Node is the runtime that used to fail late and loudly where Bun failed silently. Carried from audit #67 / #76 (D14's hand-off). Found by: audit #67 / #82.
`docs/deviations.md` gains one row for the reading the classification table rests on: `TRANSPORT-20`'s "any transport failure that produced no HTTP response" is read as an exchange that failed, not as a request the native client refused to make, and such a refusal is reported outside the `IoError` tree so `retry/classify.ts`'s allow-list makes it non-retryable. The reading is not new — undici has applied it since Phase 8a — but it lived only in that phase's checklist, and until this run `@dexpace/transport-fetch` did the opposite for the identical condition. The row records that the MUST is still the default: the table is an allow-list, and `bad port` is excluded by name because TRANSPORT-20's own dead-port probe arrives with that reason on Node. `reasonPhrase` is deliberately not re-ledgered — it is already a row beside §10 item 13. Two citations this branch moved are re-anchored: the SOCKS row's `undici-transport.ts:138,151-158,192` -> `:147,160-167,201` and `fetch-transport.ts:76-79` -> `:79-82`. `run-suite.ts`'s header re-anchors the two TRANSPORT-22 test citations for the same reason, and says why TRANSPORT-9's producer race is not a shared row: only an instrumented native client can show that a pending call was cancelled. READMEs: `transport-shared`'s module table gains the three new modules and the fork's second direction; both adapters' behaviour lists gain the classification rule, the body-less contract and the `defaultTimeoutMs` range.
This was referenced Sep 5, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #82. Milestone 5 of the audit remediation umbrella #67, wave 6b, on top of #81. Decision D20 in
docs/audit-67-decisions.md.The two shipped transports disagreed on four things, and a fifth was handed over from #76. Every fix moves the decision into
@dexpace/transport-shared— the precedent isabort-mapping.ts— so the two adapters cannot answer differently again, anddocs/sdk-documentation/write-a-transport.mdtells a third transport to use the same code.What changed
1.
CONTROL_BYTEwas excepting LF as well as HTAB (3c72ef0)./[\x00-\x08\x0B-\x1F\x7F]/uskips\x0A. Nothing observable changed —Headers.addInboundrejects LF and thetry/catchrecorded the same drop — which is exactly why it survived from Phase 8a. The class is now identical to core'shasForbiddenInboundValueByte, and the constant is exported from its module (not the barrel) so its test can read the character class directly; a test throughdegradeInboundHeaderspassed against the broken class.2. One classification table for a permanent native failure (
73af7c5).@dexpace/transport-fetchwrapped every native rejection asTransportFailureError, whichretry/classify.tsreports retryable for being anIoError— softp://example.comspent the caller's whole retry budget re-proving a URL that cannot change. undici refused the same condition throughTERMINAL_ARGUMENT_CODES. Newdispatch-classification.tsin@dexpace/transport-shareddecides it once, as an allow-list of three positive recognitions:fetch;TypeErrorwith nocause, which is how undici'sfetch(Node's globalfetch) reports argument validation, network failures always carrying one;fetchcan reportftp://at all.Everything else falls through to the retryable
TransportFailureErrorTRANSPORT-20 makes a MUST.'bad port'is excluded by name: port 1 is on WHATWG's blocked list, so TRANSPORT-20's own dead-port probe arrives with that reason on Node. ADexpaceErroris passed through unchanged — it was classified at its source.3. A body-less response reports
body === nullon both adapters (55f06ac). Three of the four native combinations disagreed: undici's dispatcher always hands back aBodyReadable; Node'sfetchreturnsnull; Bun 1.3.14'sfetchreturns a liveReadableStreamfor 204, 304 and HEAD alike (measured 2026-09-05).hasNoResponseBody(method, status)is the rule now, so the shape is the SDK's rather than the runtime's, and each adapter releases the handle it declines to expose —cancel()on fetch's,dump()on undici's, sinceResponse.close()is a no-op on a null body and an undrainedBodyReadableholds a pooled connection.4. A producer failure aborts the native call it raced (
deb32e0).send()rejected while the native call was still pending and nothing cancelled it (TRANSPORT-9). It could not: for a send with no caller signal and no composed timeout,forkSignal(undefined)returned no signal at all.ForkedSignal.signalis now always live, the interface gainsabort(reason), anddetach()latches it so the new direction cannot become the SEAM-16 violation the fork exists to prevent. Both transports read whether the caller aborted before pulling the fork; reading it after would surface every producer failure as aCancellationError.producerFailurenow classifies its own rejection at its source — the same catch runs native rejections through a table that reads a bareTypeErroras permanent, and a producer that threw one would have been mistaken for the wire refusing the request.5.
defaultTimeoutMsvalidated at both factories (ac26cb0, carried from #76 / D14). It reachedAbortSignal.timeout()unchecked: Node throwsRangeErroron1.5,2**32and-1; Bun 1.3.14 accepts the first two.requireValidDefaultTimeoutMsapplies the identical integer1 .. 2**32 - 1ruleRequestOptionsBuilder.timeoutMshas enforced since #76, with the identical wording, as aTypeErrorbefore anything is allocated.Test rows added
Every row was watched red against the unfixed transport first.
transport-conformance(both adapters run it)an unsupported URL scheme fails outside the IoError treetransport-conformancebody === null, thecontent-lengththe case justifies,reasonPhraseundefined-or-string; plus the GET twin over the same routetransport-conformancedefaultTimeoutMsvalues refused at the factory, plus the in-range twin that still sendstests/node-conformance/transport.test.mjsdefaultTimeoutMstransport-shareddispatch-classificationrows, 5body-lessrows, 3default-timeoutrows, 4signal-forkrows for the two-way fork and the latch, 4CONTROL_BYTErowstransport-fetchtransport-undiciDispatcherTRANSPORT-9's race is not a shared conformance row and
run-suite.ts's header says why: proving a pending native call was cancelled needs the signal the adapter handed it, which only an instrumented native client can show. The same reasoning TRANSPORT-22 already carried there.TransportCapabilitiesgains a requiredbuildWithDefaultTimeoutMs(value)— required rather than a flag because §17 assumes every transport has a default (TRANSPORT-5 is written against one), and typednumberbecause every value the rows supply legitimately is one.Totals after:
bun run test2526 tests across 169 files;bun run test:node184.Deviation rows added
One, appended to "Deviations recorded outside a phase" in
docs/deviations.mdper D0:The reading is not new — undici has applied it since Phase 8a — but it was recorded only in that phase's checklist while
@dexpace/transport-fetchdid the opposite for the identical condition. The row records that the MUST is still the default, and why'bad port'is excluded.reasonPhraseis deliberately not re-ledgered: it is already a row beside §10 item 13 (D7).Documentation
write-a-transport.mdgrows from eleven rules to thirteen: rule 4 (classify with the shared table), rule 8 (a body-less response reportsbody === null), rule 6 rewritten for the fork's second direction, rule 12 generalised from "refuse a proxy you cannot honour" to "refuse at construction what you cannot honour". The plumbing table and therunTransportConformanceSuiteexample are updated. All three package READMEs gain the new behaviour.Two citations this branch moved are re-anchored (
undici-transport.ts:138,151-158,192→:147,160-167,201;fetch-transport.ts:76-79→:79-82), as are the two TRANSPORT-22 test citations inrun-suite.ts's header.API reports
transport-shared.api.mdregenerated:hasNoResponseBody,isPermanentDispatchFailure,toDispatchFailure,requireValidDefaultTimeoutMs, andForkedSignal's new shape.transport-fetchandtransport-undiciare byte-identical — the changes there are@throwsand parameter prose, which the reports do not carry.Deferred — release machinery
Suspended for this run under D1. Recoverable:
@dexpace/transport-shared:CONTROL_BYTEcovers LF; new@internalexportshasNoResponseBody,isPermanentDispatchFailure,toDispatchFailure,requireValidDefaultTimeoutMs;ForkedSignal.signalis no longer optional and the interface gainsabort();producerFailurerejects with a wrappedTransportFailureError.@dexpace/transport-fetch: a permanent misconfiguration (unsupported scheme, forbidden method, invalid argument) is now a non-retryableTypeErrorrather than a retryableTransportFailureError— behaviour visible to any caller relying on the retry budget; 204/304/HEAD now carrybody === null;defaultTimeoutMsoutside1 .. 2**32 - 1now throws at the factory; a producer failure cancels the in-flightfetch. Arguably minor rather than patch, since two of the four change an observable outcome.@dexpace/transport-undici: 204/304/HEAD now carrybody === nullinstead of an empty stream — a consumer that read the body to completion still works, one that branched onbody !== nulldoes not;defaultTimeoutMsvalidated at the factory; a producer failure cancels the in-flight dispatch; the permanent/retryable table moved but its verdicts are unchanged.docs/first-release.mduntouched, though the transport-fetch entry above is its "free before the first bump" class.Gate
node .claude/skills/ci-preflight/run-ci.mjs --clean, one run, from a swept tree, pinned to Bun 1.3.14: all 20 steps passed.