Skip to content

Commit 3d728af

Browse files
fix: retry transient write failures after receiving goaway
1 parent 1db632d commit 3d728af

File tree

5 files changed

+108
-72
lines changed

5 files changed

+108
-72
lines changed

lib/client.js

Lines changed: 38 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -138,8 +138,27 @@ module.exports = function (dependencies) {
138138
return subDirectoryObject;
139139
};
140140

141+
/**
142+
* Determines if a request should be retried based on the error. This includes certain status codes, expired provider token, and transient write failures.
143+
* @param {Object} error - An object representing the error which may include the following properties:
144+
* @param {number} [error.status] - The HTTP status code returned from the APNs server.
145+
* @param {VError} error.error - The error details which may include a message describing the error.
146+
* @param {string} [error.error.message] - The error message which may indicate specific conditions such as 'ExpiredProviderToken' or transient write failures.
147+
* @returns {boolean} - Returns true if the request is considered retryable based on the error, otherwise false.
148+
*/
149+
Client.isRequestRetryable = function isRequestRetryable(error) {
150+
const isStatusCodeRetryable = [408, 429, 500, 502, 503, 504].includes(error.status);
151+
152+
const isProviderTokenExpired =
153+
error.status === 403 && error.error?.message === 'ExpiredProviderToken';
154+
155+
// This can happen after the server initiates a goaway, and the client did not finish closing and destroying the session yet, so the client tries to send a request on a closing session.
156+
const isTransientWriteFailure = !!error.error?.message?.startsWith('apn write failed');
157+
158+
return isStatusCodeRetryable || isProviderTokenExpired || isTransientWriteFailure;
159+
};
160+
141161
Client.prototype.write = async function write(notification, subDirectory, type, method, count) {
142-
const retryStatusCodes = [408, 429, 500, 502, 503, 504];
143162
const retryCount = count || 0;
144163
const subDirectoryLabel = this.subDirectoryLabel(type) ?? type;
145164
const subDirectoryInformation = this.makeSubDirectoryTypeObject(
@@ -198,21 +217,14 @@ module.exports = function (dependencies) {
198217
);
199218
return { ...subDirectoryInformation, ...sentRequest };
200219
} catch (error) {
201-
// Determine if this is a retryable request.
202-
if (
203-
retryStatusCodes.includes(error.status) ||
204-
(typeof error.error !== 'undefined' &&
205-
error.status == 403 &&
206-
error.error.message === 'ExpiredProviderToken')
207-
) {
220+
if (Client.isRequestRetryable(error)) {
208221
try {
209-
const resentRequest = await this.retryRequest(
222+
const resentRequest = await this.retryWrite(
210223
error,
211-
this.manageChannelsSession,
212-
this.config.manageChannelsAddress,
213224
notification,
214-
path,
215-
httpMethod,
225+
subDirectory,
226+
type,
227+
method,
216228
retryCount
217229
);
218230
return { ...subDirectoryInformation, ...resentRequest };
@@ -255,21 +267,14 @@ module.exports = function (dependencies) {
255267
);
256268
return { ...subDirectoryInformation, ...sentRequest };
257269
} catch (error) {
258-
// Determine if this is a retryable request.
259-
if (
260-
retryStatusCodes.includes(error.status) ||
261-
(typeof error.error !== 'undefined' &&
262-
error.status == 403 &&
263-
error.error.message === 'ExpiredProviderToken')
264-
) {
270+
if (Client.isRequestRetryable(error)) {
265271
try {
266-
const resentRequest = await this.retryRequest(
272+
const resentRequest = await this.retryWrite(
267273
error,
268-
this.session,
269-
this.config.address,
270274
notification,
271-
path,
272-
httpMethod,
275+
subDirectory,
276+
type,
277+
method,
273278
retryCount
274279
);
275280
return { ...subDirectoryInformation, ...resentRequest };
@@ -289,55 +294,24 @@ module.exports = function (dependencies) {
289294
}
290295
};
291296

292-
Client.prototype.retryRequest = async function retryRequest(
297+
Client.prototype.retryWrite = async function retryWrite(
293298
error,
294-
session,
295-
address,
296299
notification,
297-
path,
298-
httpMethod,
299-
count
300+
subDirectory,
301+
type,
302+
method,
303+
retryCount
300304
) {
301-
if (this.isDestroyed || session.closed) {
302-
const error = { error: new VError('client session is either closed or destroyed') };
303-
throw error;
304-
}
305-
306-
const retryCount = count + 1;
307-
308-
if (retryCount > this.config.connectionRetryLimit) {
305+
if (retryCount >= this.config.connectionRetryLimit) {
309306
throw error;
310307
}
311308

312309
const delayInSeconds = parseInt(error.retryAfter || 0);
313-
// Obey servers request to try after a specific time in ms.
314310
const delayPromise = new Promise(resolve => setTimeout(resolve, delayInSeconds * 1000));
315311
await delayPromise;
316312

317-
try {
318-
const sentRequest = await this.request(
319-
session,
320-
address,
321-
notification,
322-
path,
323-
httpMethod,
324-
retryCount
325-
);
326-
return sentRequest;
327-
} catch (error) {
328-
// Recursivelly call self until retryCount is exhausted
329-
// or error is thrown.
330-
const sentRequest = await this.retryRequest(
331-
error,
332-
session,
333-
address,
334-
notification,
335-
path,
336-
httpMethod,
337-
retryCount
338-
);
339-
return sentRequest;
340-
}
313+
// Retry write, which will handle reconnection if needed
314+
return await this.write(notification, subDirectory, type, method, retryCount + 1);
341315
};
342316

343317
Client.prototype.connect = function connect() {

test/.jshintrc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
{
2+
"esversion": 11,
23
"expr": true,
34
"strict": false,
45
"mocha": true,

test/client.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -665,7 +665,7 @@ describe('Client', () => {
665665
]);
666666
});
667667

668-
it('Handles goaway frames', async () => {
668+
it('Handles goaway frames and retries the request on a new connection', async () => {
669669
let didGetRequest = false;
670670
let establishedConnections = 0;
671671
server = createAndStartMockLowLevelServer(TEST_PORT, stream => {
@@ -714,7 +714,7 @@ describe('Client', () => {
714714
};
715715
await performRequestExpectingGoAway();
716716
await performRequestExpectingGoAway();
717-
expect(establishedConnections).to.equal(2);
717+
expect(establishedConnections).to.equal(8);
718718
expect(errorMessages).to.not.be.empty;
719719
let errorMessagesContainsGoAway = false;
720720
// Search for message, in older node, may be in random order.
@@ -2369,7 +2369,7 @@ describe('ManageChannelsClient', () => {
23692369
expect(infoMessagesContainsTimeout).to.be.true;
23702370
});
23712371

2372-
it('Handles goaway frames', async () => {
2372+
it('Handles goaway frames and retries the request on a new connection', async () => {
23732373
let didGetRequest = false;
23742374
let establishedConnections = 0;
23752375
server = createAndStartMockLowLevelServer(TEST_PORT, stream => {
@@ -2418,7 +2418,7 @@ describe('ManageChannelsClient', () => {
24182418
};
24192419
await performRequestExpectingGoAway();
24202420
await performRequestExpectingGoAway();
2421-
expect(establishedConnections).to.equal(2);
2421+
expect(establishedConnections).to.equal(8);
24222422
expect(errorMessages).to.not.be.empty;
24232423
let errorMessagesContainsGoAway = false;
24242424
// Search for message, in older node, may be in random order.

test/isRequestRetryable.unit.js

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
const { expect } = require('chai');
2+
const Client = require('../lib/client')({
3+
logger: { enabled: false, log: () => {} },
4+
errorLogger: { enabled: false, log: () => {} },
5+
config: () => ({}),
6+
http2: { constants: {} },
7+
});
8+
9+
describe('Client.isRequestRetryable', () => {
10+
it('returns true for error.message starting with "apn write failed"', () => {
11+
const error = { error: { message: 'apn write failed: socket hang up' } };
12+
expect(Client.isRequestRetryable(error)).to.be.true;
13+
});
14+
15+
it('returns false for error.message starting with "apn write timeout" (not retryable)', () => {
16+
const error = { error: { message: 'apn write timeout: timed out' } };
17+
expect(Client.isRequestRetryable(error)).to.be.false;
18+
});
19+
20+
it('returns false for error.message starting with "apn write aborted" (not retryable)', () => {
21+
const error = { error: { message: 'apn write aborted: aborted' } };
22+
expect(Client.isRequestRetryable(error)).to.be.false;
23+
});
24+
25+
it('returns false for error.message with other apn messages', () => {
26+
const error = { error: { message: 'apn connection closed' } };
27+
expect(Client.isRequestRetryable(error)).to.be.false;
28+
});
29+
30+
it('returns false for null error.error', () => {
31+
const error = { error: null };
32+
expect(Client.isRequestRetryable(error)).to.be.false;
33+
});
34+
35+
it('returns false for undefined error.error', () => {
36+
const error = {};
37+
expect(Client.isRequestRetryable(error)).to.be.false;
38+
});
39+
40+
it('returns false for error.error.message not matching', () => {
41+
const error = { error: { message: 'some other error' } };
42+
expect(Client.isRequestRetryable(error)).to.be.false;
43+
});
44+
45+
it('returns true for retryable status codes', () => {
46+
[408, 429, 500, 502, 503, 504].forEach(status => {
47+
expect(Client.isRequestRetryable({ status })).to.be.true;
48+
});
49+
});
50+
51+
it('returns true for ExpiredProviderToken', () => {
52+
const error = { status: 403, error: { message: 'ExpiredProviderToken' } };
53+
expect(Client.isRequestRetryable(error)).to.be.true;
54+
});
55+
56+
it('returns false for non-retryable status codes', () => {
57+
[200, 201, 400, 404, 401, 403].forEach(status => {
58+
expect(Client.isRequestRetryable({ status })).to.be.false;
59+
});
60+
});
61+
});

test/multiclient.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -463,7 +463,7 @@ describe('MultiClient', () => {
463463
]);
464464
});
465465

466-
it('Handles goaway frames', async () => {
466+
it('Handles goaway frames and retries the request on a new connection', async () => {
467467
let didGetRequest = false;
468468
let establishedConnections = 0;
469469
server = createAndStartMockLowLevelServer(TEST_PORT, stream => {
@@ -499,7 +499,7 @@ describe('MultiClient', () => {
499499
};
500500
await performRequestExpectingGoAway();
501501
await performRequestExpectingGoAway();
502-
expect(establishedConnections).to.equal(2);
502+
expect(establishedConnections).to.equal(8);
503503
});
504504

505505
it('Handles unexpected protocol errors (no response sent)', async () => {
@@ -1564,7 +1564,7 @@ describe('ManageChannelsMultiClient', () => {
15641564
]);
15651565
});
15661566

1567-
it('Handles goaway frames', async () => {
1567+
it('Handles goaway frames and retries the request on a new connection', async () => {
15681568
let didGetRequest = false;
15691569
let establishedConnections = 0;
15701570
server = createAndStartMockLowLevelServer(TEST_PORT, stream => {
@@ -1600,7 +1600,7 @@ describe('ManageChannelsMultiClient', () => {
16001600
};
16011601
await performRequestExpectingGoAway();
16021602
await performRequestExpectingGoAway();
1603-
expect(establishedConnections).to.equal(2);
1603+
expect(establishedConnections).to.equal(8);
16041604
});
16051605

16061606
it('Handles unexpected protocol errors (no response sent)', async () => {

0 commit comments

Comments
 (0)