Skip to content

Commit

Permalink
Add support for API timeouts to auth-next
Browse files Browse the repository at this point in the history
  • Loading branch information
avolkovi committed Apr 15, 2020
1 parent 18c5312 commit 6021352
Show file tree
Hide file tree
Showing 3 changed files with 168 additions and 4 deletions.
2 changes: 1 addition & 1 deletion packages-exp/auth-exp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"build": "rollup -c",
"build:deps": "lerna run --scope @firebase/'{app,auth-exp}' --include-dependencies build",
"dev": "rollup -c -w",
"test": "yarn type-check && run-p lint test:browser test:node",
"test": "yarn type-check && run-p lint test:browser",
"test:browser": "karma start --single-run",
"test:browser:debug": "karma start",
"test:node": "TS_NODE_COMPILER_OPTIONS='{\"module\":\"commonjs\"}' nyc --reporter lcovonly -- mocha src/**/*.test.* --opts ../../config/mocha.node.opts",
Expand Down
159 changes: 159 additions & 0 deletions packages-exp/auth-exp/src/api/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
/**
* @license
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { FirebaseError } from '@firebase/util';
import { expect, use } from 'chai';
import * as chaiAsPromised from 'chai-as-promised';
import { SinonStub, stub, useFakeTimers } from 'sinon';
import { DEFAULT_API_TIMEOUT, Endpoint, HttpMethod, performApiRequest } from '.';
import { mockEndpoint } from '../../test/api/helper';
import { mockAuth } from '../../test/mock_auth';
import * as mockFetch from '../../test/mock_fetch';
import { ServerError } from './errors';
import { AuthErrorCode } from '../core/errors';

use(chaiAsPromised);

describe('performApiRequest', () => {
const request = {
requestKey: 'request-value'
};

const serverResponse = {
responseKey: 'response-value'
};

context('with regular requests', () => {
beforeEach(mockFetch.setUp);
afterEach(mockFetch.tearDown);

it('should set the correct request, method and HTTP Headers', async () => {
const mock = mockEndpoint(Endpoint.SIGN_UP, serverResponse);
const response = await performApiRequest<typeof request, typeof serverResponse>(mockAuth, HttpMethod.POST, Endpoint.SIGN_UP, request);
expect(response).to.eql(serverResponse);
expect(mock.calls.length).to.eq(1);
expect(mock.calls[0].method).to.eq(HttpMethod.POST);
expect(mock.calls[0].request).to.eql(request);
expect(mock.calls[0].headers).to.eql({
'Content-Type': 'application/json'
});
});

it('should translate server errors to auth errors', async () => {
const mock = mockEndpoint(
Endpoint.SIGN_UP,
{
error: {
code: 400,
message: ServerError.EMAIL_EXISTS,
errors: [
{
message: ServerError.EMAIL_EXISTS
}
]
}
},
400
);
const promise = performApiRequest<typeof request, typeof serverResponse>(mockAuth, HttpMethod.POST, Endpoint.SIGN_UP, request);
await expect(promise).to.be.rejectedWith(
FirebaseError,
'Firebase: The email address is already in use by another account. (auth/email-already-in-use).'
);
expect(mock.calls[0].request).to.eql(request);
});

it('should handle unknown server errors', async () => {
const mock = mockEndpoint(
Endpoint.SIGN_UP,
{
error: {
code: 400,
message: 'Awesome error',
errors: [
{
message: 'Awesome error'
}
]
}
},
400
);
const promise = performApiRequest<typeof request, typeof serverResponse>(mockAuth, HttpMethod.POST, Endpoint.SIGN_UP, request);
await expect(promise).to.be.rejectedWith(
FirebaseError,
'Firebase: An internal AuthError has occurred. (auth/internal-error).'
);
expect(mock.calls[0].request).to.eql(request);
});

it('should support custom error handling per endpoint', async () => {
const mock = mockEndpoint(
Endpoint.SIGN_UP,
{
error: {
code: 400,
message: ServerError.EMAIL_EXISTS,
errors: [
{
message: ServerError.EMAIL_EXISTS
}
]
}
},
400
);
const promise = performApiRequest<typeof request, typeof serverResponse>(mockAuth, HttpMethod.POST, Endpoint.SIGN_UP, request, { [ServerError.EMAIL_EXISTS]: AuthErrorCode.ARGUMENT_ERROR });
await expect(promise).to.be.rejectedWith(
FirebaseError,
'Firebase: Error (auth/argument-error).'
);
expect(mock.calls[0].request).to.eql(request);
});
});

context('with network issues', () => {
let fetchStub: SinonStub;

beforeEach(() => {
fetchStub = stub(self, 'fetch');
});

afterEach(() => {
fetchStub.restore();
});

it('should handle timeouts', async () => {
const clock = useFakeTimers();
fetchStub.callsFake(() => {
return new Promise<never>(() => null);
});
const promise = performApiRequest<typeof request, never>(mockAuth, HttpMethod.POST, Endpoint.SIGN_UP, request);
clock.tick(DEFAULT_API_TIMEOUT + 1);
await expect(promise).to.be.rejectedWith(FirebaseError, 'Firebase: The operation has timed out. (auth/timeout).');
clock.restore();
});

it('should handle network failure', async () => {
fetchStub.callsFake(() => {
return new Promise<never>((_, reject) => reject(new Error('network error')));
});
const promise = performApiRequest<typeof request, never>(mockAuth, HttpMethod.POST, Endpoint.SIGN_UP, request);
await expect(promise).to.be.rejectedWith(FirebaseError, 'Firebase: A network AuthError (such as timeout]: interrupted connection or unreachable host) has occurred. (auth/network-request-failed).');
});
});
});
11 changes: 8 additions & 3 deletions packages-exp/auth-exp/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ export enum Endpoint {
WITHDRAW_MFA = '/v2/accounts/mfaEnrollment:withdraw'
}

export const DEFAULT_API_TIMEOUT = 30_000;

export async function performApiRequest<T, V>(
auth: Auth,
method: HttpMethod,
Expand All @@ -67,7 +69,7 @@ export async function performApiRequest<T, V>(
body: JSON.stringify(request)
}
: {};
const response = await fetch(
const response: Response = await Promise.race<Promise<Response>>([fetch(
`${auth.config.apiScheme}://${auth.config.apiHost}${path}?key=${auth.config.apiKey}`,
{
method,
Expand All @@ -77,7 +79,9 @@ export async function performApiRequest<T, V>(
referrerPolicy: 'no-referrer',
...body
}
);
), new Promise((_, reject) =>
setTimeout(() => reject(AUTH_ERROR_FACTORY.create(AuthErrorCode.TIMEOUT, { appName: auth.name })), DEFAULT_API_TIMEOUT)
)]);
if (response.ok) {
return response.json();
} else {
Expand All @@ -88,7 +92,8 @@ export async function performApiRequest<T, V>(
} else {
// TODO probably should handle improperly formatted errors as well
// If you see this, add an entry to SERVER_ERROR_MAP for the corresponding error
throw new Error(`Unexpected API error: ${json.error.message}`);
console.error(`Unexpected API error: ${json.error.message}`);
throw AUTH_ERROR_FACTORY.create(AuthErrorCode.INTERNAL_ERROR, { appName: auth.name });
}
}
} catch (e) {
Expand Down

0 comments on commit 6021352

Please sign in to comment.