Skip to content

Commit e9c5ca5

Browse files
Retry based on Stripe-Should-Retry and Retry-After headers (stripe#692)
* Allow a header to dictate whether the client should retry * Allow a header to recommend a minimum time after which to retry * Rename the header, remove 429 for now, clean up * Fix tests * Cleaner handling of inputs * Add comments and a constant
1 parent 601d6ba commit e9c5ca5

File tree

2 files changed

+93
-17
lines changed

2 files changed

+93
-17
lines changed

lib/StripeResource.js

+47-10
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ StripeResource.method = require('./StripeMethod');
1818
StripeResource.BASIC_METHODS = require('./StripeMethod.basic');
1919

2020
StripeResource.MAX_BUFFERED_REQUEST_METRICS = 100;
21+
const MAX_RETRY_AFTER_WAIT = 60;
2122

2223
/**
2324
* Encapsulates request logic for a Stripe Resource
@@ -217,6 +218,7 @@ StripeResource.prototype = {
217218
};
218219
},
219220

221+
// For more on when and how to retry API requests, see https://stripe.com/docs/error-handling#safely-retrying-requests-with-idempotency
220222
_shouldRetry(res, numRetries) {
221223
// Do not retry if we are out of retries.
222224
if (numRetries >= this._stripe.getMaxNetworkRetries()) {
@@ -228,21 +230,33 @@ StripeResource.prototype = {
228230
return true;
229231
}
230232

231-
// Retry on conflict and availability errors.
232-
if (res.statusCode === 409 || res.statusCode === 503) {
233+
// The API may ask us not to retry (eg; if doing so would be a no-op)
234+
// or advise us to retry (eg; in cases of lock timeouts); we defer to that.
235+
if (res.headers && res.headers['stripe-should-retry'] === 'false') {
236+
return false;
237+
}
238+
if (res.headers && res.headers['stripe-should-retry'] === 'true') {
239+
return true;
240+
}
241+
242+
// Retry on conflict errors.
243+
if (res.statusCode === 409) {
233244
return true;
234245
}
235246

236-
// Retry on 5xx's, except POST's, which our idempotency framework
237-
// would just replay as 500's again anyway.
238-
if (res.statusCode >= 500 && res.req._requestEvent.method !== 'POST') {
247+
// Retry on 500, 503, and other internal errors.
248+
//
249+
// Note that we expect the stripe-should-retry header to be false
250+
// in most cases when a 500 is returned, since our idempotency framework
251+
// would typically replay it anyway.
252+
if (res.statusCode >= 500) {
239253
return true;
240254
}
241255

242256
return false;
243257
},
244258

245-
_getSleepTimeInMS(numRetries) {
259+
_getSleepTimeInMS(numRetries, retryAfter = null) {
246260
const initialNetworkRetryDelay = this._stripe.getInitialNetworkRetryDelay();
247261
const maxNetworkRetryDelay = this._stripe.getMaxNetworkRetryDelay();
248262

@@ -261,6 +275,11 @@ StripeResource.prototype = {
261275
// But never sleep less than the base sleep seconds.
262276
sleepSeconds = Math.max(initialNetworkRetryDelay, sleepSeconds);
263277

278+
// And never sleep less than the time the API asks us to wait, assuming it's a reasonable ask.
279+
if (Number.isInteger(retryAfter) && retryAfter <= MAX_RETRY_AFTER_WAIT) {
280+
sleepSeconds = Math.max(sleepSeconds, retryAfter);
281+
}
282+
264283
return sleepSeconds * 1000;
265284
},
266285

@@ -342,10 +361,16 @@ StripeResource.prototype = {
342361
_request(method, host, path, data, auth, options, callback) {
343362
let requestData;
344363

345-
const retryRequest = (requestFn, apiVersion, headers, requestRetries) => {
364+
const retryRequest = (
365+
requestFn,
366+
apiVersion,
367+
headers,
368+
requestRetries,
369+
retryAfter
370+
) => {
346371
return setTimeout(
347372
requestFn,
348-
this._getSleepTimeInMS(requestRetries),
373+
this._getSleepTimeInMS(requestRetries, retryAfter),
349374
apiVersion,
350375
headers,
351376
requestRetries + 1
@@ -394,15 +419,27 @@ StripeResource.prototype = {
394419

395420
req.once('response', (res) => {
396421
if (this._shouldRetry(res, requestRetries)) {
397-
return retryRequest(makeRequest, apiVersion, headers, requestRetries);
422+
return retryRequest(
423+
makeRequest,
424+
apiVersion,
425+
headers,
426+
requestRetries,
427+
((res || {}).headers || {})['retry-after']
428+
);
398429
} else {
399430
return this._responseHandler(req, callback)(res);
400431
}
401432
});
402433

403434
req.on('error', (error) => {
404435
if (this._shouldRetry(null, requestRetries)) {
405-
return retryRequest(makeRequest, apiVersion, headers, requestRetries);
436+
return retryRequest(
437+
makeRequest,
438+
apiVersion,
439+
headers,
440+
requestRetries,
441+
null
442+
);
406443
} else {
407444
return this._errorHandler(req, requestRetries, callback)(error);
408445
}

test/StripeResource.spec.js

+46-7
Original file line numberDiff line numberDiff line change
@@ -240,14 +240,18 @@ describe('StripeResource', () => {
240240
});
241241
});
242242

243-
it('should not retry on a 500 error when the method is POST', (done) => {
243+
it('should not retry when a header says not to', (done) => {
244244
nock(`https://${options.host}`)
245245
.post(options.path, options.params)
246-
.reply(500, {
247-
error: {
248-
type: 'api_error',
246+
.reply(
247+
500,
248+
{
249+
error: {
250+
type: 'api_error',
251+
},
249252
},
250-
});
253+
{'stripe-should-retry': 'false'}
254+
);
251255

252256
realStripe.setMaxNetworkRetries(1);
253257

@@ -257,6 +261,29 @@ describe('StripeResource', () => {
257261
});
258262
});
259263

264+
it('should retry when a header says it should, even on status codes we ordinarily wouldnt', (done) => {
265+
nock(`https://${options.host}`)
266+
.post(options.path, options.params)
267+
.reply(
268+
400,
269+
{error: {type: 'your_fault'}},
270+
{'stripe-should-retry': 'true'}
271+
)
272+
.post(options.path, options.params)
273+
.reply(200, {
274+
id: 'ch_123',
275+
object: 'charge',
276+
amount: 1000,
277+
});
278+
279+
realStripe.setMaxNetworkRetries(1);
280+
281+
realStripe.charges.create(options.data, (err, charge) => {
282+
expect(charge.id).to.equal('ch_123');
283+
done(err);
284+
});
285+
});
286+
260287
it('should handle OAuth errors gracefully', (done) => {
261288
nock('https://connect.stripe.com')
262289
.post('/oauth/token')
@@ -471,17 +498,29 @@ describe('StripeResource', () => {
471498

472499
describe('_getSleepTimeInMS', () => {
473500
it('should not exceed the maximum or minimum values', () => {
474-
let sleepSeconds;
475501
const max = stripe.getMaxNetworkRetryDelay();
476502
const min = stripe.getInitialNetworkRetryDelay();
477503

478504
for (let i = 0; i < 10; i++) {
479-
sleepSeconds = stripe.invoices._getSleepTimeInMS(i) / 1000;
505+
const sleepSeconds = stripe.invoices._getSleepTimeInMS(i) / 1000;
480506

481507
expect(sleepSeconds).to.be.at.most(max);
482508
expect(sleepSeconds).to.be.at.least(min);
483509
}
484510
});
511+
512+
it('should allow a maximum override', () => {
513+
const maxSec = stripe.getMaxNetworkRetryDelay();
514+
const minMS = stripe.getInitialNetworkRetryDelay() * 1000;
515+
516+
expect(stripe.invoices._getSleepTimeInMS(3, 0)).to.be.gt(minMS);
517+
expect(stripe.invoices._getSleepTimeInMS(2, 3)).to.equal(3000);
518+
expect(stripe.invoices._getSleepTimeInMS(0, 3)).to.equal(3000);
519+
expect(stripe.invoices._getSleepTimeInMS(0, 0)).to.equal(minMS);
520+
expect(stripe.invoices._getSleepTimeInMS(0, maxSec * 2)).to.equal(
521+
maxSec * 2 * 1000
522+
);
523+
});
485524
});
486525
});
487526
});

0 commit comments

Comments
 (0)