Part of the audit remediation umbrella #67. Milestone 5. Severity: MEDIUM. Requirements: TRANSPORT-9, TRANSPORT-14, TRANSPORT-20, RETRY-2, SEAM-30.
Current SDK behavior
- Every native fetch error is retryable.
fetch-transport.ts:282-289 wraps every non-abort
error as TransportFailureError, which classify.ts:71 treats as retryable. Request
accepts any absolute URL, so ftp://example.com reaches fetch, which rejects with a
permanent TypeError; the retry engine spends the whole budget on it. The undici transport
has a TERMINAL_ARGUMENT_CODES guard (undici-transport.ts:185-231) with a comment stating
why a permanent misconfiguration must not retry. The two transports classify the same
condition oppositely.
- No body-less response coverage. For 204, 304 and any HEAD response, fetch forwards
globalThis.Response.body, which is null (fetch-transport.ts:182); undici wraps
result.body unconditionally in toDemandDrivenStream (undici-transport.ts:349), a
non-null empty stream. fetch sets reasonPhrase (:180); undici never does. No fixture in
packages/transport-conformance/src/fixtures.ts:24-111 returns 204/304 and no row issues
HEAD.
CONTROL_BYTE omits LF. transport-shared/src/header-mapping.ts:6 is
/[\x00-\x08\x0B-\x1F\x7F]/u, skipping \x0A along with the intended \x09. Harmless
today because Headers.addInbound rejects LF and the try/catch drops the header.
- Producer-failure race. When
producerFailure wins the race
(fetch-transport.ts:278-281, undici-transport.ts:489-504), send() rejects while the
native call is pending. abandon unwinds the producer but does not abort the fork, so a
response that arrives later is discarded with its body neither cancelled nor drained.
sdk-design-nodejs/03-….md:86-90 states the obligation. Not driven by the auditor.
Expected behavior
Both transports throw a non-retryable error for a permanent misconfiguration and a retryable
TransportFailureError for a network failure. Both transports produce the same
Response.body shape for 204, 304 and HEAD, and the reasonPhrase gap is ledgered beside
§10 item 13. The inbound control-byte gate covers every C0 byte except HTAB. A send that fails
on the producer side aborts the native call so no response is stranded.
Notes and leads
- fetch: map
TypeErrors for unsupported scheme, invalid URL, forbidden method, and invalid
header to a non-IoError DexpaceError (or the bare TypeError undici uses). Keep
fetch failed with a network cause as TransportFailureError.
- Body-less: decide
body === null for body-less responses (the WHATWG shape), make undici
match, and add conformance rows for 204, 304 and HEAD asserting body, contentLength,
reasonPhrase.
- Regex:
/[\x00-\x08\x0A-\x1F\x7F]/u.
- Race: abort the forked signal in the producer-failure branch, then verify with an
instrumented transport whose native call resolves after the producer fails.
- Add a conformance row for an unsupported scheme that both transports run.
- Patch changesets for
@dexpace/transport-fetch, @dexpace/transport-undici,
@dexpace/transport-shared.
Part of the audit remediation umbrella #67. Milestone 5. Severity: MEDIUM. Requirements: TRANSPORT-9, TRANSPORT-14, TRANSPORT-20, RETRY-2, SEAM-30.
Current SDK behavior
fetch-transport.ts:282-289wraps every non-aborterror as
TransportFailureError, whichclassify.ts:71treats as retryable.Requestaccepts any absolute URL, so
ftp://example.comreachesfetch, which rejects with apermanent
TypeError; the retry engine spends the whole budget on it. The undici transporthas a
TERMINAL_ARGUMENT_CODESguard (undici-transport.ts:185-231) with a comment statingwhy a permanent misconfiguration must not retry. The two transports classify the same
condition oppositely.
globalThis.Response.body, which isnull(fetch-transport.ts:182); undici wrapsresult.bodyunconditionally intoDemandDrivenStream(undici-transport.ts:349), anon-null empty stream. fetch sets
reasonPhrase(:180); undici never does. No fixture inpackages/transport-conformance/src/fixtures.ts:24-111returns 204/304 and no row issuesHEAD.
CONTROL_BYTEomits LF.transport-shared/src/header-mapping.ts:6is/[\x00-\x08\x0B-\x1F\x7F]/u, skipping\x0Aalong with the intended\x09. Harmlesstoday because
Headers.addInboundrejects LF and thetry/catchdrops the header.producerFailurewins the race(
fetch-transport.ts:278-281,undici-transport.ts:489-504),send()rejects while thenative call is pending.
abandonunwinds the producer but does not abort the fork, so aresponse that arrives later is discarded with its body neither cancelled nor drained.
sdk-design-nodejs/03-….md:86-90states the obligation. Not driven by the auditor.Expected behavior
Both transports throw a non-retryable error for a permanent misconfiguration and a retryable
TransportFailureErrorfor a network failure. Both transports produce the sameResponse.bodyshape for 204, 304 and HEAD, and thereasonPhrasegap is ledgered beside§10 item 13. The inbound control-byte gate covers every C0 byte except HTAB. A send that fails
on the producer side aborts the native call so no response is stranded.
Notes and leads
TypeErrors for unsupported scheme, invalid URL, forbidden method, and invalidheader to a non-
IoErrorDexpaceError(or the bareTypeErrorundici uses). Keepfetch failedwith a networkcauseasTransportFailureError.body === nullfor body-less responses (the WHATWG shape), make undicimatch, and add conformance rows for 204, 304 and HEAD asserting
body,contentLength,reasonPhrase./[\x00-\x08\x0A-\x1F\x7F]/u.instrumented transport whose native call resolves after the producer fails.
@dexpace/transport-fetch,@dexpace/transport-undici,@dexpace/transport-shared.