# How to audit SDK retry and timeout defaults before adoption

> Point a candidate client at a local stub that fails and hangs, then read its real retry count and tail latency off the stub instead of trusting its README.

Source: https://voxgig.com/howto/audit-sdk-retry-and-timeout-defaults

- Audience: api-consumer
- Level: intermediate
- Languages: typescript
- Verified: 2026-09-25
- Published: 2026-09-25

## Short answer

Start a local HTTP server that answers 500 to everything, point the client at it with its defaults, and count the requests that arrive. Then make the server hang, give the client the timeout you would set in production, and time the whole call. The count is the retry policy and the time is the tail latency, whatever the README says.

---
## You will need

Node 22 or later, and the candidate clients installed from npm. Verified 2026-09-25 against Node
22.22.2, stripe 22.6.2, `@octokit/core` 7.0.8, `@octokit/plugin-retry` 8.1.1, and p-retry 8.0.1.
Nothing on this page contacts Stripe or GitHub. Every client is pointed at a server on
127.0.0.1 that the audit starts and stops itself.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [`@octokit/plugin-retry`](https://octokit.github.io/rest.js/v22#automatic-retries) | You call GitHub through Octokit and want retries on 5xx without writing them | Waits of 1, 4, and 9 seconds you cannot shorten without changing the plugin's base value, and no timeout unless you supply one | You need a total deadline, or the call has to fail fast |
| [p-retry around a bare client](https://github.com/sindresorhus/p-retry#readme) | Any client, any error, and you want the policy in your own code | Ten retries and seventeen minutes of backoff by default, and every decision about status codes is yours to write | The SDK already retries, so wrapping it multiplies the attempts |
| [stripe-node maxNetworkRetries](https://github.com/stripe/stripe-node#network-retries) | You call Stripe and want retries that reuse the idempotency key | A default of two retries where the README says one, and an 80 second timeout on each attempt | Your caller gives up in under a second, so a retried timeout is wasted work |

The three differ less in whether they retry than in how long they are prepared to keep going.
stripe-node stops after three requests and about a second. plugin-retry stops after four requests
and fourteen seconds, and offers no timeout of its own. p-retry keeps going for ten retries and
seventeen minutes, because it knows nothing about HTTP and inherits its schedule from a library
written for other purposes.[^1]

## Point every client at a stub that fails on purpose

A client's retry behavior is a fact about what it sends, so measure it where the requests
arrive. The stub records every request before deciding what to do with it.

```ts title="stub.mjs"
export async function startStub(behavior = 'fail') {
  const state = { requests: [] }
  const server = createServer((req, res) => {
    state.requests.push({ method: req.method, path: req.url.split('?')[0], at: Date.now() })
    if (behavior === 'hang') return
    const status = behavior === 'fail' ? 500 : 200
    res.writeHead(status, { 'content-type': 'application/json' })
    // The 500 body is shaped like a Stripe error so stripe-node classifies it as an API error.
    res.end(JSON.stringify(status === 500
      ? { error: { type: 'api_error', message: 'simulated outage' } }
      : { id: 'cus_1', object: 'customer' }))
  })
```

`hang` accepts the connection and never answers. That is the failure a timeout exists for, and it
is the one a README never describes, because the answer depends on the timeout you set.

Each client is pointed at the stub with only the options its documentation gives for doing so.
What is not named is the library's default, which is the thing under audit.

```ts title="clients.mjs"
export function stripeClient(stub, options = {}) {
  return new Stripe('sk_test_local', {
    host: '127.0.0.1',
    port: stub.port,
    protocol: 'http',
    telemetry: false,
    ...options,
  })
}

export function octokitClient(stub, options = {}) {
  return new RetryingOctokit({ baseUrl: stub.url, ...options })
}

// A bare fetch call with its own per-attempt timeout, wrapped in p-retry. The signal is
// created inside the function so every attempt gets a fresh one. A failed attempt cancels
// its body before throwing: an unread body holds its socket open until garbage collection.
export function retriedFetch(stub, { perAttemptMs, ...retryOptions } = {}) {
  return pRetry(async () => {
    const res = await fetch(`${stub.url}/v1/customers/cus_1`, {
      signal: perAttemptMs ? AbortSignal.timeout(perAttemptMs) : undefined,
    })
    if (!res.ok) {
      await res.body?.cancel()
      throw new Error(`upstream answered ${res.status}`)
    }
    return res.json()
  }, retryOptions)
}
```

## Count what arrives, and time the whole call

One function runs a client against a fresh stub and reports two numbers: how many requests the
stub saw, and how many whole seconds passed before the client gave up.

```ts title="measure.mjs"
export async function measure(behavior, run) {
  const stub = await startStub(behavior)
  const started = Date.now()
  let outcome = 'resolved'
  let error
  try {
    await run(stub)
  } catch (e) {
    error = e
    outcome = e.type || e.name
  }
  // A timer can fire a millisecond or so short of its delay as Date.now() counts it, so a
  // margin keeps an abort at exactly four seconds from reading as three.
  const seconds = Math.floor((Date.now() - started + 50) / 1000)
  await stub.close()
  return { requests: stub.state.requests.length, seconds, outcome, error }
}
```

Rounding down is deliberate. Overhead adds to the elapsed time, so the whole second below the
schedule is the same on every machine, and the audit can be re-run as a test. The 50 ms margin
covers the one thing that runs early. Node schedules timers in whole milliseconds on a different
clock from `Date.now()`, so a timer can fire a millisecond or so short of its delay by this
measure. Without the margin, the p-retry row in the next block sometimes reads three seconds
instead of four.

Run the audit against the failing stub.

```bash
node audit.mjs
```

```text output
read from stripe-node at run time
  timeout 80000 ms, maxNetworkRetries 2

upstream answers 500 to every request, library defaults
  stripe-node                                    requests 3   elapsed 1s    StripeAPIError
  Octokit + plugin-retry                         requests 4   elapsed 14s   HttpError, retryCount 3
  p-retry around fetch                           requests 3   elapsed 4s    TimeoutError from the audit’s own 4s signal
    p-retry reported 10 retries left, delays 1000 2000 4000 ms
```

Three things in that block disagree with the documentation you would have read instead.

The stripe-node README lists the default for `maxNetworkRetries` as 1 and says the client makes
one reattempt. The client reports 2 when asked, and the stub received three requests.[^2] The
README is out of date and the code is not, which is the normal direction for that kind of drift.

plugin-retry's README says a request is retried up to three times on a 500 and says nothing about
how long that takes. The answer is fourteen seconds, because the waits are 1, 4, and 9 seconds:
the square of the attempt number.[^3] A call that was going to fail takes a quarter of a minute
to say so.

p-retry defaults to ten retries starting at one second and doubling, which the
[option list](https://github.com/sindresorhus/p-retry#readme) states plainly. The sum is 1023
seconds of sleeping. The audit stops it after four seconds with its own signal, so the three
requests and the reported delays are what the schedule looks like from the outside.

## Multiply the timeout by the attempts

A per-attempt timeout inside a retry loop is not a deadline. The hanging stub shows what each
client does with a timeout of one second, set the way its documentation says to set one.

```bash
node audit.mjs
```

```text output
upstream never answers, each client given a 1000 ms timeout
  stripe-node, timeout: 1000                     requests 3   elapsed 4s    StripeConnectionError
  Octokit, request.signal created once           requests 1   elapsed 15s   HttpError, retryCount 3
  Octokit, request.fetch with a fresh signal     requests 4   elapsed 18s   HttpError, retryCount 3
  p-retry, retries: 3, fresh signal per attempt  requests 4   elapsed 11s   TimeoutError
```

stripe-node treats a timeout as a connection error and retries it, so a one second `timeout` is
three timeouts plus two backoffs, about four seconds. With the default 80 second timeout the same
call takes over four minutes to fail. The arithmetic is attempts times timeout plus the sum of the
waits, and the README gives you only the middle term.

Octokit has no timeout option. The documented way to cancel is
[`request.signal`](https://github.com/octokit/request.js#readme), and a signal from
[`AbortSignal.timeout`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/timeout_static)
fires once. The second row shows the consequence. plugin-retry made four attempts, and three of
them failed on a signal that had already fired, so one request reached the upstream. The caller
still waited fifteen seconds. The third row fixes that by passing a `request.fetch` that wraps
every attempt in a fresh signal, and the fix costs four timeouts plus fourteen seconds of
waiting. Without any signal, undici waits five minutes for headers on each
attempt.[^4]

p-retry with three retries turns a one second timeout into eleven seconds. The default ten retries
would make it 1034. The brief version of this page is that last row: a stated timeout of ten
seconds with three retries is forty seconds of timeouts before the backoff is added.

## Check it worked

The same measurements as a test suite, asserting on the request counts exactly and on the elapsed
times as lower bounds. An upper bound would make the suite depend on how busy the machine is.

```ts title="audit.test.mjs"
  test('a signal created once is already aborted when plugin-retry tries again', async () => {
    const r = await measure('hang', (stub) =>
      octokitClient(stub).request('GET /users/octocat', { request: { signal: AbortSignal.timeout(1000) } }))
    assert.equal(r.error.request.request.retryCount, 3, 'four attempts were made')
    assert.equal(r.requests, 1, 'only the first one reached the upstream')
  })
```

```bash
node --test audit.test.mjs
```

```text output
1..1
# tests 8
# suites 1
# pass 8
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 18328.014806
```

Eight tests in eighteen seconds, because every scenario gets its own stub and they run at the
same time. Keep the suite beside the dependency and run it on every upgrade. Of everything in
your build, only this suite would have reported the stripe-node default moving from one retry to
two in a major release.

## When it goes wrong

The stub sees one request and the client reports three retries. The signal was created once, so
every retry after the first found it already aborted and failed without connecting. Create the
signal inside the function that makes the attempt, or pass a `fetch` that does.

You set `maxNetworkRetries` to 0, but the stub still counts two requests. stripe-node retries a
connection that closed with `ECONNRESET` or `EPIPE` once regardless, on the grounds that the
request usually never arrived. Its README says so in a note under the retry option. Decide
whether your write is safe to send twice before relying on zero.

The audit passes, but production still shows more requests than the client made. Another layer is
retrying: a proxy, a job queue that re-runs the whole task, or a wrapper somebody added around the
SDK. Measure at the boundary you deploy, not at the client in isolation.

## When not to do this

Do not audit against the vendor's sandbox. A sandbox answers 200, so it measures nothing about
failure, and a sandbox you make fail by sending bad requests measures the 4xx path, which no client
retries. The stub answers the question the sandbox cannot.

Do not take the count from the client's own logs or its `retryCount` property. Octokit reported
three retries while one request left the process. The stub is the only place that counts what
actually went over the wire.

Do not wrap a client that already retries in p-retry. Three attempts inside three attempts is
nine requests for one call, and the vendor's rate limit counts all nine.

Do not treat any per-attempt timeout as the caller's deadline. Those numbers are the floor, not
the ceiling, and a deadline is a separate mechanism that has to sit outside the retry loop.

Do not run the audit once and file the result. Defaults move between major versions, and the
package that moved its retry count also doubled its maximum backoff in the same release.

## Related how-tos

- [Judge whether an unofficial SDK is safe to depend on](/howto/judge-an-unofficial-sdk-before-adoption)

- [Enforce one total deadline across all retry attempts](/howto/enforce-a-total-deadline-across-retry-attempts)

- [Time out fetch calls with AbortSignal in Node.js](/howto/time-out-fetch-with-abortsignal-in-node)

- [Retry fetch calls with exponential backoff in Node.js](/howto/retry-fetch-with-backoff-in-node)

## Last verified

Verified 2026-09-25 against Node 22.22.2, stripe 22.6.2, `@octokit/core` 7.0.8,
`@octokit/plugin-retry` 8.1.1, and p-retry 8.0.1. Every output block is what the command preceding
it printed. Every client was measured against a local server on 127.0.0.1, never against Stripe
or GitHub.

[^1]: p-retry's ten retries, one second and factor of two are the defaults of
[node-retry](https://github.com/tim-kos/node-retry#readme). Its README documents `retries` as 10
and adds that setting it to 1 means do it once, then retry it once. p-retry depended on that
package through every release up to 6.2.1 and dropped it in
[7.0.0](https://github.com/sindresorhus/p-retry/releases/tag/v7.0.0), keeping the numbers. A
default that sums to seventeen minutes has outlived the code that chose it.

[^2]: The option table in the
[README](https://github.com/stripe/stripe-node#initialize-with-config-object) lists
`maxNetworkRetries` as 1, and the [retry section](https://github.com/stripe/stripe-node#network-retries)
says one reattempt. The changelog for [17.0.0](https://github.com/stripe/stripe-node/releases/tag/v17.0.0),
dated October 1, 2024, records the defaults moving from one retry to two and the maximum backoff
from two seconds to five, under a heading of breaking changes. Two documents in one repository,
one of them a table whose whole purpose is to state the default, and the code sides with the one
fewer people read.

[^3]: plugin-retry's [README](https://github.com/octokit/plugin-retry.js#readme) says a request
is retried up to 3 times on a 500 response and stops there. The wait comes from one line of
[error-request.ts](https://github.com/octokit/plugin-retry.js/blob/main/src/error-request.ts):
the retry count plus one, squared, in seconds. Squares, so a fourth retry would wait sixteen
seconds and a tenth a hundred, and three is the number that keeps the total at fourteen.

[^4]: A `fetch` with no signal is not a fetch with no timeout. undici's
[Client documentation](https://undici.nodejs.org/#/docs/api/Client) sets `headersTimeout` and
`bodyTimeout` to 300e3 milliseconds, five minutes each, and `connectTimeout` to ten seconds.
Octokit adds nothing of its own, so a hung GitHub call under plugin-retry's defaults is four
five-minute waits and fourteen seconds of sleep before the caller hears anything.