Skip to content

Commit 6d72f8a

Browse files
authored
feat: Add FCM option resolveUnhandledClientError to resolve or reject push promise on FCM server response GOAWAY (#341)
1 parent 5409e48 commit 6d72f8a

File tree

5 files changed

+99
-19
lines changed

5 files changed

+99
-19
lines changed

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,4 @@ node_modules
4040

4141
# Optional eslint cache
4242
.eslintcache
43+
.vscode/launch.json

README.md

+10
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ The official Push Notification adapter for Parse Server. See [Parse Server Push
2323
- [Google Cloud Service Account Key](#google-cloud-service-account-key)
2424
- [Migration to FCM HTTP v1 API (June 2024)](#migration-to-fcm-http-v1-api-june-2024)
2525
- [HTTP/1.1 Legacy Option](#http11-legacy-option)
26+
- [Firebase Client Error](#firebase-client-error)
2627
- [Expo Push Options](#expo-push-options)
2728
- [Bundled with Parse Server](#bundled-with-parse-server)
2829
- [Logging](#logging)
@@ -158,6 +159,15 @@ android: {
158159
}
159160
```
160161

162+
#### Firebase Client Error
163+
164+
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.
165+
166+
- `resolveUnhandledClientError: true`: Logs the error and gracefully resolves it, ensuring that push sending does not result in a failure.
167+
- `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.
168+
169+
In both cases, detailed error logs are recorded in the Parse Server logs for debugging purposes.
170+
161171
### Expo Push Options
162172

163173
Example options:

spec/.eslintrc.json

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"env": {
3+
"jasmine": true
4+
},
5+
"globals": {
6+
"Parse": true
7+
},
8+
"rules": {
9+
"no-console": [0],
10+
"no-var": "error"
11+
}
12+
}

spec/FCM.spec.js

+53-12
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,24 @@
1-
import path from 'path';
1+
import { deleteApp, getApps } from 'firebase-admin/app';
22
import log from 'npmlog';
3+
import Parse from 'parse/node.js';
4+
import path from 'path';
35
import FCM from '../src/FCM.js';
4-
import { getApps, deleteApp } from 'firebase-admin/app';
5-
6-
const testArgs = {
7-
firebaseServiceAccount: path.join(
8-
__dirname,
9-
'..',
10-
'spec',
11-
'support',
12-
'fakeServiceAccount.json',
13-
),
14-
};
6+
7+
let testArgs;
158

169
describe('FCM', () => {
1710
beforeEach(async () => {
1811
getApps().forEach(app => deleteApp(app));
12+
13+
testArgs = {
14+
firebaseServiceAccount: path.join(
15+
__dirname,
16+
'..',
17+
'spec',
18+
'support',
19+
'fakeServiceAccount.json',
20+
),
21+
};
1922
});
2023

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

227+
it('rejects exceptions that are unhandled by FCM client', async () => {
228+
const spyInfo = spyOn(log, 'info').and.callFake(() => {});
229+
const spyError = spyOn(log, 'error').and.callFake(() => {});
230+
testArgs.resolveUnhandledClientError = false;
231+
const fcm = new FCM(testArgs);
232+
spyOn(fcm.sender, 'sendEachForMulticast').and.callFake(() => {
233+
throw new Error('test error');
234+
});
235+
fcm.pushType = 'android';
236+
const data = { data: { alert: 'alert' } };
237+
const devices = [{ deviceToken: 'token' }];
238+
await expectAsync(fcm.send(data, devices)).toBeRejectedWith(new Parse.Error(Parse.Error.OTHER_CAUSE, 'Error: test error'));
239+
expect(fcm.sender.sendEachForMulticast).toHaveBeenCalled();
240+
expect(spyInfo).toHaveBeenCalledWith('parse-server-push-adapter FCM', 'sending push to 1 devices');
241+
expect(spyError).toHaveBeenCalledTimes(2);
242+
expect(spyError.calls.all()[0].args).toEqual(['parse-server-push-adapter FCM', 'error sending push: firebase client exception: Error: test error']);
243+
expect(spyError.calls.all()[1].args).toEqual(['parse-server-push-adapter FCM', 'error sending push: ParseError: -1 Error: test error']);
244+
});
245+
246+
it('resolves exceptions that are unhandled by FCM client', async () => {
247+
const spyInfo = spyOn(log, 'info').and.callFake(() => {});
248+
const spyError = spyOn(log, 'error').and.callFake(() => {});
249+
testArgs.resolveUnhandledClientError = true;
250+
const fcm = new FCM(testArgs);
251+
spyOn(fcm.sender, 'sendEachForMulticast').and.callFake(() => {
252+
throw new Error('test error');
253+
});
254+
fcm.pushType = 'android';
255+
const data = { data: { alert: 'alert' } };
256+
const devices = [{ deviceToken: 'token' }];
257+
await expectAsync(fcm.send(data, devices)).toBeResolved();
258+
expect(fcm.sender.sendEachForMulticast).toHaveBeenCalled();
259+
expect(spyInfo).toHaveBeenCalledWith('parse-server-push-adapter FCM', 'sending push to 1 devices');
260+
expect(spyError).toHaveBeenCalledTimes(2);
261+
expect(spyError.calls.all()[0].args).toEqual(['parse-server-push-adapter FCM', 'error sending push: firebase client exception: Error: test error']);
262+
expect(spyError.calls.all()[1].args).toEqual(['parse-server-push-adapter FCM', 'error sending push: ParseError: -1 Error: test error']);
263+
});
264+
224265
it('FCM request invalid push type', async () => {
225266
const fcm = new FCM(testArgs);
226267
fcm.pushType = 'invalid';

src/FCM.js

+23-7
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
'use strict';
22

3-
import Parse from 'parse';
4-
import log from 'npmlog';
5-
import { initializeApp, cert, getApps, getApp } from 'firebase-admin/app';
3+
import { cert, getApp, getApps, initializeApp } from 'firebase-admin/app';
64
import { getMessaging } from 'firebase-admin/messaging';
5+
import log from 'npmlog';
6+
import Parse from 'parse';
77
import { randomString } from './PushAdapterUtils.js';
88

99
const LOG_PREFIX = 'parse-server-push-adapter FCM';
@@ -28,6 +28,9 @@ export default function FCM(args, pushType) {
2828
const fcmEnableLegacyHttpTransport = typeof args.fcmEnableLegacyHttpTransport === 'boolean'
2929
? args.fcmEnableLegacyHttpTransport
3030
: false;
31+
this.resolveUnhandledClientError = typeof args.resolveUnhandledClientError === 'boolean'
32+
? args.resolveUnhandledClientError
33+
: false;
3134

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

91-
return this.sender
92-
.sendEachForMulticast(fcmPayload.data)
94+
// This is a safe wrapper for sendEachForMulticast, due to bug in the firebase-admin
95+
// library, where it throws an exception instead of returning a rejected promise
96+
const sendEachForMulticastSafe = fcmPayloadData => {
97+
try {
98+
return this.sender.sendEachForMulticast(fcmPayloadData);
99+
} catch (err) {
100+
log.error(LOG_PREFIX, `error sending push: firebase client exception: ${err}`);
101+
return Promise.reject(new Parse.Error(Parse.Error.OTHER_CAUSE, err));
102+
}
103+
};
104+
105+
return sendEachForMulticastSafe(fcmPayload.data)
93106
.then((response) => {
94107
const promises = [];
95108
const failedTokens = [];
@@ -140,8 +153,11 @@ FCM.prototype.send = function (data, devices) {
140153

141154
const allPromises = Promise.all(
142155
slices.map((slice) => sendToDeviceSlice(slice, this.pushType)),
143-
).catch((err) => {
144-
log.error(LOG_PREFIX, `error sending push: ${err}`);
156+
).catch(e => {
157+
log.error(LOG_PREFIX, `error sending push: ${e}`);
158+
if (!this.resolveUnhandledClientError && e instanceof Parse.Error && e.code === Parse.Error.OTHER_CAUSE) {
159+
return Promise.reject(e);
160+
}
145161
});
146162

147163
return allPromises;

0 commit comments

Comments
 (0)