Skip to content

Declaring wire.response: 'json' moves the failure into the transport catch, so one WAF challenge becomes three requests #697

Description

@rejifald

Scenario: not-json
Proofs: docs/scenarios/proofs/not-json/ (8 scripts, 274 checks, real node:http)

No disclosure content — correctness, retry-classification and observability findings. One item
(§5) is a privacy asymmetry that currently favours the failure path; it is reported because the
protection exists and works, and the success path bypasses it.

First, what works

  • hooks.onResponse is exactly the right seam and behaves correctly: it sees headers and body,
    its rejection is not retried (1 request under retry: { attempts: 3 }, versus 3 for a
    wrapping adapter or fetchAdapter({ fetch })), and it can mutate ctx.res.body — which is how a
    correct JSON payload mislabelled text/html is recovered rather than rejected.
  • The non-enumerable error channel does real work. engine.ts:370-378 pins the failing response
    where "the body never serialises into a trace sink." Measured: on the failure path a sink sees
    neither <!DOCTYPE nor the maintenance text.
  • The whole arrangement is 25 executable lines and holds four properties at once: six not-JSON
    responses detected before the caller, 200 characters of the page on StitchError.body, zero
    characters in the sink, 1 request per call, and legitimate non-JSON endpoints untouched.

1. Declaring the response type makes the retry worse, not better

http-adapter.ts:129-138 auto-detects: application/json/+json parses, everything else becomes
response.text(). So an HTML challenge at 200 is a value. The natural corrective —
wire: { response: 'json' } — forces JSON.parse, and the resulting SyntaxError is thrown by the
adapter, which lands in the transport catch (engine.ts:691-704) that retries on
attempt < max regardless of retry.on.

Measured against a real challenge endpoint, counting requests server-side:

arrangement requests the vendor counted
200 challenge, retry: { attempts: 3 } 1
200 challenge, wire.response: 'json', same retry 3

With circuit: { failures: 2 } the second arrangement also opens the breaker after 2, where the
first never opens it across 4 calls. So the config change that makes the failure visible also
makes the client hammer the thing that is watching for automated behaviour.

Ask: this is the third scenario in the pass to hit "a transport throw is retried on attempt
count alone" (already filed as #696 and #688). This one is the sharpest argument for it: a
decode failure is deterministic, and retrying it cannot help.

2. When the parse throws, 1.1 % of the page survives and no seam holds the rest

.body, .url, .status and .cause are all undefined. The only trace of the document is
the fragment V8 quotes into the message — Unexpected token '<', "<!DOCTYPE "... is not valid JSON
10 of 952 characters. The adapter raised a real SyntaxError with that identical message and
the class identity is not preserved as .cause.

On that path hooks.onResponse never fires, and hooks.onError's context is exactly
{ attempt, error, name } — no res. So nothing can retain the page, which is the one artefact
that says what actually happened (its <title> reads Just a moment...).

By contrast, at 403 or 503 the whole page is on StitchError.body with .url.

Ask: attach the response to a decode failure the way a status failure already does — the
adapter has it in scope.

3. output's diagnosis is identical for three different causes

An output contract does run (a 200 passes interpret) and does reject the page. Its entire
diagnosis is one error/invalid finding at the root path, Expected object, received string
byte-identical for a WAF challenge, a login form and a mislabelled payload. The words html,
page, challenge, login, maintenance and < appear 0 times across findings, message and
status.

drift() adds nothing: validateOutput short-circuits on hard errors before classifyDiff
(engine.ts:466-467), so soft drift never sees a diff. Control: on healthy data the same drift()
fires info/undeclared, so the silence is the mechanism, not a misconfiguration.

Also worth knowing: output: z.string() validates the challenge page.

Ask: not a bug — but a line in the drift guide that a root-level invalid on a string almost
always means "this response is a different document", since that is the common cause and the
message does not say it.

4. A Content-Type check is the worst of the three obvious rules

Scored over ten real responses (6 not-JSON arms, a healthy control, a CSV export, a text/plain
health check, a 204):

rule wrong verdicts / 10 false positives
content-type is JSON 6 4
first-byte sniff < 1 0
vendor marker cf-mitigated 4 0
first-byte + empty-body 0 0

The content-type rule's false positives are all working endpoints: the CSV export, the health check,
the 204, and a correctly-shaped payload served under text/html. Worth stating somewhere, because
it is the rule everyone reaches for first.

5. The trace protection is exactly backwards

On the failure path the error event carries no body — by design, and it works. On the
success path the result event hands a TraceSink the entire 952-character vendor page,
because a challenge is a successful call with a string value.

So the privacy control is present on the path where the body is a known error and absent on the path
where the body is an unknown document from an intermediary.

Ask: consider bounding, or making configurable, what result.data hands a sink — or at minimum
note in the trace guide that result is unbounded where error is not.

6. A seam-level guard cannot be opted out of by a member

Hooks chain child→base (stitch.ts:133-137) — both run — so a legitimately non-JSON member of a
guarded seam inherits the guard and breaks. Measured: the CSV export and the text/plain health
check both fail. The cheapest escape is 3 executable lines that pre-wrap the body, and it changes
the member's result type, so the caller has to unwrap.

Ask: a documented way for a member to replace rather than extend an inherited hook, or a note in
the seam guide that hooks are additive and cannot be removed downstream.

Reproduction

npx tsx docs/scenarios/proofs/not-json/c3-retry-and-the-waf.ts

Eight scripts run offline against a real node:http server that serves a faithful reconstruction of
an interstitial challenge (title, ray id, noscript block, _cf_chl_opt script, plus
cf-mitigated/cf-ray/Server headers), a maintenance page, a real 302 to a login form, both
content-type mislabels, an empty 200, a 204, a CSV export and a text/plain health check. Request
counts are read from the server's ledger.

Source references (verified against origin/main)

  • http-adapter.ts:129-138const isJson = responseType === 'json' || contentType.includes('application/json') || contentType.includes('+json'); if (isJson) { … parsed = text === '' ? undefined : JSON.parse(text); } else { parsed = await response.text(); }
  • engine.ts:633 / :635const max = cfg.retry?.attempts ?? 1; / const retryMatch = acceptsStatus(cfg.retry?.on ?? [429, 502, 503, 504]);
  • engine.ts:691-704 — the transport try/catch; if (attempt < max) { with no error classification
  • engine.ts:370-378Object.defineProperty(evt, ERROR_SOURCE, { value: err, enumerable: false }); and its comment
  • engine.ts:466-467const { value: validated, errors } = await validateValue(cfg, raw); if (errors.length) return { value: raw, findings: errors };
  • engine.ts:728await cfg.hooks?.onResponse?.({ name: nameOf(cfg), attempt, res });
  • engine.ts:847-854 — the status-failure path that pins e.response = res
  • stitch.ts:133-137// onResponse/onError/onRetry unwind child→base / const ordered = k === 'onRequest' ? fns : fns.slice().reverse();
  • stitch.ts:547-555rebuildError populating body/url from the pinned response
  • surface.ts:234-237httpInterpret = (res, cfg) => verdictOf(res, cfg) ?? { ok: true, data: res.body };
  • types.ts:1435-1447HookContext / Hooks
  • drift.ts:50-57validationErrors, mapping each issue to level: 'error', change: 'invalid'

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