Skip to content

Commit

Permalink
feat: add exponential backoff and retries for push/send
Browse files Browse the repository at this point in the history
  • Loading branch information
wschurman committed Sep 29, 2022
1 parent 0205b91 commit 45c023a
Show file tree
Hide file tree
Showing 5 changed files with 85 additions and 13 deletions.
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,13 @@
"homepage": "https://github.com/expo/expo-server-sdk-node#readme",
"dependencies": {
"node-fetch": "^2.6.0",
"promise-limit": "^2.7.0"
"promise-limit": "^2.7.0",
"promise-retry": "^2.0.1"
},
"devDependencies": {
"@types/jest": "^27.0.1",
"@types/node-fetch": "^2.5.12",
"@types/promise-retry": "^1.1.3",
"eslint": "^7.32.0",
"eslint-config-universe": "^7.0.1",
"fetch-mock": "^9.11.0",
Expand Down
43 changes: 31 additions & 12 deletions src/ExpoClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@
* Use this if you are running Node on your server backend when you are working with Expo
* https://expo.io
*/
import * as assert from 'assert';
import assert from 'assert';
import { Agent } from 'http';
import fetch, { Headers, Response as FetchResponse } from 'node-fetch';
import * as promiseLimit from 'promise-limit';
import * as zlib from 'zlib';
import promiseLimit from 'promise-limit';
import promiseRetry from 'promise-retry';
import zlib from 'zlib';

const BASE_URL = 'https://exp.host';
const BASE_API_URL = `${BASE_URL}/--/api/v2`;
Expand Down Expand Up @@ -74,13 +75,30 @@ export class Expo {
async sendPushNotificationsAsync(messages: ExpoPushMessage[]): Promise<ExpoPushTicket[]> {
const actualMessagesCount = Expo._getActualMessageCount(messages);

const data = await this.requestAsync(`${BASE_API_URL}/push/send`, {
httpMethod: 'post',
body: messages,
shouldCompress(body) {
return body.length > 1024;
const data = await promiseRetry(
async (retry): Promise<any> => {
try {
return await this.requestAsync(`${BASE_API_URL}/push/send`, {
httpMethod: 'post',
body: messages,
shouldCompress(body) {
return body.length > 1024;
},
});
} catch (e: any) {
// if Expo servers rate limit, retry with exponential backoff
if (e.statusCode === 429) {
return retry(e);
}
throw e;
}
},
});
{
retries: 2,
factor: 2,
minTimeout: 1000,
}
);

if (!Array.isArray(data) || data.length !== actualMessagesCount) {
const apiError: ExtensibleError = new Error(
Expand Down Expand Up @@ -236,7 +254,7 @@ export class Expo {
}

if (result.errors) {
const apiError = this.getErrorFromResult(result);
const apiError = this.getErrorFromResult(response, result);
throw apiError;
}

Expand All @@ -258,7 +276,7 @@ export class Expo {
return apiError;
}

return this.getErrorFromResult(result);
return this.getErrorFromResult(response, result);
}

private async getTextResponseErrorAsync(response: FetchResponse, text: string): Promise<Error> {
Expand All @@ -274,13 +292,14 @@ export class Expo {
* Returns an error for the first API error in the result, with an optional `others` field that
* contains any other errors.
*/
private getErrorFromResult(result: ApiResult): Error {
private getErrorFromResult(response: FetchResponse, result: ApiResult): Error {
assert(result.errors && result.errors.length > 0, `Expected at least one error from Expo`);
const [errorData, ...otherErrorData] = result.errors!;
const error: ExtensibleError = this.getErrorFromResultError(errorData);
if (otherErrorData.length) {
error.others = otherErrorData.map((data) => this.getErrorFromResultError(data));
}
error.statusCode = response.status;
return error;
}

Expand Down
20 changes: 20 additions & 0 deletions src/__tests__/ExpoClient-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,26 @@ describe('sending push notification messages', () => {
others: expect.arrayContaining([expect.any(Error)]),
});
});

test('handles 429 too many request by applying exponential backoff', async () => {
(fetch as any).mock(
'https://exp.host/--/api/v2/push/send',
{
status: 429,
body: {
errors: [{ code: 'RATE_LIMIT_ERROR', message: `Rate limit exceeded` }],
},
},
{ repeat: 3 }
);

const client = new ExpoClient();
const rejection = expect(client.sendPushNotificationsAsync([])).rejects;
await rejection.toThrowError(`Rate limit exceeded`);
await rejection.toMatchObject({ code: 'RATE_LIMIT_ERROR' });

expect((fetch as any).done()).toBeTruthy();
}, 10000);
});

describe('retrieving push notification receipts', () => {
Expand Down
1 change: 1 addition & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"sourceMap": true,
"declaration": true,
"types": ["jest", "node"],
"esModuleInterop": true,
"outDir": "./build",
"rootDir": "./src",
"strict": true
Expand Down
30 changes: 30 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -676,6 +676,18 @@
resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.3.2.tgz#fc8c2825e4ed2142473b4a81064e6e081463d1b3"
integrity sha512-eI5Yrz3Qv4KPUa/nSIAi0h+qX0XyewOliug5F2QAtuRg6Kjg6jfmxe1GIwoIRhZspD1A0RP8ANrPwvEXXtRFog==

"@types/promise-retry@^1.1.3":
version "1.1.3"
resolved "https://registry.yarnpkg.com/@types/promise-retry/-/promise-retry-1.1.3.tgz#baab427419da9088a1d2f21bf56249c21b3dd43c"
integrity sha512-LxIlEpEX6frE3co3vCO2EUJfHIta1IOmhDlcAsR4GMMv9hev1iTI9VwberVGkePJAuLZs5rMucrV8CziCfuJMw==
dependencies:
"@types/retry" "*"

"@types/retry@*":
version "0.12.2"
resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.2.tgz#ed279a64fa438bb69f2480eda44937912bb7480a"
integrity sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==

"@types/stack-utils@^2.0.0":
version "2.0.1"
resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.1.tgz#20f18294f797f2209b5f65c8e3b5c8e8261d127c"
Expand Down Expand Up @@ -1314,6 +1326,11 @@ enquirer@^2.3.5:
dependencies:
ansi-colors "^4.1.1"

err-code@^2.0.2:
version "2.0.3"
resolved "https://registry.yarnpkg.com/err-code/-/err-code-2.0.3.tgz#23c2f3b756ffdfc608d30e27c9a941024807e7f9"
integrity sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==

error-ex@^1.3.1:
version "1.3.2"
resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf"
Expand Down Expand Up @@ -3158,6 +3175,14 @@ promise-limit@^2.7.0:
resolved "https://registry.yarnpkg.com/promise-limit/-/promise-limit-2.7.0.tgz#eb5737c33342a030eaeaecea9b3d3a93cb592b26"
integrity sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==

promise-retry@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/promise-retry/-/promise-retry-2.0.1.tgz#ff747a13620ab57ba688f5fc67855410c370da22"
integrity sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==
dependencies:
err-code "^2.0.2"
retry "^0.12.0"

prompts@^2.0.1:
version "2.4.1"
resolved "https://registry.yarnpkg.com/prompts/-/prompts-2.4.1.tgz#befd3b1195ba052f9fd2fde8a486c4e82ee77f61"
Expand Down Expand Up @@ -3283,6 +3308,11 @@ resolve@^2.0.0-next.3:
is-core-module "^2.2.0"
path-parse "^1.0.6"

retry@^0.12.0:
version "0.12.0"
resolved "https://registry.yarnpkg.com/retry/-/retry-0.12.0.tgz#1b42a6266a21f07421d1b0b54b7dc167b01c013b"
integrity sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==

reusify@^1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76"
Expand Down

0 comments on commit 45c023a

Please sign in to comment.