Scenario: payload-too-large
Proofs: docs/scenarios/proofs/payload-too-large/ (8 scripts, 157 checks here and 157 on origin/main, byte-identical)
No disclosure content — API-surface, resilience-classification and encoding findings. Every size
below is the vendor's own count of body bytes off the socket, never a client-side estimate or
a Content-Length.
First, what works
- The inbound cap is exemplary.
serve() rejects a declared over-cap Content-Length on a
pre-check with a 413 whose body names the number (request body exceeds 8192 bytes,
serve.ts:95-98), and cuts an over-cap chunked body mid-stream (:107-112). The source comment
even notes it counts raw Buffer chunks rather than decoded string length.
- A 413 is not retried by default —
retry.on is [429, 502, 503, 504], so attempts: 3 cost
exactly one request. For a failure that is deterministic in the body that is the right default.
- A string body is passed through untouched (
http-adapter.ts:327), so the text escape hatch
exists.
1. The one function that knows the size is not reachable from the package
encodeRequestBody is export function at http-adapter.ts:320 and is shared by three transports.
index.ts:20-21 re-exports only fetchAdapter and FetchAdapterOptions from that module, and
packages/core/package.json publishes no ./http-adapter subpath.
Measured against the vendor's byte count, for one payload containing côté and 東京:
| seam |
json |
form |
multipart |
JSON.stringify(body).length (what everyone writes) |
−9 (−5.4 %) |
−165 (−50.9 %) |
−811 (−83.6 %) |
Buffer.byteLength(JSON.stringify(body)) |
0 |
−156 (−48.2 %) |
−802 (−82.7 %) |
auth.apply / hooks.onRequest / a wrapping Adapter |
−9 |
−165 |
−811 |
fetchAdapter({ fetch }) |
0 |
0 |
0 |
The three middle seams produce an identical estimate because they hold the same object — and
they do see req.bodyType, so they know which encoder is about to run and cannot run it. Sizing a
multipart body at the fetch seam needs a second full serialisation
(new Response(fd).arrayBuffer()) which mints a different boundary than the one that ships; the
count only agrees because the undici boundary is a fixed 32 characters.
The universal advice for this class of failure is "validate the size of your payload before
transmission". Today that is expressible at exactly one seam, and only by re-implementing or
re-running the library's own encoder.
Ask: export encodeRequestBody (or a small sizeOf(req): number) from the package. It already
exists, is already exported from its module, and is already shared by three transports.
2. A 413 opens the circuit, and the resulting error is retryable
engine.ts:897-898 carefully documents that a surface rejection reaches the try as a returned
!ok and therefore records a circuit success — "the transport is healthy, the payload is not."
A failing HTTP status does not take that path: it throws at :847-854 and lands in
circuit.onFailure() at :902-904.
Measured with circuit: { failures: 2, cooldown: '30s' } and four over-cap calls: the caller saw
413, 413, 503, 503, only 2 requests reached the vendor, and a fifth well-formed, under-cap
body was refused without a request.
A 413 is the clearest case in HTTP of "the transport is healthy, the payload is not" — the exact
sentence the comment uses for the case that is excluded.
And CircuitOpenError's class does not survive .safe(): the caller holds a StitchError with
status: 503, which is in the default retry.on, so a breaker-rejected call is retryable by
default.
Ask: exclude 413 (and 4xx generally, except 429) from circuit accounting, or name the exclusion
in the circuit guide. This is the fifth distinct scenario in the pass where the breaker counted a
failure no cooldown can heal.
3. StitchError has no headers, so a header-published cap is unreachable
Vendors publish a limit either in the response body or in a header. The body is reachable via
StitchError.body; a header is not — StitchError declares status, attempts, body, url
(types.ts:1832-1834). The only route is a hooks.onResponse side channel writing to a variable
the catch block reads: running on every response, for a number that matters on one status.
Ask: carry the response headers on StitchError the way body and url already are — the
pinned response is already on the non-enumerable channel.
4. encodeRequestBody has no binary arm, and the two obvious binary types fail differently
BodyEncoding is 'json' | 'form' | 'multipart' (types.ts:146). A gzip payload handed to body:
| what you pass |
what goes on the wire |
ratio to the gzip |
intact? |
Node Buffer |
{"type":"Buffer","data":[…]} |
3.59× |
no |
Uint8Array |
an index map {"0":31,"1":139,…} |
9.33× |
no |
latin1 string |
passed through, re-encoded as UTF-8 |
1.50× |
no |
rewriting init.body in fetchAdapter({ fetch }) |
the gzip bytes |
1.00× |
yes |
Three plausible spellings inflate a compressed body and corrupt it, silently, with a 200 from a
vendor that did not verify the encoding. content-encoding occurs zero times in
packages/core/src.
Ask: either accept Uint8Array/ArrayBuffer/Blob as a pass-through body (fetch accepts all
three natively), or reject a non-plain-object body on the JSON arm rather than stringifying it.
5. Multipart costs 81 bytes per field, and nothing says so
Measured by differencing two bodies that differ by one field: 81 bytes constant + len(name) +
len(value), plus a 38-byte terminator, with a 32-character boundary. The formula predicted a
150-field body at 53 646 bytes to the byte.
Because the cost is charged per field, the multiplier over JSON is a property of leaf count, not
of the data: 1.32× on a 50-record corpus and 5.77× on a small nested payload, from the same
encoder. A caller sizing a chunk has no way to know that 150 leaves is 12 150 bytes of frame before
a single character of their data.
Ask: a line in the body-encoding guide. The number is stable and useful.
6. Smaller, all measured
- An oversized single item and an unlucky chunk are byte-identical to the caller — same
name,
message, status, attempts, body and field set. Only the vendor's byte count differs, and
the caller never sees it.
- Chunking by estimated bytes — the recommended strategy — still 413'd 5 times and lost 105 of
200 records, where the exact serialized count delivered all 200 at the 11-request floor. And the
exact chunker went from 0 to 10 413s on a one-word wire.body change.
- Bytes sent are a browser-only observable:
fetchAdapter declares
supports: ['stream', 'downloadProgress'] and the source says fetch cannot report bytes sent.
byteLength appears 3 times in packages/core/src, all response-side.
Reproduction
npx tsx docs/scenarios/proofs/payload-too-large/c1-what-you-can-measure.ts
npx tsx docs/scenarios/proofs/payload-too-large/c4-the-413.ts
Eight scripts run offline against a node:http vendor with a hard 16 384-byte cap whose ledger
accumulates chunk.length over raw Buffer chunks — Content-Length is recorded and never scored
against. The corpus is a 200-entry literal byte-length table, re-checked before anything is sent.
Nothing depends on randomness or the clock.
Source references (verified against origin/main)
http-adapter.ts:320 — export function encodeRequestBody(req: AdapterRequest): {
index.ts:20-21 — export { fetchAdapter } from './http-adapter'; / export type { FetchAdapterOptions } …
http-adapter.ts:327 — if (typeof req.body === 'string') return { body: req.body };
http-adapter.ts:328 / :345 / :354 — the form, multipart and JSON arms
http-adapter.ts:152-155 — supports: ['stream', 'downloadProgress'], and the "cannot report bytes SENT" comment
http-adapter.ts:184 / :195 — the only byteLength uses, both in readWithProgress
engine.ts:635 — const retryMatch = acceptsStatus(cfg.retry?.on ?? [429, 502, 503, 504]);
engine.ts:847 — if (!outcome.ok && classifyStatus(res.status, cfg)) {
engine.ts:897-898 — // … it records a circuit SUCCESS — the transport is healthy, the payload is not.
engine.ts:902 / :904 — } catch (e) { / const opened = await circuit.onFailure();
resilience.ts:255-256 — export class CircuitOpenError extends Error { / readonly status = 503;
types.ts:146 — export type BodyEncoding = 'json' | 'form' | 'multipart';
types.ts:1832-1834 — export class StitchError extends Error { / readonly status: number | undefined;
serve.ts:66 — export const MAX_REQUEST_BODY_BYTES = 2 * 1024 * 1024;
serve.ts:95-98 — the Content-Length pre-check · :107-112 — the mid-stream cut · :251 — sendJson(res, 413, { error: e.message });
Found by a scenario pass that researches a real-world API integration problem, captures it, and proves or refutes each claim with runnable offline code. Every source reference above was read on origin/main.
Scenario: payload-too-large
Proofs:
docs/scenarios/proofs/payload-too-large/(8 scripts, 157 checks here and 157 onorigin/main, byte-identical)First, what works
serve()rejects a declared over-capContent-Lengthon apre-check with a 413 whose body names the number (
request body exceeds 8192 bytes,serve.ts:95-98), and cuts an over-cap chunked body mid-stream (:107-112). The source commenteven notes it counts raw
Bufferchunks rather than decoded string length.retry.onis[429, 502, 503, 504], soattempts: 3costexactly one request. For a failure that is deterministic in the body that is the right default.
http-adapter.ts:327), so the text escape hatchexists.
1. The one function that knows the size is not reachable from the package
encodeRequestBodyisexport functionathttp-adapter.ts:320and is shared by three transports.index.ts:20-21re-exports onlyfetchAdapterandFetchAdapterOptionsfrom that module, andpackages/core/package.jsonpublishes no./http-adaptersubpath.Measured against the vendor's byte count, for one payload containing
côtéand東京:JSON.stringify(body).length(what everyone writes)Buffer.byteLength(JSON.stringify(body))auth.apply/hooks.onRequest/ a wrappingAdapterfetchAdapter({ fetch })The three middle seams produce an identical estimate because they hold the same object — and
they do see
req.bodyType, so they know which encoder is about to run and cannot run it. Sizing amultipart body at the fetch seam needs a second full serialisation
(
new Response(fd).arrayBuffer()) which mints a different boundary than the one that ships; thecount only agrees because the undici boundary is a fixed 32 characters.
The universal advice for this class of failure is "validate the size of your payload before
transmission". Today that is expressible at exactly one seam, and only by re-implementing or
re-running the library's own encoder.
Ask: export
encodeRequestBody(or a smallsizeOf(req): number) from the package. It alreadyexists, is already exported from its module, and is already shared by three transports.
2. A 413 opens the circuit, and the resulting error is retryable
engine.ts:897-898carefully documents that a surface rejection reaches the try as a returned!okand therefore records a circuit success — "the transport is healthy, the payload is not."A failing HTTP status does not take that path: it throws at
:847-854and lands incircuit.onFailure()at:902-904.Measured with
circuit: { failures: 2, cooldown: '30s' }and four over-cap calls: the caller saw413, 413, 503, 503, only 2 requests reached the vendor, and a fifth well-formed, under-capbody was refused without a request.
A 413 is the clearest case in HTTP of "the transport is healthy, the payload is not" — the exact
sentence the comment uses for the case that is excluded.
And
CircuitOpenError's class does not survive.safe(): the caller holds aStitchErrorwithstatus: 503, which is in the defaultretry.on, so a breaker-rejected call is retryable bydefault.
Ask: exclude 413 (and 4xx generally, except 429) from circuit accounting, or name the exclusion
in the circuit guide. This is the fifth distinct scenario in the pass where the breaker counted a
failure no cooldown can heal.
3.
StitchErrorhas noheaders, so a header-published cap is unreachableVendors publish a limit either in the response body or in a header. The body is reachable via
StitchError.body; a header is not —StitchErrordeclaresstatus,attempts,body,url(
types.ts:1832-1834). The only route is ahooks.onResponseside channel writing to a variablethe catch block reads: running on every response, for a number that matters on one status.
Ask: carry the response headers on
StitchErrorthe waybodyandurlalready are — thepinned response is already on the non-enumerable channel.
4.
encodeRequestBodyhas no binary arm, and the two obvious binary types fail differentlyBodyEncodingis'json' | 'form' | 'multipart'(types.ts:146). A gzip payload handed tobody:Buffer{"type":"Buffer","data":[…]}Uint8Array{"0":31,"1":139,…}latin1stringinit.bodyinfetchAdapter({ fetch })Three plausible spellings inflate a compressed body and corrupt it, silently, with a 200 from a
vendor that did not verify the encoding.
content-encodingoccurs zero times inpackages/core/src.Ask: either accept
Uint8Array/ArrayBuffer/Blobas a pass-through body (fetch accepts allthree natively), or reject a non-plain-object body on the JSON arm rather than stringifying it.
5. Multipart costs 81 bytes per field, and nothing says so
Measured by differencing two bodies that differ by one field: 81 bytes constant +
len(name)+len(value), plus a 38-byte terminator, with a 32-character boundary. The formula predicted a150-field body at 53 646 bytes to the byte.
Because the cost is charged per field, the multiplier over JSON is a property of leaf count, not
of the data: 1.32× on a 50-record corpus and 5.77× on a small nested payload, from the same
encoder. A caller sizing a chunk has no way to know that 150 leaves is 12 150 bytes of frame before
a single character of their data.
Ask: a line in the body-encoding guide. The number is stable and useful.
6. Smaller, all measured
name,message,status,attempts,bodyand field set. Only the vendor's byte count differs, andthe caller never sees it.
200 records, where the exact serialized count delivered all 200 at the 11-request floor. And the
exact chunker went from 0 to 10 413s on a one-word
wire.bodychange.fetchAdapterdeclaressupports: ['stream', 'downloadProgress']and the source says fetch cannot report bytes sent.byteLengthappears 3 times inpackages/core/src, all response-side.Reproduction
Eight scripts run offline against a
node:httpvendor with a hard 16 384-byte cap whose ledgeraccumulates
chunk.lengthover rawBufferchunks —Content-Lengthis recorded and never scoredagainst. The corpus is a 200-entry literal byte-length table, re-checked before anything is sent.
Nothing depends on randomness or the clock.
Source references (verified against
origin/main)http-adapter.ts:320—export function encodeRequestBody(req: AdapterRequest): {index.ts:20-21—export { fetchAdapter } from './http-adapter';/export type { FetchAdapterOptions } …http-adapter.ts:327—if (typeof req.body === 'string') return { body: req.body };http-adapter.ts:328/:345/:354— theform,multipartand JSON armshttp-adapter.ts:152-155—supports: ['stream', 'downloadProgress'],and the "cannot report bytes SENT" commenthttp-adapter.ts:184/:195— the onlybyteLengthuses, both inreadWithProgressengine.ts:635—const retryMatch = acceptsStatus(cfg.retry?.on ?? [429, 502, 503, 504]);engine.ts:847—if (!outcome.ok && classifyStatus(res.status, cfg)) {engine.ts:897-898—// … it records a circuit SUCCESS — the transport is healthy, the payload is not.engine.ts:902/:904—} catch (e) {/const opened = await circuit.onFailure();resilience.ts:255-256—export class CircuitOpenError extends Error {/readonly status = 503;types.ts:146—export type BodyEncoding = 'json' | 'form' | 'multipart';types.ts:1832-1834—export class StitchError extends Error {/readonly status: number | undefined;serve.ts:66—export const MAX_REQUEST_BODY_BYTES = 2 * 1024 * 1024;serve.ts:95-98— theContent-Lengthpre-check ·:107-112— the mid-stream cut ·:251—sendJson(res, 413, { error: e.message });Found by a scenario pass that researches a real-world API integration problem, captures it, and proves or refutes each claim with runnable offline code. Every source reference above was read on
origin/main.