Skip to content

Commit ab8e0f0

Browse files
authored
fix: shield coverage result polling (#6847)
## Explanation <!-- Thanks for your contribution! Take a moment to answer these questions so that reviewers have the information they need to properly understand your changes: * What is the current state of things and why does it need to change? * What is the solution your changes offer and how does it work? * Are there any changes whose purpose might not obvious to those unfamiliar with the domain? * If your primary goal was to update one package but you found you had to update another one along the way, why did you do so? * If you had to upgrade a dependency, why did you do so? --> This PR includes ~ - Updated internal coverage result polling and log logic. - Added cancellation logic to the polling. - Updated implementation of timeout. - Cancel any pending requests before starting new polling or logging. - Updated TransactionMeta comparison in `TransactionController:stateChange` subscriber to avoid triggering multiple check coverage result unnecessarily. - Removed `Personal Sign` check from the check signature coverage result. ## References <!-- Are there any issues that this pull request is tied to? Are there other links that reviewers should consult to understand these changes better? Are there client or consumer pull requests to adopt any breaking changes? For example: * Fixes #12345 * Related to #67890 --> ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary - [x] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Replaces manual shield coverage polling with a Cockatiel/controller-utils policy that supports retries and cancellation, updates error handling, tests, changelog, and adds required deps. > > - **Backend**: > - **Polling refactor**: Replace manual timeout loop in `ShieldRemoteBackend.#getCoverageResult` with `PollingWithCockatielPolicy`, using per-request IDs (`txMeta.id`/`signatureRequest.id`). > - **Cancellation**: Abort pending polls before `logSignature`/`logTransaction` via `#pollingPolicy.abortPendingRequest`. > - **Retry/backoff**: Compute retries/backoff with `ConstantBackoff` and `DEFAULT_MAX_RETRIES` (`computePollingIntervalAndRetryCount`). > - **Error handling**: Throw `HttpError` with parsed body message for non-200 responses. > - **New Utility**: > - Add `src/polling-with-policy.ts` implementing Cockatiel-based policy with retry filtering (retry 4xx except 400) and request tracking. > - **Tests**: > - Update `backend.test.ts` expectations for new timeout/error messages. > - Add `polling-with-policy.test.ts` covering retry, max-retry, cancellation, and concurrent requests. > - Add `tests/utils.delay` helper. > - **Dependencies**: > - Add `@metamask/controller-utils` and `cockatiel`. > - **Docs**: > - Update `CHANGELOG.md` noting optimized coverage-result polling. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit e13e024. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent d0f2e51 commit ab8e0f0

File tree

8 files changed

+362
-64
lines changed

8 files changed

+362
-64
lines changed

packages/shield-controller/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Fixed
11+
12+
- Fixed and optimized shield-coverage-result polling with Cockatiel Policy from Controller-utils. ([#6847](https://github.com/MetaMask/core/pull/6847))
13+
1014
## [1.0.0]
1115

1216
### Added

packages/shield-controller/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,10 @@
4848
},
4949
"dependencies": {
5050
"@metamask/base-controller": "^9.0.0",
51+
"@metamask/controller-utils": "^11.14.1",
5152
"@metamask/messenger": "^0.3.0",
52-
"@metamask/utils": "^11.8.1"
53+
"@metamask/utils": "^11.8.1",
54+
"cockatiel": "^3.1.2"
5355
},
5456
"devDependencies": {
5557
"@babel/runtime": "^7.23.9",

packages/shield-controller/src/backend.test.ts

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ describe('ShieldRemoteBackend', () => {
129129
expect(getAccessToken).toHaveBeenCalledTimes(1);
130130
});
131131

132-
it('should throw on check coverage timeout', async () => {
132+
it('should throw on check coverage timeout with coverage status', async () => {
133133
const { backend, fetchMock } = setup({
134134
getCoverageResultTimeout: 0,
135135
getCoverageResultPollInterval: 0,
@@ -144,12 +144,42 @@ describe('ShieldRemoteBackend', () => {
144144
// Mock get coverage result: result unavailable.
145145
fetchMock.mockResolvedValue({
146146
status: 404,
147-
json: jest.fn().mockResolvedValue({ status: 'unavailable' }),
148147
} as unknown as Response);
149148

150149
const txMeta = generateMockTxMeta();
151150
await expect(backend.checkCoverage({ txMeta })).rejects.toThrow(
152-
'Timeout waiting for coverage result',
151+
'Failed to get coverage result: 404',
152+
);
153+
154+
// Waiting here ensures coverage of the unexpected error and lets us know
155+
// that the polling loop is exited as expected.
156+
await new Promise((resolve) => setTimeout(resolve, 10));
157+
});
158+
159+
it('should throw on check coverage timeout', async () => {
160+
const { backend, fetchMock } = setup({
161+
getCoverageResultTimeout: 0,
162+
getCoverageResultPollInterval: 0,
163+
});
164+
165+
// Mock init coverage check.
166+
fetchMock.mockResolvedValueOnce({
167+
status: 200,
168+
json: jest.fn().mockResolvedValue({ coverageId: 'coverageId' }),
169+
} as unknown as Response);
170+
171+
// Mock get coverage result: result unavailable.
172+
fetchMock.mockResolvedValue({
173+
status: 412,
174+
json: jest.fn().mockResolvedValue({
175+
message: 'Results are not available yet',
176+
statusCode: 412,
177+
}),
178+
} as unknown as Response);
179+
180+
const txMeta = generateMockTxMeta();
181+
await expect(backend.checkCoverage({ txMeta })).rejects.toThrow(
182+
'Failed to get coverage result: Results are not available yet',
153183
);
154184

155185
// Waiting here ensures coverage of the unexpected error and lets us know

packages/shield-controller/src/backend.ts

Lines changed: 85 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
import {
2+
ConstantBackoff,
3+
DEFAULT_MAX_RETRIES,
4+
HttpError,
5+
} from '@metamask/controller-utils';
16
import {
27
EthMethod,
38
SignatureRequestType,
@@ -7,6 +12,7 @@ import type { TransactionMeta } from '@metamask/transaction-controller';
712
import type { Json } from '@metamask/utils';
813

914
import { SignTypedDataVersion } from './constants';
15+
import { PollingWithCockatielPolicy } from './polling-with-policy';
1016
import type {
1117
CheckCoverageRequest,
1218
CheckSignatureCoverageRequest,
@@ -56,14 +62,12 @@ export type GetCoverageResultResponse = {
5662
export class ShieldRemoteBackend implements ShieldBackend {
5763
readonly #getAccessToken: () => Promise<string>;
5864

59-
readonly #getCoverageResultTimeout: number;
60-
61-
readonly #getCoverageResultPollInterval: number;
62-
6365
readonly #baseUrl: string;
6466

6567
readonly #fetch: typeof globalThis.fetch;
6668

69+
readonly #pollingPolicy: PollingWithCockatielPolicy;
70+
6771
constructor({
6872
getAccessToken,
6973
getCoverageResultTimeout = 5000, // milliseconds
@@ -78,10 +82,18 @@ export class ShieldRemoteBackend implements ShieldBackend {
7882
fetch: typeof globalThis.fetch;
7983
}) {
8084
this.#getAccessToken = getAccessToken;
81-
this.#getCoverageResultTimeout = getCoverageResultTimeout;
82-
this.#getCoverageResultPollInterval = getCoverageResultPollInterval;
8385
this.#baseUrl = baseUrl;
8486
this.#fetch = fetchFn;
87+
88+
const { backoff, maxRetries } = computePollingIntervalAndRetryCount(
89+
getCoverageResultTimeout,
90+
getCoverageResultPollInterval,
91+
);
92+
93+
this.#pollingPolicy = new PollingWithCockatielPolicy({
94+
backoff,
95+
maxRetries,
96+
});
8597
}
8698

8799
async checkCoverage(req: CheckCoverageRequest): Promise<CoverageResult> {
@@ -95,9 +107,11 @@ export class ShieldRemoteBackend implements ShieldBackend {
95107
}
96108

97109
const txCoverageResultUrl = `${this.#baseUrl}/v1/transaction/coverage/result`;
98-
const coverageResult = await this.#getCoverageResult(coverageId, {
99-
coverageResultUrl: txCoverageResultUrl,
100-
});
110+
const coverageResult = await this.#getCoverageResult(
111+
req.txMeta.id,
112+
coverageId,
113+
txCoverageResultUrl,
114+
);
101115
return {
102116
coverageId,
103117
message: coverageResult.message,
@@ -119,9 +133,11 @@ export class ShieldRemoteBackend implements ShieldBackend {
119133
}
120134

121135
const signatureCoverageResultUrl = `${this.#baseUrl}/v1/signature/coverage/result`;
122-
const coverageResult = await this.#getCoverageResult(coverageId, {
123-
coverageResultUrl: signatureCoverageResultUrl,
124-
});
136+
const coverageResult = await this.#getCoverageResult(
137+
req.signatureRequest.id,
138+
coverageId,
139+
signatureCoverageResultUrl,
140+
);
125141
return {
126142
coverageId,
127143
message: coverageResult.message,
@@ -138,6 +154,9 @@ export class ShieldRemoteBackend implements ShieldBackend {
138154
...initBody,
139155
};
140156

157+
// cancel the pending get coverage result request
158+
this.#pollingPolicy.abortPendingRequest(req.signatureRequest.id);
159+
141160
const res = await this.#fetch(
142161
`${this.#baseUrl}/v1/signature/coverage/log`,
143162
{
@@ -159,6 +178,9 @@ export class ShieldRemoteBackend implements ShieldBackend {
159178
...initBody,
160179
};
161180

181+
// cancel the pending get coverage result request
182+
this.#pollingPolicy.abortPendingRequest(req.txMeta.id);
183+
162184
const res = await this.#fetch(
163185
`${this.#baseUrl}/v1/transaction/coverage/log`,
164186
{
@@ -188,51 +210,39 @@ export class ShieldRemoteBackend implements ShieldBackend {
188210
}
189211

190212
async #getCoverageResult(
213+
requestId: string,
191214
coverageId: string,
192-
configs: {
193-
coverageResultUrl: string;
194-
timeout?: number;
195-
pollInterval?: number;
196-
},
215+
coverageResultUrl: string,
197216
): Promise<GetCoverageResultResponse> {
198217
const reqBody: GetCoverageResultRequest = {
199218
coverageId,
200219
};
201220

202-
const timeout = configs?.timeout ?? this.#getCoverageResultTimeout;
203-
const pollInterval =
204-
configs?.pollInterval ?? this.#getCoverageResultPollInterval;
205-
206221
const headers = await this.#createHeaders();
207-
return await new Promise((resolve, reject) => {
208-
let timeoutReached = false;
209-
setTimeout(() => {
210-
timeoutReached = true;
211-
reject(new Error('Timeout waiting for coverage result'));
212-
}, timeout);
213-
214-
const poll = async (): Promise<GetCoverageResultResponse> => {
215-
// The timeoutReached variable is modified in the timeout callback.
216-
// eslint-disable-next-line no-unmodified-loop-condition
217-
while (!timeoutReached) {
218-
const startTime = Date.now();
219-
const res = await this.#fetch(configs.coverageResultUrl, {
220-
method: 'POST',
221-
headers,
222-
body: JSON.stringify(reqBody),
223-
});
224-
if (res.status === 200) {
225-
return (await res.json()) as GetCoverageResultResponse;
226-
}
227-
await sleep(pollInterval - (Date.now() - startTime));
228-
}
229-
// The following line will not have an effect as the upper level promise
230-
// will already be rejected by now.
231-
throw new Error('unexpected error');
232-
};
233-
234-
poll().then(resolve).catch(reject);
235-
});
222+
223+
const getCoverageResultFn = async (signal: AbortSignal) => {
224+
const res = await this.#fetch(coverageResultUrl, {
225+
method: 'POST',
226+
headers,
227+
body: JSON.stringify(reqBody),
228+
signal,
229+
});
230+
if (res.status === 200) {
231+
return (await res.json()) as GetCoverageResultResponse;
232+
}
233+
234+
// parse the error message from the response body
235+
let errorMessage = 'Timeout waiting for coverage result';
236+
try {
237+
const errorJson = await res.json();
238+
errorMessage = `Failed to get coverage result: ${errorJson.message || errorJson.status}`;
239+
} catch {
240+
errorMessage = `Failed to get coverage result: ${res.status}`;
241+
}
242+
throw new HttpError(res.status, errorMessage);
243+
};
244+
245+
return this.#pollingPolicy.start(requestId, getCoverageResultFn);
236246
}
237247

238248
async #createHeaders() {
@@ -244,16 +254,6 @@ export class ShieldRemoteBackend implements ShieldBackend {
244254
}
245255
}
246256

247-
/**
248-
* Sleep for a specified amount of time.
249-
*
250-
* @param ms - The number of milliseconds to sleep.
251-
* @returns A promise that resolves after the specified amount of time.
252-
*/
253-
async function sleep(ms: number) {
254-
return new Promise((resolve) => setTimeout(resolve, ms));
255-
}
256-
257257
/**
258258
* Make the body for the init coverage check request.
259259
*
@@ -324,3 +324,28 @@ export function parseSignatureRequestMethod(
324324

325325
return signatureRequest.type;
326326
}
327+
328+
/**
329+
* Compute the polling interval and retry count for the Cockatiel policy based on the timeout and poll interval given.
330+
*
331+
* @param timeout - The timeout in milliseconds.
332+
* @param pollInterval - The poll interval in milliseconds.
333+
* @returns The polling interval and retry count.
334+
*/
335+
function computePollingIntervalAndRetryCount(
336+
timeout: number,
337+
pollInterval: number,
338+
) {
339+
const backoff = new ConstantBackoff(pollInterval);
340+
const computedMaxRetries = Math.floor(timeout / pollInterval) + 1;
341+
342+
const maxRetries =
343+
isNaN(computedMaxRetries) || !isFinite(computedMaxRetries)
344+
? DEFAULT_MAX_RETRIES
345+
: computedMaxRetries;
346+
347+
return {
348+
backoff,
349+
maxRetries,
350+
};
351+
}

0 commit comments

Comments
 (0)