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
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
@octokit/plugin-retry | 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 | 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 | 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.
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.
Related how-tos
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
-
p-retry’s ten retries, one second and factor of two are the defaults of node-retry. Its README documents
retriesas 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 -
The option table in the README lists
maxNetworkRetriesas 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 -
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
-
A
fetchwith no signal is not a fetch with no timeout. undici’s Client documentation setsheadersTimeoutandbodyTimeoutto 300e3 milliseconds, five minutes each, andconnectTimeoutto 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