# How to reduce a failing SDK call to a minimal reproduction

> Record the request an SDK sent, replay and shrink it with a fresh token, place the fault with a second client, and hand over a script that checks it still fails.

Source: https://voxgig.com/howto/minimal-reproduction-of-a-failing-sdk-call

- Audience: api-consumer
- Level: advanced
- Verified: 2026-10-15
- Published: 2026-10-15

## 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](https://developer.mozilla.org/en-US/docs/Web/API/Window/fetch) | 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](https://docs.usebruno.com/bru-lang/overview) | 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](https://curl.se/docs/manpage.html) | 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](https://httpie.io/docs/cli/request-items) | 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](https://docs.mitmproxy.org/stable/overview/features/) | 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.

```js title="record.mjs"
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:

```bash
node app.mjs
```

```text output
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.

```js title="bisect.mjs"
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.

```bash
node bisect.mjs
```

```text output
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.

```bash
node versions.mjs
```

```text output
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.

```bash
node to-curl.mjs minimal.json > repro.sh && node with-api.mjs bash repro.sh && node with-api.mjs bash repro.sh
```

```text output
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](https://pubs.opengroup.org/onlinepubs/9799919799/utilities/head.html)
asks `head` for a positive count, and the
[`head` on macOS](https://github.com/apple-oss-distributions/text_cmds/blob/main/head/head.c)
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:

```bash
node --test repro.test.mjs | tail -8
```

```text output
# 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:

```bash
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`](https://docs.mitmproxy.org/stable/concepts/commands/)
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:

```bash
node stale.mjs
```

```text output
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

- [Tag SDK calls with session and request ids](/howto/tag-sdk-calls-with-session-and-request-ids)

- [Wrap an SDK transport in a middleware chain](/howto/wrap-an-sdk-transport-with-middleware)

- [Diagnose a 401 or a 403 from a credential](/howto/diagnose-401-and-403-from-a-credential)

## 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.

[^1]: curl's `--write-out` variables are written inside braces, as in `%{http_code}`, and the
[manual](https://curl.se/docs/manpage.html) 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.

[^2]: mitmproxy's own [client playback tutorial](https://docs.mitmproxy.org/stable/tutorials/client-replay/)
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 `mitmdump` commands, one to
record and one to replay.

[^3]: The [everything curl](https://everything.curl.dev/cmdline/copyas.html) 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.