Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Move template data under a new customData field #3946

Merged
merged 10 commits into from
Oct 19, 2020
5 changes: 5 additions & 0 deletions .changeset/stupid-dots-grab.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@firebase/util": patch
---

Write template data to a new `customData` field in` FirebaseError` instead of writing to the error object itself to avoid overwriting existing fields.
12 changes: 8 additions & 4 deletions packages-exp/auth-exp/src/api/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,7 @@ describe('api/_performApiRequest', () => {
assert.fail('Call should have failed');
} catch (e) {
expect(e.code).to.eq(`auth/${AuthErrorCode.NEED_CONFIRMATION}`);
expect(e._tokenResponse).to.eql({
expect((e as FirebaseError).customData!._tokenResponse).to.eql({
needConfirmation: true,
idToken: 'id-token'
});
Expand Down Expand Up @@ -314,7 +314,9 @@ describe('api/_performApiRequest', () => {
assert.fail('Call should have failed');
} catch (e) {
expect(e.code).to.eq(`auth/${AuthErrorCode.CREDENTIAL_ALREADY_IN_USE}`);
expect(e._tokenResponse).to.eql(response);
expect((e as FirebaseError).customData!._tokenResponse).to.eql(
response
);
}
});

Expand Down Expand Up @@ -343,8 +345,10 @@ describe('api/_performApiRequest', () => {
assert.fail('Call should have failed');
} catch (e) {
expect(e.code).to.eq(`auth/${AuthErrorCode.EMAIL_EXISTS}`);
expect(e.email).to.eq('email@test.com');
expect(e.phoneNumber).to.eq('+1555-this-is-a-number');
expect((e as FirebaseError).customData!.email).to.eq('email@test.com');
expect((e as FirebaseError).customData!.phoneNumber).to.eq(
'+1555-this-is-a-number'
);
}
});
});
Expand Down
4 changes: 3 additions & 1 deletion packages-exp/auth-exp/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,8 @@ function makeTaggedError(
}

const error = AUTH_ERROR_FACTORY.create(code, errorParams);
(error as TaggedWithTokenResponse)._tokenResponse = response;

// We know customData is defined on error because errorParams is defined
(error.customData! as TaggedWithTokenResponse)._tokenResponse = response;
return error;
}
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ describe('src/core/providers/facebook', () => {
const error = AUTH_ERROR_FACTORY.create(AuthErrorCode.NEED_CONFIRMATION, {
appName: 'foo'
});
(error as TaggedWithTokenResponse)._tokenResponse = {
(error.customData! as TaggedWithTokenResponse)._tokenResponse = {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Always having to use the ! seems weird. In storage, all errors are FirebaseStorageError which extends FirebaseError and makes customData non-optional. But I see auth-exp uses the shared ErrorFactory so I don't know if it would be really messy to get it to return an extended error type instead of FirebaseError.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also should a property of customData be underscore-prefixed? Not sure what that would mean.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid point. What other SDKs have done is do a is[Product]Error() check, which narrows the type to the specific error type, then you can access customData safely. See here as an example.

That said, I'm not too worried about them since they are all in test files.

I think the underscore means it's for internal usage only, which is fine. @samhorlbeck

...TEST_ID_TOKEN_RESPONSE,
oauthAccessToken: 'access-token'
};
Expand Down
2 changes: 1 addition & 1 deletion packages-exp/auth-exp/src/core/providers/facebook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export class FacebookAuthProvider extends OAuthProvider {
error: FirebaseError
): externs.OAuthCredential | null {
return FacebookAuthProvider.credentialFromTaggedObject(
error as TaggedWithTokenResponse
(error.customData || {}) as TaggedWithTokenResponse
);
}

Expand Down
2 changes: 1 addition & 1 deletion packages-exp/auth-exp/src/core/providers/github.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ describe('src/core/providers/github', () => {
const error = AUTH_ERROR_FACTORY.create(AuthErrorCode.NEED_CONFIRMATION, {
appName: 'foo'
});
(error as TaggedWithTokenResponse)._tokenResponse = {
(error.customData! as TaggedWithTokenResponse)._tokenResponse = {
...TEST_ID_TOKEN_RESPONSE,
oauthAccessToken: 'access-token'
};
Expand Down
2 changes: 1 addition & 1 deletion packages-exp/auth-exp/src/core/providers/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export class GithubAuthProvider extends OAuthProvider {
error: FirebaseError
): externs.OAuthCredential | null {
return GithubAuthProvider.credentialFromTaggedObject(
error as TaggedWithTokenResponse
(error.customData || {}) as TaggedWithTokenResponse
);
}

Expand Down
2 changes: 1 addition & 1 deletion packages-exp/auth-exp/src/core/providers/google.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ describe('src/core/providers/google', () => {
const error = AUTH_ERROR_FACTORY.create(AuthErrorCode.NEED_CONFIRMATION, {
appName: 'foo'
});
(error as TaggedWithTokenResponse)._tokenResponse = {
(error.customData! as TaggedWithTokenResponse)._tokenResponse = {
...TEST_ID_TOKEN_RESPONSE,
oauthAccessToken: 'access-token',
oauthIdToken: 'id-token'
Expand Down
2 changes: 1 addition & 1 deletion packages-exp/auth-exp/src/core/providers/google.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export class GoogleAuthProvider extends OAuthProvider {
error: FirebaseError
): externs.OAuthCredential | null {
return GoogleAuthProvider.credentialFromTaggedObject(
error as TaggedWithTokenResponse
(error.customData || {}) as TaggedWithTokenResponse
);
}

Expand Down
2 changes: 1 addition & 1 deletion packages-exp/auth-exp/src/core/providers/twitter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ describe('src/core/providers/twitter', () => {
const error = AUTH_ERROR_FACTORY.create(AuthErrorCode.NEED_CONFIRMATION, {
appName: 'foo'
});
(error as TaggedWithTokenResponse)._tokenResponse = {
(error.customData! as TaggedWithTokenResponse)._tokenResponse = {
...TEST_ID_TOKEN_RESPONSE,
oauthAccessToken: 'access-token',
oauthTokenSecret: 'token-secret'
Expand Down
2 changes: 1 addition & 1 deletion packages-exp/auth-exp/src/core/providers/twitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export class TwitterAuthProvider extends OAuthProvider {
error: FirebaseError
): externs.OAuthCredential | null {
return TwitterAuthProvider.credentialFromTaggedObject(
error as TaggedWithTokenResponse
(error.customData || {}) as TaggedWithTokenResponse
);
}

Expand Down
5 changes: 3 additions & 2 deletions packages-exp/auth-exp/src/mfa/mfa_error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,9 @@ export class MultiFactorError
Object.setPrototypeOf(this, MultiFactorError.prototype);
this.appName = auth.name;
this.code = error.code;
this.tenantid = auth.tenantId;
this.serverResponse = error.serverResponse as IdTokenMfaResponse;
this.tenantId = auth.tenantId ?? undefined;
this.serverResponse = error.customData!
.serverResponse as IdTokenMfaResponse;
}

static _fromErrorAndCredential(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ async function registerInstallation(
);
return set(appConfig, registeredInstallationEntry);
} catch (e) {
if (isServerError(e) && e.serverCode === 409) {
if (isServerError(e) && e.customData.serverCode === 409) {
// Server returned a "FID can not be used" error.
// Generate a new ID next time.
await remove(appConfig);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,10 @@ async function fetchAuthTokenFromServer(
await set(installations.appConfig, updatedInstallationEntry);
return authToken;
} catch (e) {
if (isServerError(e) && (e.serverCode === 401 || e.serverCode === 404)) {
if (
isServerError(e) &&
(e.customData.serverCode === 401 || e.customData.serverCode === 404)
) {
// Server returned a "FID not found" or a "Invalid authentication" error.
// Generate a new ID next time.
await remove(installations.appConfig);
Expand Down
2 changes: 1 addition & 1 deletion packages-exp/installations-exp/src/util/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export interface ServerErrorData {
serverStatus: string;
}

export type ServerError = FirebaseError & ServerErrorData;
export type ServerError = FirebaseError & { customData: ServerErrorData };

/** Returns true if error is a FirebaseError that is based on an error from the server. */
export function isServerError(error: unknown): error is ServerError {
Expand Down
10 changes: 6 additions & 4 deletions packages/analytics/src/get-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ async function attemptFetchDynamicConfigWithRetry(
}

const backoffMillis =
Number(e.httpStatus) === 503
Number(e.customData.httpStatus) === 503
? calculateBackoffMillis(
backoffCount,
retryData.intervalMillis,
Expand Down Expand Up @@ -284,16 +284,18 @@ function setAbortableTimeout(
});
}

type RetriableError = FirebaseError & { customData: { httpStatus: string } };

/**
* Returns true if the {@link Error} indicates a fetch request may succeed later.
*/
function isRetriableError(e: Error): boolean {
if (!(e instanceof FirebaseError)) {
function isRetriableError(e: Error): e is RetriableError {
if (!(e instanceof FirebaseError) || !e.customData) {
return false;
}

// Uses string index defined by ErrorData, which FirebaseError implements.
const httpStatus = Number(e['httpStatus']);
const httpStatus = Number(e.customData['httpStatus']);

return (
httpStatus === 429 ||
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ async function registerInstallation(
);
return set(appConfig, registeredInstallationEntry);
} catch (e) {
if (isServerError(e) && e.serverCode === 409) {
if (isServerError(e) && e.customData.serverCode === 409) {
// Server returned a "FID can not be used" error.
// Generate a new ID next time.
await remove(appConfig);
Expand Down
5 changes: 4 additions & 1 deletion packages/installations/src/helpers/refresh-auth-token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,10 @@ async function fetchAuthTokenFromServer(
await set(dependencies.appConfig, updatedInstallationEntry);
return authToken;
} catch (e) {
if (isServerError(e) && (e.serverCode === 401 || e.serverCode === 404)) {
if (
isServerError(e) &&
(e.customData.serverCode === 401 || e.customData.serverCode === 404)
) {
// Server returned a "FID not found" or a "Invalid authentication" error.
// Generate a new ID next time.
await remove(dependencies.appConfig);
Expand Down
2 changes: 1 addition & 1 deletion packages/installations/src/util/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export interface ServerErrorData {
serverStatus: string;
}

export type ServerError = FirebaseError & ServerErrorData;
export type ServerError = FirebaseError & { customData: ServerErrorData };

/** Returns true if error is a FirebaseError that is based on an error from the server. */
export function isServerError(error: unknown): error is ServerError {
Expand Down
7 changes: 4 additions & 3 deletions packages/remote-config/src/client/retrying_client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,16 +61,17 @@ export function setAbortableTimeout(
});
}

type RetriableError = FirebaseError & { customData: { httpStatus: string } };
/**
* Returns true if the {@link Error} indicates a fetch request may succeed later.
*/
function isRetriableError(e: Error): boolean {
if (!(e instanceof FirebaseError)) {
function isRetriableError(e: Error): e is RetriableError {
if (!(e instanceof FirebaseError) || !e.customData) {
return false;
}

// Uses string index defined by ErrorData, which FirebaseError implements.
const httpStatus = Number(e['httpStatus']);
const httpStatus = Number(e.customData['httpStatus']);

return (
httpStatus === 429 ||
Expand Down
14 changes: 10 additions & 4 deletions packages/remote-config/test/client/rest_client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,10 @@ describe('RestClient', () => {

await expect(fetchPromise)
.to.eventually.be.rejectedWith(FirebaseError, firebaseError.message)
.with.property('originalErrorMessage', 'Network request failed');
.with.nested.property(
'customData.originalErrorMessage',
'Network request failed'
);
});

it('throws on JSON parse failure', async () => {
Expand All @@ -154,7 +157,10 @@ describe('RestClient', () => {

await expect(fetchPromise)
.to.eventually.be.rejectedWith(FirebaseError, firebaseError.message)
.with.property('originalErrorMessage', 'Unexpected end of input');
.with.nested.property(
'customData.originalErrorMessage',
'Unexpected end of input'
);
});

it('handles 304 status code and empty body', async () => {
Expand Down Expand Up @@ -200,7 +206,7 @@ describe('RestClient', () => {

await expect(fetchPromise)
.to.eventually.be.rejectedWith(FirebaseError, error.message)
.with.property('httpStatus', 500);
.with.nested.property('customData.httpStatus', 500);
});

it('normalizes NO_CHANGE state to 304 status', async () => {
Expand Down Expand Up @@ -257,7 +263,7 @@ describe('RestClient', () => {

await expect(fetchPromise)
.to.eventually.be.rejectedWith(FirebaseError, error.message)
.with.property('httpStatus', status);
.with.nested.property('customData.httpStatus', status);
}
});
});
Expand Down
36 changes: 6 additions & 30 deletions packages/util/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,26 +69,16 @@ export interface ErrorData {
[key: string]: unknown;
}

export interface FirebaseError extends Error, ErrorData {
// Unique code for error - format is service/error-code-string.
readonly code: string;

// Developer-friendly error message.
readonly message: string;

// Always 'FirebaseError'.
readonly name: typeof ERROR_NAME;

// Where available - stack backtrace in a string.
readonly stack?: string;
}

// Based on code from:
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error#Custom_Error_Types
export class FirebaseError extends Error {
readonly name = ERROR_NAME;

constructor(readonly code: string, message: string) {
constructor(
readonly code: string,
message: string,
public customData?: Record<string, unknown>
) {
super(message);

// Fix For ES5
Expand Down Expand Up @@ -125,21 +115,7 @@ export class ErrorFactory<
// Service Name: Error message (service/code).
const fullMessage = `${this.serviceName}: ${message} (${fullCode}).`;

const error = new FirebaseError(fullCode, fullMessage);

// Keys with an underscore at the end of their name are not included in
// error.data for some reason.
// TODO: Replace with Object.entries when lib is updated to es2017.
for (const key of Object.keys(customData)) {
if (key.slice(-1) !== '_') {
if (key in error) {
console.warn(
`Overwriting FirebaseError base field "${key}" can cause unexpected behavior.`
);
}
error[key] = customData[key];
}
}
const error = new FirebaseError(fullCode, fullMessage, customData);

return error;
}
Expand Down
25 changes: 1 addition & 24 deletions packages/util/test/errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
* limitations under the License.
*/
import { assert } from 'chai';
import { stub } from 'sinon';
import { ErrorFactory, ErrorMap, FirebaseError } from '../src/errors';

type ErrorCode =
Expand Down Expand Up @@ -60,14 +59,7 @@ describe('FirebaseError', () => {
e.message,
"Fake: Could not find file: 'foo.txt' (fake/file-not-found)."
);
assert.equal(e.file, 'foo.txt');
});

it('anonymously replaces template values with data', () => {
const e = ERROR_FACTORY.create('anon-replace', { repl_: 'world' });
assert.equal(e.code, 'fake/anon-replace');
assert.equal(e.message, 'Fake: Hello, world! (fake/anon-replace).');
assert.isUndefined(e.repl_);
assert.equal(e.customData!.file, 'foo.txt');
});

it('uses "Error" as template when template is missing', () => {
Expand All @@ -88,21 +80,6 @@ describe('FirebaseError', () => {
);
});

it('warns if overwriting a base error field with custom data', () => {
const warnStub = stub(console, 'warn');
const e = ERROR_FACTORY.create('overwrite-field', {
code: 'overwritten code'
});
assert.equal(e.code, 'overwritten code');
// TODO: use sinon-chai for this.
assert.ok(
warnStub.calledOnceWith(
'Overwriting FirebaseError base field "code" can cause unexpected behavior.'
)
);
warnStub.restore();
});

it('has stack', () => {
const e = ERROR_FACTORY.create('generic-error');

Expand Down