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

feat: Add retry policy to the data deletion service #24716

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,7 @@ import {
} from '@metamask/base-controller';
import { ObservableStore } from '@metamask/obs-store';
import { MetaMetricsControllerState } from '../metametrics';
import {
createDataDeletionRegulationTask,
fetchDeletionRegulationStatus,
} from './services/services';
import type { DataDeletionService } from './services/services';

// Unique name for the controller
const controllerName = 'MetaMetricsDataDeletionController';
Expand Down Expand Up @@ -109,19 +106,24 @@ export default class MetaMetricsDataDeletionController extends BaseController<
> {
private metaMetricsId;

#dataDeletionService: DataDeletionService;

/**
* Creates a MetaMetricsDataDeletionController instance.
*
* @param args - The arguments to this function.
* @param args.dataDeletionService - The service used for deleting data.
* @param args.messenger - Messenger used to communicate with BaseV2 controller.
* @param args.state - Initial state to set on this controller.
* @param args.metaMetricsStore
*/
constructor({
dataDeletionService,
messenger,
state,
metaMetricsStore,
}: {
dataDeletionService: DataDeletionService;
messenger: MetaMetricsDataDeletionControllerMessenger;
state?: MetaMetricsDataDeletionState;
metaMetricsStore: ObservableStore<MetaMetricsControllerState>;
Expand All @@ -134,6 +136,7 @@ export default class MetaMetricsDataDeletionController extends BaseController<
state: { ...defaultState, ...state },
});
this.metaMetricsId = metaMetricsStore.getState().metaMetricsId;
this.#dataDeletionService = dataDeletionService;
}

_formatDeletionDate() {
Expand All @@ -155,7 +158,10 @@ export default class MetaMetricsDataDeletionController extends BaseController<
throw new Error('MetaMetrics ID not found');
}

const { data } = await createDataDeletionRegulationTask(this.metaMetricsId);
const { data } =
await this.#dataDeletionService.createDataDeletionRegulationTask(
this.metaMetricsId,
);
this.update((state) => {
state.metaMetricsDataDeletionId = data?.regulateId;
state.metaMetricsDataDeletionDate = this._formatDeletionDate();
Expand All @@ -173,7 +179,10 @@ export default class MetaMetricsDataDeletionController extends BaseController<
throw new Error('Delete Regulation id not found');
}

const { data } = await fetchDeletionRegulationStatus(deleteRegulationId);
const { data } =
await this.#dataDeletionService.fetchDeletionRegulationStatus(
deleteRegulationId,
);

const regulation = data?.regulation;
this.update((state) => {
Expand Down
273 changes: 238 additions & 35 deletions app/scripts/controllers/metametrics-data-deletion/services/services.ts
Original file line number Diff line number Diff line change
@@ -1,47 +1,250 @@
import { hasProperty, isObject } from '@metamask/utils';
import {
circuitBreaker,
ConsecutiveBreaker,
ExponentialBackoff,
handleAll,
type IPolicy,
retry,
wrap,
CircuitState,
} from 'cockatiel';
import getFetchWithTimeout from '../../../../../shared/modules/fetch-with-timeout';
import { CurrentRegulationStatus, RegulationId } from '../types';

const analyticsDataDeletionSourceId =
process.env.ANALYTICS_DATA_DELETION_SOURCE_ID;
const analyticsDataDeletionEndpoint =
process.env.ANALYTICS_DATA_DELETION_ENDPOINT;
const DEFAULT_ANALYTICS_DATA_DELETION_SOURCE_ID =
process.env.ANALYTICS_DATA_DELETION_SOURCE_ID ?? 'test';
const DEFAULT_ANALYTICS_DATA_DELETION_ENDPOINT =
process.env.ANALYTICS_DATA_DELETION_ENDPOINT ??
'https://metametrics.metamask.test/';

const fetchWithTimeout = getFetchWithTimeout();

export async function createDataDeletionRegulationTask(
metaMetricsId: string,
): Promise<RegulationId> {
if (!analyticsDataDeletionEndpoint || !analyticsDataDeletionSourceId) {
throw new Error('Segment API source ID or endpoint not found');
}
/**
* The number of times we retry a specific failed request to the data deletion API.
*/
const RETRIES = 3;

/**
* The maximum conseutive failures allowed before treating the server as inaccessible, and
* breaking the circuit.
*
* Each update attempt will result (1 + retries) calls if the server is down.
*/
const MAX_CONSECUTIVE_FAILURES = (1 + RETRIES) * 3;

/**
* When the circuit breaks, we wait for this period of time (in milliseconds) before allowing
* a request to go through to the API.
*/
const CIRCUIT_BREAK_DURATION = 30 * 60 * 1000;

const response = await fetchWithTimeout(
`${analyticsDataDeletionEndpoint}/regulations/sources/${analyticsDataDeletionSourceId}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/vnd.segment.v1+json' },
body: JSON.stringify({
regulationType: 'DELETE_ONLY',
subjectType: 'USER_ID',
subjectIds: [metaMetricsId],
}),
},
/**
* The threshold (in milliseconds) for when a successful request is considered "degraded".
*/
const DEFAULT_DEGRADED_THRESHOLD = 5_000;

/**
* Type guard for Fetch network responses with a `statusCode` property.
*
* @param response - A suspected Fetch network response.
* @returns A type checked Fetch network response.
*/
function isValidResponse(
response: unknown,
): response is { statusCode: number } {
return (
isObject(response) &&
hasProperty(response, 'statusCode') &&
typeof response.statusCode === 'number'
);
return response.json();
}

export async function fetchDeletionRegulationStatus(
deleteRegulationId: string,
): Promise<CurrentRegulationStatus> {
if (!analyticsDataDeletionEndpoint) {
throw new Error('Segment API source ID or endpoint not found');
/**
* Returns `true` if the parameter is a Fetch network response with a status code that indiciates
* server failure.
*
* @param response - The response to check.
* @returns `true` if the response indicates a server failure, `false` otherwise.
*/
function onServerFailure(response: unknown) {
return isValidResponse(response) && response.statusCode >= 500;
}

/**
* Create a Cockatiel retry policy.
*
* This policy uses a retry and circuit breaker strategy. Callbacks are accepted for circuit breaks
* and degraded responses as well.
*
* @param args - Arguments
* @param args.circuitBreakDuration - The amount of time to wait when the circuit breaks
* from too many consecutive failures.
* @param args.degradedThreshold - The threshold between "normal" and "degrated" service,
* in milliseconds.
* @param args.maximumConsecutiveFailures - The maximum number of consecutive failures
* allowed before breaking the circuit and pausing further updates.
* @param args.onBreak - An event handler for when the circuit breaks, useful for capturing
* metrics about network failures.
* @param args.onDegraded - An event handler for when the circuit remains closed, but requests
* are failing or resolving too slowly (i.e. resolving more slowly than the `degradedThreshold`).
* @param args.retries - Number of retry attempts.
* @returns A Cockatiel retry policy.
*/
function createRetryPolicy({
circuitBreakDuration,
degradedThreshold,
maximumConsecutiveFailures,
onBreak,
onDegraded,
retries,
}: {
circuitBreakDuration: number;
degradedThreshold: number;
maximumConsecutiveFailures: number;
onBreak?: () => void;
onDegraded?: () => void;
retries: number;
}) {
const retryPolicy = retry(handleAll.orWhenResult(onServerFailure), {
maxAttempts: retries,
backoff: new ExponentialBackoff(),
});
const circuitBreakerPolicy = circuitBreaker(handleAll, {
halfOpenAfter: circuitBreakDuration,
breaker: new ConsecutiveBreaker(maximumConsecutiveFailures),
});
if (onBreak) {
circuitBreakerPolicy.onBreak(onBreak);
}
if (onDegraded) {
retryPolicy.onGiveUp(() => {
if (circuitBreakerPolicy.state === CircuitState.Closed) {
onDegraded();
}
});
retryPolicy.onSuccess(({ duration }) => {
if (
circuitBreakerPolicy.state === CircuitState.Closed &&
duration > degradedThreshold
) {
onDegraded();
}
});
}
return wrap(retryPolicy, circuitBreakerPolicy);
}

/**
* A serivce for requesting the deletion of analytics data.
*/
export class DataDeletionService {
#analyticsDataDeletionEndpoint: string;

#analyticsDataDeletionSourceId: string;

#fetchStatusPolicy: IPolicy;

#createDataDeletionTaskPolicy: IPolicy;

/**
* Construct a data deletion service.
*
* @param options - Options.
* @param options.analyticsDataDeletionEndpoint - The base URL for the data deletion API.
* @param options.analyticsDataDeletionSourceId - The Segment source ID to delete data from.
* @param options.onBreak - An event handler for when the circuit breaks, useful for capturing
* metrics about network failures.
* @param options.onDegraded - An event handler for when the circuit remains closed, but requests
* are failing or resolving too slowly (i.e. resolving more slowly than the `degradedThreshold`).
*/
constructor({
analyticsDataDeletionEndpoint = DEFAULT_ANALYTICS_DATA_DELETION_ENDPOINT,
analyticsDataDeletionSourceId = DEFAULT_ANALYTICS_DATA_DELETION_SOURCE_ID,
onBreak,
onDegraded,
}: {
analyticsDataDeletionEndpoint?: string;
analyticsDataDeletionSourceId?: string;
onBreak?: () => void;
onDegraded?: () => void;
} = {}) {
if (!analyticsDataDeletionEndpoint) {
throw new Error('Missing ANALYTICS_DATA_DELETION_ENDPOINT');
} else if (!analyticsDataDeletionSourceId) {
throw new Error('Missing ANALYTICS_DATA_DELETION_SOURCE_ID');
}
this.#analyticsDataDeletionEndpoint = analyticsDataDeletionEndpoint;
this.#analyticsDataDeletionSourceId = analyticsDataDeletionSourceId;
this.#createDataDeletionTaskPolicy = createRetryPolicy({
circuitBreakDuration: CIRCUIT_BREAK_DURATION,
degradedThreshold: DEFAULT_DEGRADED_THRESHOLD,
maximumConsecutiveFailures: MAX_CONSECUTIVE_FAILURES,
onBreak,
onDegraded,
retries: RETRIES,
});
this.#fetchStatusPolicy = createRetryPolicy({
circuitBreakDuration: CIRCUIT_BREAK_DURATION,
degradedThreshold: DEFAULT_DEGRADED_THRESHOLD,
maximumConsecutiveFailures: MAX_CONSECUTIVE_FAILURES,
onBreak,
onDegraded,
retries: RETRIES,
});
}

/**
* Submit a deletion request.
*
* We use Segment for this request. Segment calls this deletion request a "regulation", and
* returns a "regulation ID" to keep track of this request and get status updates for it.
*
* @param metaMetricsId - The ID associated with the analytics data that we will be deleting.
* @returns The regulation ID for the deletion request.
*/
async createDataDeletionRegulationTask(
metaMetricsId: string,
): Promise<RegulationId> {
const response = await this.#createDataDeletionTaskPolicy.execute(() =>
fetchWithTimeout(
`${this.#analyticsDataDeletionEndpoint}/regulations/sources/${
this.#analyticsDataDeletionSourceId
}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/vnd.segment.v1+json' },
body: JSON.stringify({
regulationType: 'DELETE_ONLY',
subjectType: 'USER_ID',
subjectIds: [metaMetricsId],
}),
},
),
);
return response.json();
}

/**
* Fetch the status of the given deletion request.
*
* @param deleteRegulationId - The Segment "regulation ID" for the deletion request to check.
* @returns The status of the given deletion request.
*/
async fetchDeletionRegulationStatus(
deleteRegulationId: string,
): Promise<CurrentRegulationStatus> {
const response = await this.#fetchStatusPolicy.execute(() =>
fetchWithTimeout(
`${
this.#analyticsDataDeletionEndpoint
}/regulations/${deleteRegulationId}`,
{
method: 'GET',
headers: { 'Content-Type': 'application/vnd.segment.v1+json' },
},
),
);
return response.json();
}
const response = await fetchWithTimeout(
`${analyticsDataDeletionEndpoint}/regulations/${deleteRegulationId}`,
{
method: 'GET',
headers: { 'Content-Type': 'application/vnd.segment.v1+json' },
},
);
return response.json();
}
3 changes: 3 additions & 0 deletions app/scripts/metamask-controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,7 @@ import UserStorageController from './controllers/user-storage/user-storage-contr
import { PushPlatformNotificationsController } from './controllers/push-platform-notifications/push-platform-notifications';
import { MetamaskNotificationsController } from './controllers/metamask-notifications/metamask-notifications';
import MetaMetricsDataDeletionController from './controllers/metametrics-data-deletion/metametrics-data-deletion';
import { DataDeletionService } from './controllers/metametrics-data-deletion/services/services';

export const METAMASK_CONTROLLER_EVENTS = {
// Fired after state changes that impact the extension badge (unapproved msg count)
Expand Down Expand Up @@ -744,12 +745,14 @@ export default class MetamaskController extends EventEmitter {
this.metaMetricsController.handleMetaMaskStateUpdate(update);
});

const dataDeletionService = new DataDeletionService();
const metaMetricsDataDeletionMessenger =
this.controllerMessenger.getRestricted({
name: 'MetaMetricsDataDeletionController',
});
this.metaMetricsDataDeletionController =
new MetaMetricsDataDeletionController({
dataDeletionService,
messenger: metaMetricsDataDeletionMessenger,
state: initState.metaMetricsDataDeletionController,
metaMetricsStore: this.metaMetricsController.store,
Expand Down
Loading