Scenario: axios-migration
Proofs: docs/scenarios/proofs/axios-migration/ (8 scripts, 151 checks, real axios 1.19.0)
axios-adapter.ts is 217 lines and was exercised by no test I could find — axiosAdapter( appears
in the whole docs/scenarios/proofs tree only inside a string literal, and there is no call site
anywhere in packages/. That is almost certainly why this shipped.
1. The documented snippet is rejected by TypeScript
axios-adapter.ts:1-7 opens with:
import axios from 'axios';
import { axiosAdapter, stitch } from 'stitchapi';
const getUser = stitch({ url: '…/users/{id}', adapter: axiosAdapter(axios) });
Against axios 1.19.0, tsc 5.9.3:
error TS2345: Argument of type 'AxiosStatic' is not assignable to parameter of type 'AxiosLike'.
Types of property 'request' are incompatible.
Types of parameters 'config' and 'config' are incompatible.
Type 'AxiosLikeConfig' is not assignable to type 'AxiosRequestConfig<unknown, any>'.
Types of property 'responseType' are incompatible.
Type 'string | undefined' is not assignable to type 'ResponseType | undefined'.
Not a strictness setting. Reproduced identically under strict: false and under
--strictFunctionTypes false. axios.create() and the two-argument defaults form fail the same
way; only a hand-written client compiles.
The root cause is one word. axios-adapter.ts:37:
is wider than axios's own ResponseType union, so AxiosLikeConfig is not assignable to
AxiosRequestConfig — and AxiosLike, documented at :31 as "structurally satisfied by
axios", is not.
The runtime is completely unaffected — every other measurement in this suite was taken through a
real axios instance at runtime. This is a types-only defect, which is the worst kind for adoption:
a developer pastes the documented line, gets a red squiggle in their editor, and concludes the
adapter is broken.
Ask: narrow the field to the values the adapter actually uses —
responseType?: 'arraybuffer' | 'json' | 'text' — or the full axios union. And add a type-level
regression: a .test-d.ts, or simply a call site somewhere inside packages/ that
pnpm -r check:types covers. With no call site in the repo, the public signature of this module is
currently unchecked.
2. AdapterResponse.url is never set on the axios path
Axios returns exactly { body, headers, status } where fetch returns four keys. Measured 12/12
shapes. Two consequences, both silent:
StitchError.url is always undefined on this transport.
- The download filename fallback that reads the response URL has nothing to read.
Ask: set it from the request URL (axios exposes response.config.url, and the adapter already
holds req.url). Following a redirect makes the two differ, but the request URL is strictly better
than undefined.
3. defaults accepts anything, including a typo
AxiosLikeConfig has an [key: string]: unknown index signature, so defaults gets no
compile-time check at all. Measured: maxRedirects: 0 really did stop a 302 being followed, while
the typo maxRedirect: 0 beside it was accepted in silence and did nothing.
Ask: the index signature is deliberate (it is how arbitrary axios options pass through), so this
is mostly a doc note — but flagging the known-misspelling class is cheap, and narrowing the declared
fields (see §1) would at least make the known options checked.
4. The cancellation reason does not survive the transport
abort(new Error('user navigated away')) reaches the caller as "user navigated away" on
fetchAdapter and as "canceled" on axiosAdapter — axios substitutes its own CanceledError.
Every other cancellation cell is identical across the two transports (socket closes, status
undefined, no cause, a pre-dispatch abort records 1 circuit failure on 0 requests). So the one
channel carrying the caller's intent is the one the transport swap rewrites.
Ask: the adapter could read err.config?.signal?.reason and rethrow with it. Related to
#705, which asks for the abort reason to be
preserved generally.
5. "Behavior is identical across transports" is true of the encoder and not of the transport
The header claim (axios-adapter.ts:8-10) holds where it is specific: json, form and multipart
bodies are byte-identical on the wire, and every response parses to the same type and digest.
Multipart's 45-byte delta is exactly 3 × (47−32) — the boundary token, three times.
But across 12 shapes × 25 fields there are 66 divergent cells, including §2 above and: a POST
with no body arrives as application/x-www-form-urlencoded on axios and with no content-type on
fetch. (Cross-origin redirect credential-stripping agrees exactly — the one place a disagreement
would be a vulnerability.)
Ask: narrow the sentence to what is true — the body encoding and response parsing are shared —
rather than "behavior", which reads as a transport-swap guarantee.
Not a library defect, but worth a doc line
The findings that dominate the scenario are interactions between a user's interceptors and the
engine's declarations, and every one of them is a case where both layers are individually correct:
- A reject-on-4xx interceptor has never run under plain axios (default
validateStatus rejects
first); axiosAdapter's validateStatus: () => true (:109) is what activates it. It then
destroys the status: verdict: { accept: [404] } stops working, and retry.on: [503] — which
sends 1 request on fetch — sends 3 on axios, because a throw is retried on attempt count alone.
- An instance retry interceptor (1+2) under
retry: { attempts: 3 } sends 9 requests; the engine
reports attempts: 3, the interceptor reports retries: 6, and no number equals 9.
- An auth interceptor runs after
auth.apply and overwrites it, while onRequest — and every trace
in the process — reports the token that lost. Off the collided header name the credential is
doubled rather than overwritten.
A short "migrating from axios" note saying audit your interceptors first, and pass a clone would
save real debugging. The assembled migration is 30 lines and the load-bearing part is two eject
calls.
Reproduction
npx tsx docs/scenarios/proofs/axios-migration/c6-refusals.ts
npx tsx docs/scenarios/proofs/axios-migration/c1-rejecting-interceptor.ts
Real axios 1.19.0 (a workspace devDependency added for these proofs), one real node:http vendor on
127.0.0.1 whose ledger records raw request bytes, every cancellation sequenced on the vendor's own
arrival/hang-up promises rather than a timer. C6 runs tsc out of band so the compile failure is
measured, not asserted from reading types.
Which engine
axios-adapter.ts, http-adapter.ts and index.ts are byte-identical to origin/main, so
every citation here is valid on both trees. engine.ts/types.ts/resilience.ts have drifted; the
proofs' README carries the line-number map.
Source references (verified against origin/main)
axios-adapter.ts:1-7 — the header snippet that does not compile
axios-adapter.ts:8-10 — the "behavior is identical across transports" claim
axios-adapter.ts:31 — "structurally satisfied by axios" · :37 — responseType?: string;
axios-adapter.ts:49-51 — AxiosLike · :44-48 — AxiosLikeResponse (no url, no statusText)
axios-adapter.ts:109 — validateStatus: () => true
axios-adapter.ts:64-70 — the streaming refusal (exact, pre-transport, 0 requests)
Found while writing scenario 54 ("the 200 call sites you cannot rewrite this quarter") for the docs. Every claim is backed by a runnable offline proof in docs/scenarios/proofs/axios-migration/.
Scenario: axios-migration
Proofs:
docs/scenarios/proofs/axios-migration/(8 scripts, 151 checks, real axios 1.19.0)axios-adapter.tsis 217 lines and was exercised by no test I could find —axiosAdapter(appearsin the whole
docs/scenarios/proofstree only inside a string literal, and there is no call siteanywhere in
packages/. That is almost certainly why this shipped.1. The documented snippet is rejected by TypeScript
axios-adapter.ts:1-7opens with:Against axios 1.19.0,
tsc5.9.3:Not a strictness setting. Reproduced identically under
strict: falseand under--strictFunctionTypes false.axios.create()and the two-argumentdefaultsform fail the sameway; only a hand-written client compiles.
The root cause is one word.
axios-adapter.ts:37:is wider than axios's own
ResponseTypeunion, soAxiosLikeConfigis not assignable toAxiosRequestConfig— andAxiosLike, documented at:31as "structurally satisfied byaxios", is not.The runtime is completely unaffected — every other measurement in this suite was taken through a
real
axiosinstance at runtime. This is a types-only defect, which is the worst kind for adoption:a developer pastes the documented line, gets a red squiggle in their editor, and concludes the
adapter is broken.
Ask: narrow the field to the values the adapter actually uses —
responseType?: 'arraybuffer' | 'json' | 'text'— or the full axios union. And add a type-levelregression: a
.test-d.ts, or simply a call site somewhere insidepackages/thatpnpm -r check:typescovers. With no call site in the repo, the public signature of this module iscurrently unchecked.
2.
AdapterResponse.urlis never set on the axios pathAxios returns exactly
{ body, headers, status }where fetch returns four keys. Measured 12/12shapes. Two consequences, both silent:
StitchError.urlis alwaysundefinedon this transport.Ask: set it from the request URL (axios exposes
response.config.url, and the adapter alreadyholds
req.url). Following a redirect makes the two differ, but the request URL is strictly betterthan
undefined.3.
defaultsaccepts anything, including a typoAxiosLikeConfighas an[key: string]: unknownindex signature, sodefaultsgets nocompile-time check at all. Measured:
maxRedirects: 0really did stop a 302 being followed, whilethe typo
maxRedirect: 0beside it was accepted in silence and did nothing.Ask: the index signature is deliberate (it is how arbitrary axios options pass through), so this
is mostly a doc note — but flagging the known-misspelling class is cheap, and narrowing the declared
fields (see §1) would at least make the known options checked.
4. The cancellation reason does not survive the transport
abort(new Error('user navigated away'))reaches the caller as"user navigated away"onfetchAdapterand as"canceled"onaxiosAdapter— axios substitutes its ownCanceledError.Every other cancellation cell is identical across the two transports (socket closes,
statusundefined, no
cause, a pre-dispatch abort records 1 circuit failure on 0 requests). So the onechannel carrying the caller's intent is the one the transport swap rewrites.
Ask: the adapter could read
err.config?.signal?.reasonand rethrow with it. Related to#705, which asks for the abort reason to be
preserved generally.
5. "Behavior is identical across transports" is true of the encoder and not of the transport
The header claim (
axios-adapter.ts:8-10) holds where it is specific: json, form and multipartbodies are byte-identical on the wire, and every response parses to the same type and digest.
Multipart's 45-byte delta is exactly
3 × (47−32)— the boundary token, three times.But across 12 shapes × 25 fields there are 66 divergent cells, including §2 above and: a POST
with no body arrives as
application/x-www-form-urlencodedon axios and with no content-type onfetch. (Cross-origin redirect credential-stripping agrees exactly — the one place a disagreement
would be a vulnerability.)
Ask: narrow the sentence to what is true — the body encoding and response parsing are shared —
rather than "behavior", which reads as a transport-swap guarantee.
Not a library defect, but worth a doc line
The findings that dominate the scenario are interactions between a user's interceptors and the
engine's declarations, and every one of them is a case where both layers are individually correct:
validateStatusrejectsfirst);
axiosAdapter'svalidateStatus: () => true(:109) is what activates it. It thendestroys the status:
verdict: { accept: [404] }stops working, andretry.on: [503]— whichsends 1 request on fetch — sends 3 on axios, because a throw is retried on attempt count alone.
retry: { attempts: 3 }sends 9 requests; the enginereports
attempts: 3, the interceptor reportsretries: 6, and no number equals 9.auth.applyand overwrites it, whileonRequest— and every tracein the process — reports the token that lost. Off the collided header name the credential is
doubled rather than overwritten.
A short "migrating from axios" note saying audit your interceptors first, and pass a clone would
save real debugging. The assembled migration is 30 lines and the load-bearing part is two
ejectcalls.
Reproduction
Real axios 1.19.0 (a workspace devDependency added for these proofs), one real
node:httpvendor on127.0.0.1 whose ledger records raw request bytes, every cancellation sequenced on the vendor's own
arrival/hang-up promises rather than a timer. C6 runs
tscout of band so the compile failure ismeasured, not asserted from reading types.
Which engine
axios-adapter.ts,http-adapter.tsandindex.tsare byte-identical toorigin/main, soevery citation here is valid on both trees.
engine.ts/types.ts/resilience.tshave drifted; theproofs' README carries the line-number map.
Source references (verified against
origin/main)axios-adapter.ts:1-7— the header snippet that does not compileaxios-adapter.ts:8-10— the "behavior is identical across transports" claimaxios-adapter.ts:31— "structurally satisfied byaxios" ·:37—responseType?: string;axios-adapter.ts:49-51—AxiosLike·:44-48—AxiosLikeResponse(nourl, nostatusText)axios-adapter.ts:109—validateStatus: () => trueaxios-adapter.ts:64-70— the streaming refusal (exact, pre-transport, 0 requests)Found while writing scenario 54 ("the 200 call sites you cannot rewrite this quarter") for the docs. Every claim is backed by a runnable offline proof in
docs/scenarios/proofs/axios-migration/.