voluptasmollitia
Version:
Monorepo for the Firebase JavaScript SDK
325 lines (289 loc) • 9.72 kB
text/typescript
/**
* @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.
*/
/**
* @fileoverview Most logic is copied from packages/remote-config/src/client/retrying_client.ts
*/
import { FirebaseApp } from '@firebase/app-types';
import {
DynamicConfig,
ThrottleMetadata,
MinimalDynamicConfig
} from '@firebase/analytics-types';
import { FirebaseError, calculateBackoffMillis } from '@firebase/util';
import { AnalyticsError, ERROR_FACTORY } from './errors';
import { DYNAMIC_CONFIG_URL, FETCH_TIMEOUT_MILLIS } from './constants';
import { logger } from './logger';
// App config fields needed by analytics.
export interface AppFields {
appId: string;
apiKey: string;
measurementId?: string;
}
/**
* Backoff factor for 503 errors, which we want to be conservative about
* to avoid overloading servers. Each retry interval will be
* BASE_INTERVAL_MILLIS * LONG_RETRY_FACTOR ^ retryCount, so the second one
* will be ~30 seconds (with fuzzing).
*/
export const LONG_RETRY_FACTOR = 30;
/**
* Base wait interval to multiplied by backoffFactor^backoffCount.
*/
const BASE_INTERVAL_MILLIS = 1000;
/**
* Stubbable retry data storage class.
*/
class RetryData {
constructor(
public throttleMetadata: { [appId: string]: ThrottleMetadata } = {},
public intervalMillis: number = BASE_INTERVAL_MILLIS
) {}
getThrottleMetadata(appId: string): ThrottleMetadata {
return this.throttleMetadata[appId];
}
setThrottleMetadata(appId: string, metadata: ThrottleMetadata): void {
this.throttleMetadata[appId] = metadata;
}
deleteThrottleMetadata(appId: string): void {
delete this.throttleMetadata[appId];
}
}
const defaultRetryData = new RetryData();
/**
* Set GET request headers.
* @param apiKey App API key.
*/
function getHeaders(apiKey: string): Headers {
return new Headers({
Accept: 'application/json',
'x-goog-api-key': apiKey
});
}
/**
* Fetches dynamic config from backend.
* @param app Firebase app to fetch config for.
*/
export async function fetchDynamicConfig(
appFields: AppFields
): Promise<DynamicConfig> {
const { appId, apiKey } = appFields;
const request: RequestInit = {
method: 'GET',
headers: getHeaders(apiKey)
};
const appUrl = DYNAMIC_CONFIG_URL.replace('{app-id}', appId);
const response = await fetch(appUrl, request);
if (response.status !== 200 && response.status !== 304) {
let errorMessage = '';
try {
// Try to get any error message text from server response.
const jsonResponse = (await response.json()) as {
error?: { message?: string };
};
if (jsonResponse.error?.message) {
errorMessage = jsonResponse.error.message;
}
} catch (_ignored) {}
throw ERROR_FACTORY.create(AnalyticsError.CONFIG_FETCH_FAILED, {
httpStatus: response.status,
responseMessage: errorMessage
});
}
return response.json();
}
/**
* Fetches dynamic config from backend, retrying if failed.
* @param app Firebase app to fetch config for.
*/
export async function fetchDynamicConfigWithRetry(
app: FirebaseApp,
// retryData and timeoutMillis are parameterized to allow passing a different value for testing.
retryData: RetryData = defaultRetryData,
timeoutMillis?: number
): Promise<DynamicConfig | MinimalDynamicConfig> {
const { appId, apiKey, measurementId } = app.options;
if (!appId) {
throw ERROR_FACTORY.create(AnalyticsError.NO_APP_ID);
}
if (!apiKey) {
if (measurementId) {
return {
measurementId,
appId
};
}
throw ERROR_FACTORY.create(AnalyticsError.NO_API_KEY);
}
const throttleMetadata: ThrottleMetadata = retryData.getThrottleMetadata(
appId
) || {
backoffCount: 0,
throttleEndTimeMillis: Date.now()
};
const signal = new AnalyticsAbortSignal();
setTimeout(
async () => {
// Note a very low delay, eg < 10ms, can elapse before listeners are initialized.
signal.abort();
},
timeoutMillis !== undefined ? timeoutMillis : FETCH_TIMEOUT_MILLIS
);
return attemptFetchDynamicConfigWithRetry(
{ appId, apiKey, measurementId },
throttleMetadata,
signal,
retryData
);
}
/**
* Runs one retry attempt.
* @param appFields Necessary app config fields.
* @param throttleMetadata Ongoing metadata to determine throttling times.
* @param signal Abort signal.
*/
async function attemptFetchDynamicConfigWithRetry(
appFields: AppFields,
{ throttleEndTimeMillis, backoffCount }: ThrottleMetadata,
signal: AnalyticsAbortSignal,
retryData: RetryData = defaultRetryData // for testing
): Promise<DynamicConfig | MinimalDynamicConfig> {
const { appId, measurementId } = appFields;
// Starts with a (potentially zero) timeout to support resumption from stored state.
// Ensures the throttle end time is honored if the last attempt timed out.
// Note the SDK will never make a request if the fetch timeout expires at this point.
try {
await setAbortableTimeout(signal, throttleEndTimeMillis);
} catch (e) {
if (measurementId) {
logger.warn(
`Timed out fetching this Firebase app's measurement ID from the server.` +
` Falling back to the measurement ID ${measurementId}` +
` provided in the "measurementId" field in the local Firebase config. [${e.message}]`
);
return { appId, measurementId };
}
throw e;
}
try {
const response = await fetchDynamicConfig(appFields);
// Note the SDK only clears throttle state if response is success or non-retriable.
retryData.deleteThrottleMetadata(appId);
return response;
} catch (e) {
if (!isRetriableError(e)) {
retryData.deleteThrottleMetadata(appId);
if (measurementId) {
logger.warn(
`Failed to fetch this Firebase app's measurement ID from the server.` +
` Falling back to the measurement ID ${measurementId}` +
` provided in the "measurementId" field in the local Firebase config. [${e.message}]`
);
return { appId, measurementId };
} else {
throw e;
}
}
const backoffMillis =
Number(e.customData.httpStatus) === 503
? calculateBackoffMillis(
backoffCount,
retryData.intervalMillis,
LONG_RETRY_FACTOR
)
: calculateBackoffMillis(backoffCount, retryData.intervalMillis);
// Increments backoff state.
const throttleMetadata = {
throttleEndTimeMillis: Date.now() + backoffMillis,
backoffCount: backoffCount + 1
};
// Persists state.
retryData.setThrottleMetadata(appId, throttleMetadata);
logger.debug(`Calling attemptFetch again in ${backoffMillis} millis`);
return attemptFetchDynamicConfigWithRetry(
appFields,
throttleMetadata,
signal,
retryData
);
}
}
/**
* Supports waiting on a backoff by:
*
* <ul>
* <li>Promisifying setTimeout, so we can set a timeout in our Promise chain</li>
* <li>Listening on a signal bus for abort events, just like the Fetch API</li>
* <li>Failing in the same way the Fetch API fails, so timing out a live request and a throttled
* request appear the same.</li>
* </ul>
*
* <p>Visible for testing.
*/
function setAbortableTimeout(
signal: AnalyticsAbortSignal,
throttleEndTimeMillis: number
): Promise<void> {
return new Promise((resolve, reject) => {
// Derives backoff from given end time, normalizing negative numbers to zero.
const backoffMillis = Math.max(throttleEndTimeMillis - Date.now(), 0);
const timeout = setTimeout(resolve, backoffMillis);
// Adds listener, rather than sets onabort, because signal is a shared object.
signal.addEventListener(() => {
clearTimeout(timeout);
// If the request completes before this timeout, the rejection has no effect.
reject(
ERROR_FACTORY.create(AnalyticsError.FETCH_THROTTLE, {
throttleEndTimeMillis
})
);
});
});
}
type RetriableError = FirebaseError & { customData: { httpStatus: string } };
/**
* Returns true if the {@link Error} indicates a fetch request may succeed later.
*/
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.customData['httpStatus']);
return (
httpStatus === 429 ||
httpStatus === 500 ||
httpStatus === 503 ||
httpStatus === 504
);
}
/**
* Shims a minimal AbortSignal (copied from Remote Config).
*
* <p>AbortController's AbortSignal conveniently decouples fetch timeout logic from other aspects
* of networking, such as retries. Firebase doesn't use AbortController enough to justify a
* polyfill recommendation, like we do with the Fetch API, but this minimal shim can easily be
* swapped out if/when we do.
*/
export class AnalyticsAbortSignal {
listeners: Array<() => void> = [];
addEventListener(listener: () => void): void {
this.listeners.push(listener);
}
abort(): void {
this.listeners.forEach(listener => listener());
}
}