Skip to content

Commit

Permalink
feat: Add FCM option resolveUnhandledClientError to resolve or reje…
Browse files Browse the repository at this point in the history
…ct push promise on FCM server response GOAWAY (#341)
  • Loading branch information
mtrezza authored Dec 14, 2024
1 parent 5409e48 commit 6d72f8a
Show file tree
Hide file tree
Showing 5 changed files with 99 additions and 19 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,4 @@ node_modules

# Optional eslint cache
.eslintcache
.vscode/launch.json
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ The official Push Notification adapter for Parse Server. See [Parse Server Push
- [Google Cloud Service Account Key](#google-cloud-service-account-key)
- [Migration to FCM HTTP v1 API (June 2024)](#migration-to-fcm-http-v1-api-june-2024)
- [HTTP/1.1 Legacy Option](#http11-legacy-option)
- [Firebase Client Error](#firebase-client-error)
- [Expo Push Options](#expo-push-options)
- [Bundled with Parse Server](#bundled-with-parse-server)
- [Logging](#logging)
Expand Down Expand Up @@ -158,6 +159,15 @@ android: {
}
```

#### Firebase Client Error

Occasionally, errors within the Firebase Cloud Messaging (FCM) client may not be managed internally and are instead passed to the Parse Server Push Adapter. These errors can occur, for instance, due to unhandled FCM server connection issues.

- `resolveUnhandledClientError: true`: Logs the error and gracefully resolves it, ensuring that push sending does not result in a failure.
- `resolveUnhandledClientError: false`: Causes push sending to fail, returning a `Parse.Error.OTHER_CAUSE` with error details that can be parsed to handle it accordingly. This is the default.

In both cases, detailed error logs are recorded in the Parse Server logs for debugging purposes.

### Expo Push Options

Example options:
Expand Down
12 changes: 12 additions & 0 deletions spec/.eslintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"env": {
"jasmine": true
},
"globals": {
"Parse": true
},
"rules": {
"no-console": [0],
"no-var": "error"
}
}
65 changes: 53 additions & 12 deletions spec/FCM.spec.js
Original file line number Diff line number Diff line change
@@ -1,21 +1,24 @@
import path from 'path';
import { deleteApp, getApps } from 'firebase-admin/app';
import log from 'npmlog';
import Parse from 'parse/node.js';
import path from 'path';
import FCM from '../src/FCM.js';
import { getApps, deleteApp } from 'firebase-admin/app';

const testArgs = {
firebaseServiceAccount: path.join(
__dirname,
'..',
'spec',
'support',
'fakeServiceAccount.json',
),
};

let testArgs;

describe('FCM', () => {
beforeEach(async () => {
getApps().forEach(app => deleteApp(app));

testArgs = {
firebaseServiceAccount: path.join(
__dirname,
'..',
'spec',
'support',
'fakeServiceAccount.json',
),
};
});

it('can initialize', () => {
Expand Down Expand Up @@ -221,6 +224,44 @@ describe('FCM', () => {
expect(spyError).toHaveBeenCalledWith('parse-server-push-adapter FCM', 'error sending push: testing error abort');
});

it('rejects exceptions that are unhandled by FCM client', async () => {
const spyInfo = spyOn(log, 'info').and.callFake(() => {});
const spyError = spyOn(log, 'error').and.callFake(() => {});
testArgs.resolveUnhandledClientError = false;
const fcm = new FCM(testArgs);
spyOn(fcm.sender, 'sendEachForMulticast').and.callFake(() => {
throw new Error('test error');
});
fcm.pushType = 'android';
const data = { data: { alert: 'alert' } };
const devices = [{ deviceToken: 'token' }];
await expectAsync(fcm.send(data, devices)).toBeRejectedWith(new Parse.Error(Parse.Error.OTHER_CAUSE, 'Error: test error'));

Check warning on line 238 in spec/FCM.spec.js

View workflow job for this annotation

GitHub Actions / Lint

'expectAsync' is not defined
expect(fcm.sender.sendEachForMulticast).toHaveBeenCalled();
expect(spyInfo).toHaveBeenCalledWith('parse-server-push-adapter FCM', 'sending push to 1 devices');
expect(spyError).toHaveBeenCalledTimes(2);
expect(spyError.calls.all()[0].args).toEqual(['parse-server-push-adapter FCM', 'error sending push: firebase client exception: Error: test error']);
expect(spyError.calls.all()[1].args).toEqual(['parse-server-push-adapter FCM', 'error sending push: ParseError: -1 Error: test error']);
});

it('resolves exceptions that are unhandled by FCM client', async () => {
const spyInfo = spyOn(log, 'info').and.callFake(() => {});
const spyError = spyOn(log, 'error').and.callFake(() => {});
testArgs.resolveUnhandledClientError = true;
const fcm = new FCM(testArgs);
spyOn(fcm.sender, 'sendEachForMulticast').and.callFake(() => {
throw new Error('test error');
});
fcm.pushType = 'android';
const data = { data: { alert: 'alert' } };
const devices = [{ deviceToken: 'token' }];
await expectAsync(fcm.send(data, devices)).toBeResolved();

Check warning on line 257 in spec/FCM.spec.js

View workflow job for this annotation

GitHub Actions / Lint

'expectAsync' is not defined
expect(fcm.sender.sendEachForMulticast).toHaveBeenCalled();
expect(spyInfo).toHaveBeenCalledWith('parse-server-push-adapter FCM', 'sending push to 1 devices');
expect(spyError).toHaveBeenCalledTimes(2);
expect(spyError.calls.all()[0].args).toEqual(['parse-server-push-adapter FCM', 'error sending push: firebase client exception: Error: test error']);
expect(spyError.calls.all()[1].args).toEqual(['parse-server-push-adapter FCM', 'error sending push: ParseError: -1 Error: test error']);
});

it('FCM request invalid push type', async () => {
const fcm = new FCM(testArgs);
fcm.pushType = 'invalid';
Expand Down
30 changes: 23 additions & 7 deletions src/FCM.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
'use strict';

import Parse from 'parse';
import log from 'npmlog';
import { initializeApp, cert, getApps, getApp } from 'firebase-admin/app';
import { cert, getApp, getApps, initializeApp } from 'firebase-admin/app';
import { getMessaging } from 'firebase-admin/messaging';
import log from 'npmlog';
import Parse from 'parse';
import { randomString } from './PushAdapterUtils.js';

const LOG_PREFIX = 'parse-server-push-adapter FCM';
Expand All @@ -28,6 +28,9 @@ export default function FCM(args, pushType) {
const fcmEnableLegacyHttpTransport = typeof args.fcmEnableLegacyHttpTransport === 'boolean'
? args.fcmEnableLegacyHttpTransport
: false;
this.resolveUnhandledClientError = typeof args.resolveUnhandledClientError === 'boolean'
? args.resolveUnhandledClientError
: false;

let app;
if (getApps().length === 0) {
Expand Down Expand Up @@ -88,8 +91,18 @@ FCM.prototype.send = function (data, devices) {
const length = deviceTokens.length;
log.info(LOG_PREFIX, `sending push to ${length} devices`);

return this.sender
.sendEachForMulticast(fcmPayload.data)
// This is a safe wrapper for sendEachForMulticast, due to bug in the firebase-admin
// library, where it throws an exception instead of returning a rejected promise
const sendEachForMulticastSafe = fcmPayloadData => {
try {
return this.sender.sendEachForMulticast(fcmPayloadData);
} catch (err) {
log.error(LOG_PREFIX, `error sending push: firebase client exception: ${err}`);
return Promise.reject(new Parse.Error(Parse.Error.OTHER_CAUSE, err));
}
};

return sendEachForMulticastSafe(fcmPayload.data)
.then((response) => {
const promises = [];
const failedTokens = [];
Expand Down Expand Up @@ -140,8 +153,11 @@ FCM.prototype.send = function (data, devices) {

const allPromises = Promise.all(
slices.map((slice) => sendToDeviceSlice(slice, this.pushType)),
).catch((err) => {
log.error(LOG_PREFIX, `error sending push: ${err}`);
).catch(e => {
log.error(LOG_PREFIX, `error sending push: ${e}`);
if (!this.resolveUnhandledClientError && e instanceof Parse.Error && e.code === Parse.Error.OTHER_CAUSE) {
return Promise.reject(e);
}
});

return allPromises;
Expand Down

0 comments on commit 6d72f8a

Please sign in to comment.