How-to › Operate integrations in production

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.

Audience
API consumer
Level
intermediate
Topic
Evaluate, adopt and upgrade SDKs
Languages
TypeScript
Verified

A vendor client says it retries once, and during last night’s outage your logs show four requests for every call you made. A timeout you set to ten seconds returned after forty. The README describes the options and says nothing about what the defaults add up to under a real failure.

What you get

You will end up with a script that runs each candidate client against a stub that fails or hangs, and a table of the requests it sent and the seconds it spent. This is for you if a README is the only evidence you have for a client’s retry behavior.

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
@octokit/plugin-retryYou call GitHub through Octokit and want retries on 5xx without writing themWaits of 1, 4, and 9 seconds you cannot shorten without changing the plugin’s base value, and no timeout unless you supply oneYou need a total deadline, or the call has to fail fast
p-retry around a bare clientAny client, any error, and you want the policy in your own codeTen retries and seventeen minutes of backoff by default, and every decision about status codes is yours to writeThe SDK already retries, so wrapping it multiplies the attempts
stripe-node maxNetworkRetriesYou call Stripe and want retries that reuse the idempotency keyA default of two retries where the README says one, and an 80 second timeout on each attemptYour 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.

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.

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.

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.

node audit.mjs
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 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.

node audit.mjs
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, and a signal from AbortSignal.timeout 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.

  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')
  })
node --test audit.test.mjs
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.

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.

Footnotes

  1. p-retry’s ten retries, one second and factor of two are the defaults of node-retry. 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, keeping the numbers. A default that sums to seventeen minutes has outlived the code that chose it. ↩︎ Back to text

  2. The option table in the README lists maxNetworkRetries as 1, and the retry section says one reattempt. The changelog for 17.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. ↩︎ Back to text

  3. plugin-retry’s 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: 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. ↩︎ Back to text

  4. A fetch with no signal is not a fetch with no timeout. undici’s Client documentation 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. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.