Skip to content

encodeRequestBody is exported from its module and not from the package, so a request's size is knowable only inside fetchAdapter({ fetch }) — and a 413 opens the circuit #702

Description

@rejifald

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 defaultretry.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:320export function encodeRequestBody(req: AdapterRequest): {
  • index.ts:20-21export { fetchAdapter } from './http-adapter'; / export type { FetchAdapterOptions } …
  • http-adapter.ts:327if (typeof req.body === 'string') return { body: req.body };
  • http-adapter.ts:328 / :345 / :354 — the form, multipart and JSON arms
  • http-adapter.ts:152-155supports: ['stream', 'downloadProgress'], and the "cannot report bytes SENT" comment
  • http-adapter.ts:184 / :195 — the only byteLength uses, both in readWithProgress
  • engine.ts:635const retryMatch = acceptsStatus(cfg.retry?.on ?? [429, 502, 503, 504]);
  • engine.ts:847if (!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-256export class CircuitOpenError extends Error { / readonly status = 503;
  • types.ts:146export type BodyEncoding = 'json' | 'form' | 'multipart';
  • types.ts:1832-1834export class StitchError extends Error { / readonly status: number | undefined;
  • serve.ts:66export const MAX_REQUEST_BODY_BYTES = 2 * 1024 * 1024;
  • serve.ts:95-98 — the Content-Length pre-check · :107-112 — the mid-stream cut · :251sendJson(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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions