Skip to content

Commit d0d40bf

Browse files
authored
fix(fcm): Wrap HTTP/2 session errors in promise (#2868)
* Promise based option * Throw session errors and bundle with a promise to a BatchResponse if one exists * Cleaned up implementation and updated session error types * Added unit test * rebuild apidocs * Wrap session errors in a new `FirebaseMessagingSessionError`
1 parent a58ef0a commit d0d40bf

File tree

6 files changed

+201
-51
lines changed

6 files changed

+201
-51
lines changed

src/messaging/messaging.ts

Lines changed: 60 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@
1717

1818
import { App } from '../app';
1919
import { deepCopy } from '../utils/deep-copy';
20-
import { ErrorInfo, MessagingClientErrorCode, FirebaseMessagingError } from '../utils/error';
20+
import {
21+
ErrorInfo, MessagingClientErrorCode, FirebaseMessagingError, FirebaseMessagingSessionError
22+
} from '../utils/error';
2123
import * as utils from '../utils';
2224
import * as validator from '../utils/validator';
2325
import { validateMessage } from './messaging-internal';
@@ -206,48 +208,71 @@ export class Messaging {
206208
MessagingClientErrorCode.INVALID_ARGUMENT, 'dryRun must be a boolean');
207209
}
208210

209-
const http2SessionHandler = this.useLegacyTransport ? undefined : new Http2SessionHandler(`https://${FCM_SEND_HOST}`)
211+
const http2SessionHandler = this.useLegacyTransport ? undefined : new Http2SessionHandler(`https://${FCM_SEND_HOST}`);
210212

211213
return this.getUrlPath()
212214
.then((urlPath) => {
213-
const requests: Promise<SendResponse>[] = copy.map(async (message) => {
214-
validateMessage(message);
215-
const request: { message: Message; validate_only?: boolean } = { message };
216-
if (dryRun) {
217-
request.validate_only = true;
218-
}
219-
220-
if (http2SessionHandler){
221-
return this.messagingRequestHandler.invokeHttp2RequestHandlerForSendResponse(
222-
FCM_SEND_HOST, urlPath, request, http2SessionHandler);
223-
}
224-
return this.messagingRequestHandler.invokeHttpRequestHandlerForSendResponse(FCM_SEND_HOST, urlPath, request);
225-
});
226-
return Promise.allSettled(requests);
227-
})
228-
.then((results) => {
229-
const responses: SendResponse[] = [];
230-
results.forEach(result => {
231-
if (result.status === 'fulfilled') {
232-
responses.push(result.value);
233-
} else { // rejected
234-
responses.push({ success: false, error: result.reason })
235-
}
236-
})
237-
const successCount: number = responses.filter((resp) => resp.success).length;
238-
return {
239-
responses,
240-
successCount,
241-
failureCount: responses.length - successCount,
242-
};
215+
if (http2SessionHandler) {
216+
let sendResponsePromise: Promise<PromiseSettledResult<SendResponse>[]>;
217+
return new Promise((resolve: (result: PromiseSettledResult<SendResponse>[]) => void, reject) => {
218+
// Start session listeners
219+
http2SessionHandler.invoke().catch((error) => {
220+
const pendingBatchResponse =
221+
sendResponsePromise ? sendResponsePromise.then(this.parseSendResponses) : undefined;
222+
reject(new FirebaseMessagingSessionError(error, undefined, pendingBatchResponse));
223+
});
224+
225+
// Start making requests
226+
const requests: Promise<SendResponse>[] = copy.map(async (message) => {
227+
validateMessage(message);
228+
const request: { message: Message; validate_only?: boolean; } = { message };
229+
if (dryRun) {
230+
request.validate_only = true;
231+
}
232+
return this.messagingRequestHandler.invokeHttp2RequestHandlerForSendResponse(
233+
FCM_SEND_HOST, urlPath, request, http2SessionHandler);
234+
});
235+
236+
// Resolve once all requests have completed
237+
sendResponsePromise = Promise.allSettled(requests);
238+
sendResponsePromise.then(resolve);
239+
});
240+
} else {
241+
const requests: Promise<SendResponse>[] = copy.map(async (message) => {
242+
validateMessage(message);
243+
const request: { message: Message; validate_only?: boolean; } = { message };
244+
if (dryRun) {
245+
request.validate_only = true;
246+
}
247+
return this.messagingRequestHandler.invokeHttpRequestHandlerForSendResponse(
248+
FCM_SEND_HOST, urlPath, request);
249+
});
250+
return Promise.allSettled(requests);
251+
}
243252
})
253+
.then(this.parseSendResponses)
244254
.finally(() => {
245-
if (http2SessionHandler){
246-
http2SessionHandler.close()
247-
}
255+
http2SessionHandler?.close();
248256
});
249257
}
250258

259+
private parseSendResponses(results: PromiseSettledResult<SendResponse>[]): BatchResponse {
260+
const responses: SendResponse[] = [];
261+
results.forEach(result => {
262+
if (result.status === 'fulfilled') {
263+
responses.push(result.value);
264+
} else { // rejected
265+
responses.push({ success: false, error: result.reason });
266+
}
267+
});
268+
const successCount: number = responses.filter((resp) => resp.success).length;
269+
return {
270+
responses,
271+
successCount,
272+
failureCount: responses.length - successCount,
273+
};
274+
}
275+
251276
/**
252277
* Sends the given multicast message to all the FCM registration tokens
253278
* specified in it.

src/utils/api-request.ts

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1054,6 +1054,7 @@ class Http2RequestConfigImpl extends BaseRequestConfigImpl implements Http2Reque
10541054

10551055
public buildRequestOptions(): https.RequestOptions {
10561056
const parsed = this.buildUrl();
1057+
// TODO(b/401051826)
10571058
const protocol = parsed.protocol;
10581059

10591060
return {
@@ -1315,9 +1316,16 @@ export class ExponentialBackoffPoller<T> extends EventEmitter {
13151316
export class Http2SessionHandler {
13161317

13171318
private http2Session: http2.ClientHttp2Session
1319+
protected promise: Promise<void>
1320+
protected resolve: () => void;
1321+
protected reject: (_: any) => void;
13181322

13191323
constructor(url: string){
1320-
this.http2Session = this.createSession(url)
1324+
this.promise = new Promise((resolve, reject) => {
1325+
this.resolve = resolve;
1326+
this.reject = reject;
1327+
this.http2Session = this.createSession(url)
1328+
});
13211329
}
13221330

13231331
public createSession(url: string): http2.ClientHttp2Session {
@@ -1330,23 +1338,32 @@ export class Http2SessionHandler {
13301338
const http2Session = http2.connect(url, opts)
13311339

13321340
http2Session.on('goaway', (errorCode, _, opaqueData) => {
1333-
throw new FirebaseAppError(
1341+
this.reject(new FirebaseAppError(
13341342
AppErrorCodes.NETWORK_ERROR,
1335-
`Error while making requests: GOAWAY - ${opaqueData.toString()}, Error code: ${errorCode}`
1336-
);
1343+
`Error while making requests: GOAWAY - ${opaqueData?.toString()}, Error code: ${errorCode}`
1344+
));
13371345
})
13381346

13391347
http2Session.on('error', (error) => {
1340-
throw new FirebaseAppError(
1348+
this.reject(new FirebaseAppError(
13411349
AppErrorCodes.NETWORK_ERROR,
1342-
`Error while making requests: ${error}`
1343-
);
1350+
`Session error while making requests: ${error}`
1351+
));
13441352
})
1353+
1354+
http2Session.on('close', () => {
1355+
// Resolve current promise
1356+
this.resolve()
1357+
});
13451358
return http2Session
13461359
}
13471360
return this.http2Session
13481361
}
13491362

1363+
public invoke(): Promise<void> {
1364+
return this.promise
1365+
}
1366+
13501367
get session(): http2.ClientHttp2Session {
13511368
return this.http2Session
13521369
}

src/utils/error.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
*/
1717

1818
import { FirebaseError as FirebaseErrorInterface } from '../app';
19+
import { BatchResponse } from '../messaging/messaging-api';
1920
import { deepCopy } from '../utils/deep-copy';
2021

2122
/**
@@ -344,6 +345,38 @@ export class FirebaseMessagingError extends PrefixedFirebaseError {
344345
}
345346
}
346347

348+
export class FirebaseMessagingSessionError extends FirebaseMessagingError {
349+
public pendingBatchResponse?: Promise<BatchResponse>;
350+
/**
351+
*
352+
* @param info - The error code info.
353+
* @param message - The error message. This will override the default message if provided.
354+
* @param pendingBatchResponse - BatchResponse for pending messages when session error occured.
355+
* @constructor
356+
* @internal
357+
*/
358+
constructor(info: ErrorInfo, message?: string, pendingBatchResponse?: Promise<BatchResponse>) {
359+
// Override default message if custom message provided.
360+
super(info, message || info.message);
361+
this.pendingBatchResponse = pendingBatchResponse;
362+
363+
/* tslint:disable:max-line-length */
364+
// Set the prototype explicitly. See the following link for more details:
365+
// https://github.com/Microsoft/TypeScript/wiki/Breaking-Changes#extending-built-ins-like-error-array-and-map-may-no-longer-work
366+
/* tslint:enable:max-line-length */
367+
(this as any).__proto__ = FirebaseMessagingSessionError.prototype;
368+
}
369+
370+
/** @returns The object representation of the error. */
371+
public toJSON(): object {
372+
return {
373+
code: this.code,
374+
message: this.message,
375+
pendingBatchResponse: this.pendingBatchResponse,
376+
};
377+
}
378+
}
379+
347380
/**
348381
* Firebase project management error code structure. This extends PrefixedFirebaseError.
349382
*/

test/resources/mocks.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -322,10 +322,11 @@ export interface MockHttp2Request {
322322
}
323323

324324
export interface MockHttp2Response {
325-
headers: http2.IncomingHttpHeaders & http2.IncomingHttpStatusHeader,
326-
data: Buffer,
325+
headers?: http2.IncomingHttpHeaders & http2.IncomingHttpStatusHeader,
326+
data?: Buffer,
327327
delay?: number,
328-
error?: any
328+
sessionError?: any
329+
streamError?: any,
329330
}
330331

331332
export class Http2Mocker {
@@ -340,12 +341,12 @@ export class Http2Mocker {
340341
this.connectStub = sinon.stub(http2, 'connect');
341342
this.connectStub.callsFake((_target: any, options: any) => {
342343
const session = this.originalConnect('https://www.example.com', options);
343-
session.request = this.createMockRequest()
344+
session.request = this.createMockRequest(session)
344345
return session;
345346
})
346347
}
347348

348-
private createMockRequest() {
349+
private createMockRequest(session:http2.ClientHttp2Session) {
349350
return (requestHeaders: http2.OutgoingHttpHeaders) => {
350351
// Create a mock ClientHttp2Stream to return
351352
const mockStream = new stream.Readable({
@@ -365,8 +366,11 @@ export class Http2Mocker {
365366
const mockRes = this.mockResponses.shift();
366367
if (mockRes) {
367368
this.timeouts.push(setTimeout(() => {
368-
if (mockRes.error) {
369-
mockStream.emit('error', mockRes.error)
369+
if (mockRes.sessionError) {
370+
session.emit('error', mockRes.sessionError)
371+
}
372+
if (mockRes.streamError) {
373+
mockStream.emit('error', mockRes.streamError)
370374
}
371375
else {
372376
mockStream.emit('response', mockRes.headers);

test/unit/messaging/messaging.spec.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
import { HttpClient } from '../../../src/utils/api-request';
3535
import { getMetricsHeader, getSdkVersion } from '../../../src/utils/index';
3636
import * as utils from '../utils';
37+
import { FirebaseMessagingSessionError } from '../../../src/utils/error';
3738

3839
chai.should();
3940
chai.use(sinonChai);
@@ -121,6 +122,12 @@ function mockHttp2SendRequestError(
121122
} as mocks.MockHttp2Response
122123
}
123124

125+
function mockHttp2Error(streamError?: any, sessionError?:any): mocks.MockHttp2Response {
126+
return {
127+
streamError: streamError,
128+
sessionError: sessionError
129+
} as mocks.MockHttp2Response
130+
}
124131

125132
function mockErrorResponse(
126133
path: string,
@@ -906,6 +913,30 @@ describe('Messaging', () => {
906913
});
907914
});
908915

916+
it('should throw error with BatchResponse promise on session error event using HTTP/2', () => {
917+
mockedHttp2Responses.push(mockHttp2SendRequestResponse('projects/projec_id/messages/1'))
918+
const sessionError = 'MOCK_SESSION_ERROR'
919+
mockedHttp2Responses.push(mockHttp2Error(
920+
new Error(`MOCK_STREAM_ERROR caused by ${sessionError}`),
921+
new Error(sessionError)
922+
));
923+
http2Mocker.http2Stub(mockedHttp2Responses)
924+
925+
return messaging.sendEach(
926+
[validMessage, validMessage], true
927+
).catch(async (error: FirebaseMessagingSessionError) => {
928+
expect(error.code).to.equal('messaging/app/network-error');
929+
expect(error.pendingBatchResponse).to.not.be.undefined;
930+
await error.pendingBatchResponse?.then((response: BatchResponse) => {
931+
expect(http2Mocker.requests.length).to.equal(2);
932+
expect(response.failureCount).to.equal(1);
933+
const responses = response.responses;
934+
checkSendResponseSuccess(responses[0], 'projects/projec_id/messages/1');
935+
checkSendResponseFailure(responses[1], 'app/network-error');
936+
})
937+
});
938+
})
939+
909940
// This test was added to also verify https://github.com/firebase/firebase-admin-node/issues/1146
910941
it('should be fulfilled when called with different message types using HTTP/2', () => {
911942
const messageIds = [

test/unit/utils/api-request.spec.ts

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,12 +140,14 @@ function mockHttp2SendRequestError(
140140
} as mocks.MockHttp2Response
141141
}
142142

143-
function mockHttp2Error(err: any): mocks.MockHttp2Response {
143+
function mockHttp2Error(streamError?: any, sessionError?:any): mocks.MockHttp2Response {
144144
return {
145-
error: err
145+
streamError: streamError,
146+
sessionError: sessionError
146147
} as mocks.MockHttp2Response
147148
}
148149

150+
149151
/**
150152
* Returns a new RetryConfig instance for testing. This is same as the default
151153
* RetryConfig, with the backOffFactor set to 0 to avoid delays.
@@ -2500,6 +2502,44 @@ describe('Http2Client', () => {
25002502
http2SessionHandler: http2SessionHandler
25012503
}).should.eventually.be.rejectedWith(err).and.have.property('code', 'app/network-error');
25022504
});
2505+
2506+
it('should fail on session and stream errors', async () => {
2507+
const reqData = { request: 'data' };
2508+
const streamError = 'Error while making request: test stream error. Error code: AWFUL_STREAM_ERROR';
2509+
const sessionError = 'Session error while making requests: Error: AWFUL_SESSION_ERROR'
2510+
mockedHttp2Responses.push(mockHttp2Error(
2511+
{ message: 'test stream error', code: 'AWFUL_STREAM_ERROR' },
2512+
new Error('AWFUL_SESSION_ERROR')
2513+
));
2514+
http2Mocker.http2Stub(mockedHttp2Responses);
2515+
2516+
const client = new Http2Client();
2517+
http2SessionHandler = new Http2SessionHandler(mockHostUrl)
2518+
2519+
await client.send({
2520+
method: 'POST',
2521+
url: mockUrl,
2522+
headers: {
2523+
'authorization': 'Bearer token',
2524+
'My-Custom-Header': 'CustomValue',
2525+
},
2526+
data: reqData,
2527+
http2SessionHandler: http2SessionHandler,
2528+
}).should.eventually.be.rejectedWith(streamError).and.have.property('code', 'app/network-error')
2529+
.then(() => {
2530+
expect(http2Mocker.requests.length).to.equal(1);
2531+
expect(http2Mocker.requests[0].headers[':method']).to.equal('POST');
2532+
expect(http2Mocker.requests[0].headers[':scheme']).to.equal('https:');
2533+
expect(http2Mocker.requests[0].headers[':path']).to.equal(mockPath);
2534+
expect(JSON.parse(http2Mocker.requests[0].data)).to.deep.equal(reqData);
2535+
expect(http2Mocker.requests[0].headers.authorization).to.equal('Bearer token');
2536+
expect(http2Mocker.requests[0].headers['content-type']).to.contain('application/json');
2537+
expect(http2Mocker.requests[0].headers['My-Custom-Header']).to.equal('CustomValue');
2538+
});
2539+
2540+
await http2SessionHandler.invoke().should.eventually.be.rejectedWith(sessionError)
2541+
.and.have.property('code', 'app/network-error')
2542+
});
25032543
});
25042544

25052545
describe('AuthorizedHttpClient', () => {

0 commit comments

Comments
 (0)