Skip to content

Commit

Permalink
feat: Add retry policy to the data deletion service
Browse files Browse the repository at this point in the history
The data deletion service now uses a retry policy. It has been
refactored into a class so that the retry state may be stored as an
instance variable. The service is now injected as well, rather than
imported, to keep it decoupled from the controller and make testing
easier.
  • Loading branch information
Gudahtt committed May 22, 2024
1 parent c20f93c commit 3dc92b1
Show file tree
Hide file tree
Showing 5 changed files with 210 additions and 39 deletions.
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 @@ -110,19 +107,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 @@ -135,6 +137,7 @@ export default class MetaMetricsDataDeletionController extends BaseController<
state: { ...defaultState, ...state },
});
this.metaMetricsId = metaMetricsStore.getState().metaMetricsId;
this.#dataDeletionService = dataDeletionService;
}

_formatDeletionDate() {
Expand All @@ -156,7 +159,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 @@ -174,7 +180,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
223 changes: 190 additions & 33 deletions app/scripts/controllers/metametrics-data-deletion/services/services.ts
Original file line number Diff line number Diff line change
@@ -1,47 +1,204 @@
import { hasProperty, isObject } from '@metamask/utils';
import getFetchWithTimeout from '../../../../../shared/modules/fetch-with-timeout';
import { CurrentRegulationStatus, RegulationId } from '../types';
import {
circuitBreaker,
ConsecutiveBreaker,
ExponentialBackoff,
handleAll,
type IPolicy,
retry,
wrap,
CircuitState,
} from 'cockatiel';

const analyticsDataDeletionSourceId =
const DEFAULT_ANALYTICS_DATA_DELETION_SOURCE_ID =
process.env.ANALYTICS_DATA_DELETION_SOURCE_ID;
const analyticsDataDeletionEndpoint =
const DEFAULT_ANALYTICS_DATA_DELETION_ENDPOINT =
process.env.ANALYTICS_DATA_DELETION_ENDPOINT;

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');
}
const RETRIES = 3;
// Each update attempt will result (1 + retries) calls if the server is down
const MAX_CONSECUTIVE_FAILURES = (1 + RETRIES) * 3;

const DEFAULT_DEGRADED_THRESHOLD = 5_000;

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],
}),
},
/**
* 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);
}

export class DataDeletionService {
#analyticsDataDeletionEndpoint: string;

#analyticsDataDeletionSourceId: string;

#fetchStatusPolicy: IPolicy;

#createDataDeletionTaskPolicy: IPolicy;

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,
});
}

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();
}

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 @@ -325,6 +325,7 @@ import { WeakRefObjectMap } from './lib/WeakRefObjectMap';

import { PushPlatformNotificationsController } from './controllers/push-platform-notifications/push-platform-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 @@ -742,12 +743,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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,7 @@
"bn.js": "^5.2.1",
"bowser": "^2.11.0",
"classnames": "^2.2.6",
"cockatiel": "^3.1.2",
"contentful": "^10.8.7",
"copy-to-clipboard": "^3.3.3",
"currency-formatter": "^1.4.2",
Expand Down
1 change: 1 addition & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -25512,6 +25512,7 @@ __metadata:
chalk: "npm:^4.1.2"
chokidar: "npm:^3.5.3"
classnames: "npm:^2.2.6"
cockatiel: "npm:^3.1.2"
concurrently: "npm:^8.2.2"
contentful: "npm:^10.8.7"
copy-to-clipboard: "npm:^3.3.3"
Expand Down

0 comments on commit 3dc92b1

Please sign in to comment.