A charge fails through the vendor’s SDK with a 400 that names a header your code never set. The SDK maintainers ask for a reproduction without your code in it. The API vendor asks for one without the SDK. Your only evidence is a stack trace and a request id from a token that expires within the hour.
What you get
You will end up with a recorded request, the smallest request that fails the same way, and a curl script that mints its own token and checks the failure. This is for you if you must say whether a bug belongs to the SDK, your code or the API before anyone will look at it.
Short answer
Record the failing request through the SDK’s fetch hook, then replay it with a fresh token to show the API answers the same way without the SDK. Remove headers and body fields one at a time while the status and error code stay the same. Compare SDK versions and a second client to place the fault, and ship a script that mints its own token and checks the failure.
You will need
Node 22 or later, curl 7.84 or later, the first release with %header{} in --write-out, and
credentials that can mint a token. Verified 2026-10-15 against Node 22.22.0 and curl 8.5.0. The
vendor here is a stand-in: acme-api.mjs runs a payments API locally, and sdk/ holds two
versions of a small SDK for it, so every command runs offline. If the request starts life in a
browser or a HAR file instead, turning a HAR file into a curl command is the earlier how-to in
this series.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| A second SDK for the same API | You need to know fast whether the SDK is at fault | An extra dependency, or a hand-written client you have to get right | The second client shares code with the first |
| Bruno | A team keeps shared requests in the repository as files | The maintainer needs Bruno or its CLI to run the request, where curl is already there | The repro must run unattended in CI or on another developer’s machine |
| curl replay | Anything you will attach to an issue | Quoting, token handling and the failure check are yours to write | The request needs a signature curl cannot compute |
| HTTPie | Reading and editing a request by hand while you bisect | Another tool the maintainer must install before the repro runs at all | You are writing the final script |
| mitmproxy replay.client | Replaying the exact bytes, unchanged, from a recorded flow | A proxy and a CA certificate in the client’s path, and a token frozen in the flow | You need to change one header at a time |
curl needs nothing the maintainer has to install first, at the price of writing the token step and the check yourself. mitmproxy holds every byte constant, which is its value and its limit: the token is part of the bytes. A second client answers “is it the SDK” in one run, provided it really is independent.
Record what the SDK sent
Most SDKs accept a custom fetch. Pass one that records each exchange and redacts credentials
before anything is written, because the recording is going on an issue. Some SDKs call it with a
Request object rather than a URL, and building one Request from another uses up its body, so
the hook clones that object before it reads anything. Without the clone, the SDK’s own call
fails inside the hook.
export function recordingFetch(log, inner = globalThis.fetch) {
return async (input, init = {}) => {
// A string or form body is recorded as sent. A stream can be read once, by
// fetch, so it is noted and left alone. A Request passed as input is
// cloned first, because building one Request from another uses up its
// body, and its body is read from the clone while fetch sends the original.
const textual = init.body == null || typeof init.body === 'string' || init.body instanceof URLSearchParams
const source = input instanceof Request ? input.clone() : input
const request = new Request(source, textual ? init : { ...init, body: undefined })
const fromRequest = init.body == null && request.body ? request.text().catch(() => '<body not recorded>') : null
const response = await inner(input, init)
const requestBody = fromRequest
? await fromRequest
: init.body == null ? null : textual ? String(init.body) : `<${init.body.constructor?.name ?? 'body'} not recorded>`
const responseBody = await response.clone().text()
log.push({
method: request.method,
url: request.url,
requestHeaders: redactHeaders(request.headers),
requestBody: redactText(requestBody, request.headers.get('content-type')),
status: response.status,
responseHeaders: redactHeaders(response.headers),
responseBody: redactText(responseBody, response.headers.get('content-type')),
})
return response
}
}
Run your code once with it:
node app.mjs
charge failed: 400 idempotency_key_invalid: Idempotency-Key must be at most 64 characters (request-id req_0002)
recorded 2 exchanges to captured.json
POST /oauth/token -> 200, request-id req_0001
POST /v1/charges -> 400, request-id req_0002
captured.json now holds both exchanges, headers included, with the token, the client secret, the
Authorization header, and any cookie in either direction replaced by <redacted>. Keep the
response headers. The request-id is the one thing the vendor can look up on their side.
Replay it and shrink it
Replay the recorded request with a token minted now, not the recorded one, and treat “the same failure” as the same status and error code. The message and the request id change on every call. Then remove one header at a time, then the body, and keep each removal only while the failure stays the same.
export async function shrink(base, request, target, token, log = () => {}) {
const fails = async (candidate) => sameFailure(await send(base, candidate, token), target)
if (!(await fails(request))) return null
let current = request
for (const name of Object.keys(current.headers)) {
const { [name]: _, ...rest } = current.headers
const candidate = { ...current, headers: rest }
if (await fails(candidate)) {
current = candidate
log(`drop header ${name}: still fails`)
} else {
log(`keep header ${name}: needed`)
}
}
The rest of the function shrinks the body field by field, then replaces each surviving header value with filler and searches for the shortest length that still fails.
node bisect.mjs
target: 400 idempotency_key_invalid, first seen as req_0002 with acme-node/2.3.0
drop header accept: still fails
drop header acme-version: still fails
drop header content-type: still fails
keep header idempotency-key: needed
drop header user-agent: still fails
drop the body: still fails
header idempotency-key: fails at 65 characters, not at 64
minimal: POST /v1/charges, headers idempotency-key, body none
The minimal request has one header of 65 characters and no body, so the failure has nothing to do with the charge.
Settle whose bug it is
Pin each SDK version in turn, then send the same charge through a hand-written fetch that
shares no code with the SDK.
node versions.mjs
acme-node 2.2.0 201 succeeded idempotency-key 36 characters
acme-node 2.3.0 400 idempotency_key_invalid idempotency-key 83 characters
hand-written fetch 201 succeeded idempotency-key 36 characters
The charge is the same in all three rows, and the API accepts it from two of the clients. The fault belongs to SDK 2.3.0, which mints 83-character keys against a documented limit of 64. Pin 2.2.0 and open the issue against the SDK.
Check it worked
Generate the script, then run it twice, each time against a freshly started API, which is as close as this directory gets to a clean machine. It mints its own token and exits 0 only if the failure reproduces.
node to-curl.mjs minimal.json > repro.sh && node with-api.mjs bash repro.sh && node with-api.mjs bash repro.sh
status 400, code idempotency_key_invalid, request-id req_0002
reproduced: 400 idempotency_key_invalid
status 400, code idempotency_key_invalid, request-id req_0002
reproduced: 400 idempotency_key_invalid
The script’s header comment carries the evidence: the SDK version, the API version header it
sent, and the original request id. It reads the request id back with curl’s -w and
%header{request-id}.1 It drops that last line with sed '$d' rather than GNU’s
head -n -1, because POSIX
asks head for a positive count, and the
head on macOS
refuses anything else.
The sample’s tests cover the recording hook, the shrink, and the script, including a run under a
head that refuses a negative count:
node --test repro.test.mjs | tail -8
# tests 10
# suites 0
# pass 10
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 990.66437
Hold everything else constant with mitmproxy
To rule out the client entirely, record the traffic in a proxy and replay it byte for byte. Run here against mitmproxy 12.2.3 in reverse mode in front of the local API:
mitmdump --mode reverse:API_BASE -p 8090 -w flows.mitm
mitmdump -n -C flows.mitm
The replay sent every recorded request again, and the charge answered 400 again. In the
console the same replay is the replay.client
command, run on a focused flow.2 HTTPie 3.2.4, also run here, sent the minimal request just as
well, which makes it a fine tool for the bisecting and a poor format for the issue.
When it goes wrong
The repro passes the next day. A token from an earlier session fails differently, so the script that embeds one stops reproducing:
node stale.mjs
the token from yesterday 401 token_invalid: a different failure
a token minted now 400 idempotency_key_invalid: the same failure
Mint the token inside the script, and compare on status and code. A rate-limited key, a deleted resource and an expired token all change the answer while the bug is still there.
A header disappears in the shrink and the failure changes code. That header was needed, and the
bisect keeps it. Read the keep lines before deciding the minimal request is wrong.
A browser’s copied command line misbehaves on another machine. Re-create it from the recorded headers instead of trusting the quoting.3
When not to do this
Do not shrink a request that writes real data in production. Each replay of a failing charge is harmless only until the shrink removes the cause and the next attempt succeeds. Bisect against a sandbox or a test account.
Do not attach a recording you have not read. Redaction covers only the names in record.mjs, so
a secret that a vendor puts in a custom header goes into the file.
Do not stop at the SDK version pin. Pinning 2.2.0 unblocks you but leaves the bug in place for everyone else, so open the issue with the script.
Do not use mitmproxy replay for the issue itself. The flow carries your token and your certificate setup, neither of which the maintainer has.
Related how-tos
Last verified
Verified 2026-10-15 against Node 22.22.0 and curl 8.5.0. Every output block is what the command before it printed. mitmproxy 12.2.3 and HTTPie 3.2.4 ran in this container against the same local API, and what they did is described in prose rather than shown as captured output.
Footnotes
-
curl’s
--write-outvariables are written inside braces, as in%{http_code}, and the manual notes one exception. For%header{name}, added in 7.84.0, the braces hold the header’s name, and the word “header” stays outside them. ↩︎ Back to text -
mitmproxy’s own client playback tutorial is set in a cafe whose wireless network, it reports, is generously sponsored with ratepayers’ money by the city council. Its login page is automated with two
mitmdumpcommands, one to record and one to replay. ↩︎ Back to text -
The everything curl book walks through “Copy as cURL” in Firefox, Chrome, Edge and Safari. It adds that Chromium browsers on Windows may generate an incorrect command line, misquoted because of a bug in Chromium. ↩︎ Back to text