Retried requests leave their request timeout armed since 22.6.0 (process stays alive for the full timeout) · Issue #2897 · stripe/stripe-node · GitHub
Skip to content

Retried requests leave their request timeout armed since 22.6.0 (process stays alive for the full timeout) #2897

Open

Description

@KevinDeLaTorre

Describe the bug

Since 22.6.0, a request that gets retried leaves the failed attempt's timeout timer armed until it fires. The call itself succeeds and returns the right result, but with the default timeout of 80s, a short-lived process that hit one retry stays alive for 80 seconds after its work is done. Deno's test sanitizer also fails tests on the pending timer.

Root cause: since #2815 (the fix for #2814), FetchHttpClient keeps the timeout armed after the headers arrive so it also bounds the body read, and only toJSON() or toStream() calls requestTimeout.release(). When RequestSender decides to retry (a 5xx, a 409, stripe-should-retry: true), it passes the response straight to retryRequest without reading its body, so that attempt's timer is never released:

// src/RequestSender.ts (current master)
if (RequestSender._shouldRetry(res, requestRetries, maxRetries)) {
  return retryRequest(makeRequest, apiVersion, headers, requestRetries);
}

To Reproduce

  1. Create a client with Stripe.createFetchHttpClient() over a fetch that returns a 500 and then a 200, with maxNetworkRetries: 1 and timeout: 5000 (full script under Code snippets).
  2. Call stripe.customers.retrieve('cus_1').
  3. The call resolves with the customer after one retry, but one timer with the timeout delay is still pending, and the process does not exit until it fires (about 5s later).
  4. Without the 500 (no retry), no timer is left and the process exits at once.

Expected behavior

A response the SDK discards in order to retry releases its timeout, so the process exits as soon as the call settles, as it did through 22.5.0.

Code snippets

// repro.mjs: node repro.mjs retry | node repro.mjs control
import Stripe from 'stripe';

const arm = process.argv[2] ?? 'retry';
const TIMEOUT_MS = 5000;

const realSet = globalThis.setTimeout, realClear = globalThis.clearTimeout;
const pending = new Map();
globalThis.setTimeout = (fn, ms, ...rest) => {
  const id = realSet((...a) => { pending.delete(id); fn(...a); }, ms, ...rest);
  pending.set(id, ms);
  return id;
};
globalThis.clearTimeout = (id) => { pending.delete(id); realClear(id); };

let calls = 0;
const replies = arm === 'retry'
  ? [{ status: 500, body: { error: { type: 'api_error', message: 'boom' } } }]
  : [];
replies.push({ status: 200, body: { id: 'cus_1', object: 'customer' } });
const fakeFetch = async () => {
  const r = replies[Math.min(calls++, replies.length - 1)];
  return new Response(JSON.stringify(r.body), {
    status: r.status,
    headers: { 'content-type': 'application/json', 'request-id': `req_${calls}` },
  });
};

const stripe = new Stripe('sk_test_fake', {
  httpClient: Stripe.createFetchHttpClient(fakeFetch),
  timeout: TIMEOUT_MS,
  maxNetworkRetries: 1,
});

const t0 = Date.now();
const customer = await stripe.customers.retrieve('cus_1');
const leaked = [...pending.values()].filter((ms) => ms === TIMEOUT_MS).length;
console.log(JSON.stringify({ arm, calls, customer: customer.id, leaked_request_timeouts: leaked }));
process.on('exit', () => console.log(`process exited ${Date.now() - t0}ms after start`));

OS

macOS 26.5

Node version

Node v26.11.1

Library version

stripe-node v23.0.0

API version

2026-09-30.endive

Additional context

Output of the script above on 23.0.0:

{"arm":"control","calls":1,"customer":"cus_1","leaked_request_timeouts":0}
process exited 5ms after start
{"arm":"retry","calls":2,"customer":"cus_1","leaked_request_timeouts":1}
process exited 5003ms after start

Same retry-arm result across releases:

stripe-node retry arm
18.5.0, 19.0.0, 21.0.1, 22.2.2, 22.3.2, 22.5.0 no leaked timer, exits after the retry backoff
22.6.0, 22.6.2, 23.0.0, 23.1.0-beta.1 1 leaked timer, exits after timeout

22.6.0 is the release that shipped #2815. One possible fix is to release the timeout (or cancel the body) on the response before retrying it. Reading it through toJSON() without awaiting it also releases the timer without making the retry wait on a stalled body.

Activity

  1. jar-stripe commented on Oct 15, 2026

    @jar-stripe
    Contributor

    Hi @KevinDeLaTorre ! Thanks for raising this! I actually just found and put up a fix for this last night: #2895 (amongst other things). I'll try and verify it with your steps to reproduce; if you have a moment and a reproducable test project, could you try installing from this branch to see if it resolves ihe issue?

  2. KevinDeLaTorre commented on Oct 15, 2026

    @KevinDeLaTorre
    Author

    @jar-stripe Thanks for the quick fix! I built the branch and ran the repro from the issue against it and against 23.0.0:

    23.0.0, retry arm: leaked_request_timeouts 1, process exited 5002ms after start
    #2895, retry arm: leaked_request_timeouts 0, process exited 506ms after start
    control arm on both: 0 leaked, exited in 3ms

    So the retried attempt's timer is released and the process exits right after the retry backoff, same as 22.5.0. Looks good from my side.

  3. jar-stripe commented on Oct 19, 2026

    @jar-stripe
    Contributor

    @KevinDeLaTorre fantastic, thank you for confirming! The branch with the fix has been merged and this will go out with this month's release. I'll leave this issue open until the release is out.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions