Skip to content

Commit 40611c1

Browse files
committed
Add support for API timeouts to auth-next (#2915)
* Add support for API timeouts to auth-next * PR feedback * [AUTOMATED]: Prettier Code Styling
1 parent f8aec19 commit 40611c1

File tree

3 files changed

+234
-14
lines changed

3 files changed

+234
-14
lines changed

packages-exp/auth-exp/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
"build": "rollup -c",
1818
"build:deps": "lerna run --scope @firebase/'{app,auth-exp}' --include-dependencies build",
1919
"dev": "rollup -c -w",
20-
"test": "yarn type-check && run-p lint test:browser test:node",
20+
"test": "yarn type-check && run-p lint test:browser",
2121
"test:browser": "karma start --single-run",
2222
"test:browser:debug": "karma start",
2323
"test:node": "TS_NODE_COMPILER_OPTIONS='{\"module\":\"commonjs\"}' nyc --reporter lcovonly -- mocha src/**/*.test.* --opts ../../config/mocha.node.opts",
+204
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
/**
2+
* @license
3+
* Copyright 2020 Google LLC
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
import { FirebaseError } from '@firebase/util';
19+
import { expect, use } from 'chai';
20+
import * as chaiAsPromised from 'chai-as-promised';
21+
import { SinonStub, stub, useFakeTimers } from 'sinon';
22+
import {
23+
DEFAULT_API_TIMEOUT_MS,
24+
Endpoint,
25+
HttpMethod,
26+
performApiRequest
27+
} from '.';
28+
import { mockEndpoint } from '../../test/api/helper';
29+
import { mockAuth } from '../../test/mock_auth';
30+
import * as mockFetch from '../../test/mock_fetch';
31+
import { ServerError } from './errors';
32+
import { AuthErrorCode } from '../core/errors';
33+
34+
use(chaiAsPromised);
35+
36+
describe('performApiRequest', () => {
37+
const request = {
38+
requestKey: 'request-value'
39+
};
40+
41+
const serverResponse = {
42+
responseKey: 'response-value'
43+
};
44+
45+
context('with regular requests', () => {
46+
beforeEach(mockFetch.setUp);
47+
afterEach(mockFetch.tearDown);
48+
49+
it('should set the correct request, method and HTTP Headers', async () => {
50+
const mock = mockEndpoint(Endpoint.SIGN_UP, serverResponse);
51+
const response = await performApiRequest<
52+
typeof request,
53+
typeof serverResponse
54+
>(mockAuth, HttpMethod.POST, Endpoint.SIGN_UP, request);
55+
expect(response).to.eql(serverResponse);
56+
expect(mock.calls.length).to.eq(1);
57+
expect(mock.calls[0].method).to.eq(HttpMethod.POST);
58+
expect(mock.calls[0].request).to.eql(request);
59+
expect(mock.calls[0].headers).to.eql({
60+
'Content-Type': 'application/json',
61+
'X-Client-Version': 'testSDK/0.0.0'
62+
});
63+
});
64+
65+
it('should translate server errors to auth errors', async () => {
66+
const mock = mockEndpoint(
67+
Endpoint.SIGN_UP,
68+
{
69+
error: {
70+
code: 400,
71+
message: ServerError.EMAIL_EXISTS,
72+
errors: [
73+
{
74+
message: ServerError.EMAIL_EXISTS
75+
}
76+
]
77+
}
78+
},
79+
400
80+
);
81+
const promise = performApiRequest<typeof request, typeof serverResponse>(
82+
mockAuth,
83+
HttpMethod.POST,
84+
Endpoint.SIGN_UP,
85+
request
86+
);
87+
await expect(promise).to.be.rejectedWith(
88+
FirebaseError,
89+
'Firebase: The email address is already in use by another account. (auth/email-already-in-use).'
90+
);
91+
expect(mock.calls[0].request).to.eql(request);
92+
});
93+
94+
it('should handle unknown server errors', async () => {
95+
const mock = mockEndpoint(
96+
Endpoint.SIGN_UP,
97+
{
98+
error: {
99+
code: 400,
100+
message: 'Awesome error',
101+
errors: [
102+
{
103+
message: 'Awesome error'
104+
}
105+
]
106+
}
107+
},
108+
400
109+
);
110+
const promise = performApiRequest<typeof request, typeof serverResponse>(
111+
mockAuth,
112+
HttpMethod.POST,
113+
Endpoint.SIGN_UP,
114+
request
115+
);
116+
await expect(promise).to.be.rejectedWith(
117+
FirebaseError,
118+
'Firebase: An internal AuthError has occurred. (auth/internal-error).'
119+
);
120+
expect(mock.calls[0].request).to.eql(request);
121+
});
122+
123+
it('should support custom error handling per endpoint', async () => {
124+
const mock = mockEndpoint(
125+
Endpoint.SIGN_UP,
126+
{
127+
error: {
128+
code: 400,
129+
message: ServerError.EMAIL_EXISTS,
130+
errors: [
131+
{
132+
message: ServerError.EMAIL_EXISTS
133+
}
134+
]
135+
}
136+
},
137+
400
138+
);
139+
const promise = performApiRequest<typeof request, typeof serverResponse>(
140+
mockAuth,
141+
HttpMethod.POST,
142+
Endpoint.SIGN_UP,
143+
request,
144+
{
145+
[ServerError.EMAIL_EXISTS]: AuthErrorCode.ARGUMENT_ERROR
146+
}
147+
);
148+
await expect(promise).to.be.rejectedWith(
149+
FirebaseError,
150+
'Firebase: Error (auth/argument-error).'
151+
);
152+
expect(mock.calls[0].request).to.eql(request);
153+
});
154+
});
155+
156+
context('with network issues', () => {
157+
let fetchStub: SinonStub;
158+
159+
beforeEach(() => {
160+
fetchStub = stub(self, 'fetch');
161+
});
162+
163+
afterEach(() => {
164+
fetchStub.restore();
165+
});
166+
167+
it('should handle timeouts', async () => {
168+
const clock = useFakeTimers();
169+
fetchStub.callsFake(() => {
170+
return new Promise<never>(() => null);
171+
});
172+
const promise = performApiRequest<typeof request, never>(
173+
mockAuth,
174+
HttpMethod.POST,
175+
Endpoint.SIGN_UP,
176+
request
177+
);
178+
clock.tick(DEFAULT_API_TIMEOUT_MS + 1);
179+
await expect(promise).to.be.rejectedWith(
180+
FirebaseError,
181+
'Firebase: The operation has timed out. (auth/timeout).'
182+
);
183+
clock.restore();
184+
});
185+
186+
it('should handle network failure', async () => {
187+
fetchStub.callsFake(() => {
188+
return new Promise<never>((_, reject) =>
189+
reject(new Error('network error'))
190+
);
191+
});
192+
const promise = performApiRequest<typeof request, never>(
193+
mockAuth,
194+
HttpMethod.POST,
195+
Endpoint.SIGN_UP,
196+
request
197+
);
198+
await expect(promise).to.be.rejectedWith(
199+
FirebaseError,
200+
'Firebase: A network AuthError (such as timeout]: interrupted connection or unreachable host) has occurred. (auth/network-request-failed).'
201+
);
202+
});
203+
});
204+
});

packages-exp/auth-exp/src/api/index.ts

+29-13
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ export enum Endpoint {
5353
WITHDRAW_MFA = '/v2/accounts/mfaEnrollment:withdraw'
5454
}
5555

56+
export const DEFAULT_API_TIMEOUT_MS = 30_000;
57+
5658
export async function performApiRequest<T, V>(
5759
auth: Auth,
5860
method: HttpMethod,
@@ -82,18 +84,29 @@ export async function performApiRequest<T, V>(
8284
})
8385
.join('&');
8486

85-
const response = await fetch(
86-
`${auth.config.apiScheme}://${auth.config.apiHost}${path}?${queryString}`,
87-
{
88-
method,
89-
headers: {
90-
'Content-Type': 'application/json',
91-
'X-Client-Version': auth.config.sdkClientVersion
92-
},
93-
referrerPolicy: 'no-referrer',
94-
...body
95-
}
96-
);
87+
const response: Response = await Promise.race<Promise<Response>>([
88+
fetch(
89+
`${auth.config.apiScheme}://${auth.config.apiHost}${path}?${queryString}`,
90+
{
91+
method,
92+
headers: {
93+
'Content-Type': 'application/json',
94+
'X-Client-Version': auth.config.sdkClientVersion
95+
},
96+
referrerPolicy: 'no-referrer',
97+
...body
98+
}
99+
),
100+
new Promise((_, reject) =>
101+
setTimeout(() => {
102+
return reject(
103+
AUTH_ERROR_FACTORY.create(AuthErrorCode.TIMEOUT, {
104+
appName: auth.name
105+
})
106+
);
107+
}, DEFAULT_API_TIMEOUT_MS)
108+
)
109+
]);
97110
if (response.ok) {
98111
return response.json();
99112
} else {
@@ -104,7 +117,10 @@ export async function performApiRequest<T, V>(
104117
} else {
105118
// TODO probably should handle improperly formatted errors as well
106119
// If you see this, add an entry to SERVER_ERROR_MAP for the corresponding error
107-
throw new Error(`Unexpected API error: ${json.error.message}`);
120+
console.error(`Unexpected API error: ${json.error.message}`);
121+
throw AUTH_ERROR_FACTORY.create(AuthErrorCode.INTERNAL_ERROR, {
122+
appName: auth.name
123+
});
108124
}
109125
}
110126
} catch (e) {

0 commit comments

Comments
 (0)