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(27254): implement new remote-feature-flag-controller #4931

Open
wants to merge 6 commits into
base: main
Choose a base branch
from

Conversation

DDDDDanica
Copy link

@DDDDDanica DDDDDanica commented Nov 14, 2024

Explanation

Following the ADR here

Adds a new controller, remote-feature-flag-controller that fetches the remote feature flags and provide cache solution for consumers.

References

Related to #27254

Changelog

@metamask/remote-feature-flag-controller

ADDED: Initial release

Checklist

  • I've updated the test suite for new or updated code as appropriate
  • I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate
  • I've highlighted breaking changes using the "BREAKING" category above as appropriate
  • I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes

@DDDDDanica DDDDDanica force-pushed the feature/27254-feature-flag-controller branch 6 times, most recently from 78af7f4 to cd93f7a Compare November 15, 2024 03:13
Copy link

socket-security bot commented Nov 15, 2024

No dependency changes detected. Learn more about Socket for GitHub ↗︎

👍 No dependency changes detected in pull request

@DDDDDanica DDDDDanica force-pushed the feature/27254-feature-flag-controller branch from cd93f7a to 82f7997 Compare November 16, 2024 03:42
@DDDDDanica DDDDDanica self-assigned this Nov 16, 2024
@DDDDDanica DDDDDanica force-pushed the feature/27254-feature-flag-controller branch 2 times, most recently from 78ca22b to a7a3cf1 Compare November 19, 2024 11:53
@DDDDDanica DDDDDanica marked this pull request as ready for review November 19, 2024 23:04
@DDDDDanica DDDDDanica requested a review from a team as a code owner November 19, 2024 23:04
@DDDDDanica DDDDDanica force-pushed the feature/27254-feature-flag-controller branch 9 times, most recently from 0519340 to fcc71a0 Compare November 20, 2024 12:19
@DDDDDanica
Copy link
Author

@metamaskbot publish-preview

Copy link
Contributor

Preview builds have been published. See these instructions for more information about preview builds.

Expand for full list of packages and versions.
{
  "@metamask-previews/accounts-controller": "19.0.0-preview-fcc71a0",
  "@metamask-previews/address-book-controller": "6.0.1-preview-fcc71a0",
  "@metamask-previews/announcement-controller": "7.0.1-preview-fcc71a0",
  "@metamask-previews/approval-controller": "7.1.1-preview-fcc71a0",
  "@metamask-previews/assets-controllers": "44.0.1-preview-fcc71a0",
  "@metamask-previews/base-controller": "7.0.2-preview-fcc71a0",
  "@metamask-previews/build-utils": "3.0.1-preview-fcc71a0",
  "@metamask-previews/chain-controller": "0.1.3-preview-fcc71a0",
  "@metamask-previews/composable-controller": "9.0.1-preview-fcc71a0",
  "@metamask-previews/controller-utils": "11.4.3-preview-fcc71a0",
  "@metamask-previews/ens-controller": "15.0.0-preview-fcc71a0",
  "@metamask-previews/eth-json-rpc-provider": "4.1.6-preview-fcc71a0",
  "@metamask-previews/gas-fee-controller": "22.0.1-preview-fcc71a0",
  "@metamask-previews/json-rpc-engine": "10.0.1-preview-fcc71a0",
  "@metamask-previews/json-rpc-middleware-stream": "8.0.5-preview-fcc71a0",
  "@metamask-previews/keyring-controller": "18.0.0-preview-fcc71a0",
  "@metamask-previews/logging-controller": "6.0.2-preview-fcc71a0",
  "@metamask-previews/message-manager": "11.0.1-preview-fcc71a0",
  "@metamask-previews/multichain": "0.0.0-preview-fcc71a0",
  "@metamask-previews/name-controller": "8.0.1-preview-fcc71a0",
  "@metamask-previews/network-controller": "22.0.2-preview-fcc71a0",
  "@metamask-previews/notification-controller": "7.0.0-preview-fcc71a0",
  "@metamask-previews/notification-services-controller": "0.13.0-preview-fcc71a0",
  "@metamask-previews/permission-controller": "11.0.3-preview-fcc71a0",
  "@metamask-previews/permission-log-controller": "3.0.1-preview-fcc71a0",
  "@metamask-previews/phishing-controller": "12.3.0-preview-fcc71a0",
  "@metamask-previews/polling-controller": "12.0.1-preview-fcc71a0",
  "@metamask-previews/preferences-controller": "14.0.0-preview-fcc71a0",
  "@metamask-previews/profile-sync-controller": "1.0.2-preview-fcc71a0",
  "@metamask-previews/queued-request-controller": "7.0.1-preview-fcc71a0",
  "@metamask-previews/rate-limit-controller": "6.0.1-preview-fcc71a0",
  "@metamask-previews/selected-network-controller": "19.0.0-preview-fcc71a0",
  "@metamask-previews/signature-controller": "22.0.0-preview-fcc71a0",
  "@metamask-previews/transaction-controller": "39.0.0-preview-fcc71a0",
  "@metamask-previews/user-operation-controller": "18.0.0-preview-fcc71a0"
}

@DDDDDanica DDDDDanica force-pushed the feature/27254-feature-flag-controller branch 6 times, most recently from 153ebc9 to 0fab160 Compare November 21, 2024 21:20
@DDDDDanica DDDDDanica force-pushed the feature/27254-feature-flag-controller branch 4 times, most recently from a2d5828 to fe6c0a5 Compare November 26, 2024 01:48
@DDDDDanica DDDDDanica force-pushed the feature/27254-feature-flag-controller branch from fe6c0a5 to 6e0f005 Compare November 26, 2024 04:14
return [];
}

if (this.#isCacheExpired()) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
if (this.#isCacheExpired()) {
if (!this.#isCacheExpired()) {

Because we only want to return the data cached in state if the cache is NOT expired

Copy link
Author

Choose a reason for hiding this comment

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

Good catch ! Since the variable name changed, the logic should be reversed. Modified in 276357d

* @private
*/
#isCacheExpired(): boolean {
return Date.now() - this.state.cacheTimestamp < this.#fetchInterval;
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
return Date.now() - this.state.cacheTimestamp < this.#fetchInterval;
return Date.now() - this.state.cacheTimestamp > this.#fetchInterval;

Now that this is named isCacheExpired, it should return true when the cache is expired / no longer valid

Copy link
Author

Choose a reason for hiding this comment

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

Addressed 276357d

this.updateCache(flags.cachedData);
resolve(flags.cachedData);
}
return await promise;
Copy link
Contributor

@danjm danjm Nov 26, 2024

Choose a reason for hiding this comment

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

If there is no cached data, or if there is an API error, it looks like this promise will stay unresolved forever

Copy link
Contributor

Choose a reason for hiding this comment

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

Should we have a catch here?

Copy link
Author

Choose a reason for hiding this comment

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

  1. Agreed ! when there is no cached data, we should return []
  2. The error catch is handled in service side, hence we don't need catch here, hope it makes sense !
return {
        error: true,
        message: err.message || 'Unknown error',
        statusCode: err.response?.status?.toString() || null,
        statusText: err.response?.statusText || null,
        cachedData: cachedData || [],
        cacheTimestamp: cacheTimestamp || Date.now(),
      };

Addressed 276357d

Copy link
Contributor

Choose a reason for hiding this comment

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

What was the reason for handling the error as is done in the service?

Copy link
Contributor

Choose a reason for hiding this comment

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

I notice that the example gas price service does not handle errors this way (https://github.com/MetaMask/core/blob/main/examples/example-controllers/src/gas-prices-service/gas-prices-service.ts#L62)

@DDDDDanica DDDDDanica force-pushed the feature/27254-feature-flag-controller branch 2 times, most recently from 977ddbf to 276357d Compare November 26, 2024 11:46
@DDDDDanica DDDDDanica force-pushed the feature/27254-feature-flag-controller branch from 276357d to b25f847 Compare November 26, 2024 12:18
@DDDDDanica DDDDDanica force-pushed the feature/27254-feature-flag-controller branch from 7f893dc to a09cd92 Compare November 26, 2024 16:04
Copy link
Contributor

@danjm danjm left a comment

Choose a reason for hiding this comment

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

Everything looks good to me, except I have those last questions about the approach to error handling.

@joaoloureirop joaoloureirop requested a review from a team November 26, 2024 19:27
joaoloureirop
joaoloureirop previously approved these changes Nov 26, 2024
Copy link
Contributor

@mcmire mcmire left a comment

Choose a reason for hiding this comment

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

Some more suggestions. After that I think we are good here.

) {
return new RemoteFeatureFlagController({
messenger: options.messenger ?? getControllerMessenger(),
state: options.state ?? { remoteFeatureFlags: [], cacheTimestamp: 0 },
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: Won't the controller mix in defaults for state already? So we should be able to simply say:

Suggested change
state: options.state ?? { remoteFeatureFlags: [], cacheTimestamp: 0 },
state: options.state,

Copy link
Author

Choose a reason for hiding this comment

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

Good suggestion ! Also simplified messenger, addressed in 41bb909

).toHaveBeenCalledTimes(1);
});

it('should not affect existing cache when toggling disabled state', async () => {
Copy link
Contributor

Choose a reason for hiding this comment

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

Hmm. Is this test verifying the behavior of getRemoteFeatureFlags? Or is it verifying the behavior of disable? If the latter, then I wonder if it would be better to move this test for the describe for disable. You also may be able to simplify it; if we just want to verify disable doesn't change state then we can prepopulate that state in the controller.

Copy link
Author

Choose a reason for hiding this comment

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

Since this is primarily testing the disable functionality, I'll move to a different test and simplify it , addressed in 614997a0e55603083347c47f9e04d74a2055db50

  describe('disable', () => {
    it('should preserve cached flags but return empty array when disabled', async () => {

expect(fetchSpy).toHaveBeenCalledTimes(1);

// Simulate cache expiration
controller.update((state) => ({
Copy link
Contributor

Choose a reason for hiding this comment

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

Hmm, this won't work; only the controller can update its own state — you can't do that in a test.

Can we mock the current time using jest.useFakeTimers() and then use jest.setSystemTime() to fake-advance time instead?

Copy link
Author

Choose a reason for hiding this comment

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

that's a much better approach! addressed in 41bb909

this.#fetch(url, { cache: 'no-cache' }),
);

if (!response || !response.ok) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Is the check for !response needed here? response should not be null or undefined (the type says so)

Suggested change
if (!response || !response.ok) {
if (!response.ok) {

Copy link
Author

@DDDDDanica DDDDDanica Nov 27, 2024

Choose a reason for hiding this comment

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

addressed in 614997a

Comment on lines 155 to 156
statusCode: response?.status?.toString() || null,
statusText: response?.statusText || 'Error',
Copy link
Contributor

Choose a reason for hiding this comment

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

Are the ?. needed here? Are the || needed as well?

Suggested change
statusCode: response?.status?.toString() || null,
statusText: response?.statusText || 'Error',
statusCode: response.status.toString(),
statusText: response.statusText,

Copy link
Author

Choose a reason for hiding this comment

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

Agree, moved 614997a

Comment on lines 168 to 169
statusText: response.statusText || 'OK',
cachedData: data || [],
Copy link
Contributor

Choose a reason for hiding this comment

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

Is the || needed here? Also do we want to use ?? instead?

Suggested change
statusText: response.statusText || 'OK',
cachedData: data || [],
statusText: response.statusText,
cachedData: data ?? [],

Copy link
Author

Choose a reason for hiding this comment

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

thanks for the suggestion, added 614997a

const BASE_URL = 'https://client-config.api.cx.metamask.io/v1';

describe('ClientConfigApiService', () => {
let mockFetch: jest.Mock;
Copy link
Contributor

Choose a reason for hiding this comment

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

Instead of setting variables up top, what are your thoughts on setting them in the tests themselves? As it is, these variables are shared across all tests and that could lead to intermittent failures or difficult to debug tests. It also forces the reader to look up to find data that could be getting implicitly set up here.

If we need to hide some setup code because it doesn't matter to a test, can we use a mock object builder function like the one we've added in remote-feature-flag-controller.test.ts?

Copy link
Member

Choose a reason for hiding this comment

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

Copy link
Author

Choose a reason for hiding this comment

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

Thanks both, the mock builder is indeed better than leaving setup in beforeEach. Addressed in 614997a

expect(result).toStrictEqual({
error: true,
message: 'Network error',
statusCode: '503',
Copy link
Contributor

Choose a reason for hiding this comment

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

This is an example of why moving setup data into tests is a good idea. The reason that the statusCode is 503 on this line is because it's set on line 23. That isn't immediately obvious just from reading this test in isolation. And if that line changes it breaks this test (or any other test that relies on it).

Copy link
Author

@DDDDDanica DDDDDanica Nov 27, 2024

Choose a reason for hiding this comment

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

Makes total sense. It is definitely better to create a builder and pass in params for each test case 🙏🏻 Addressed in 614997a

Comment on lines 175 to 185
const err = error as Error & {
response?: { status: number; statusText: string };
};
return {
error: true,
message: err.message || 'Unknown error',
statusCode: err.response?.status?.toString() || null,
statusText: err.response?.statusText || null,
cachedData: cachedData || [],
cacheTimestamp: cacheTimestamp || Date.now(),
};
Copy link
Contributor

@mcmire mcmire Nov 26, 2024

Choose a reason for hiding this comment

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

Technically, error can be anything, so it may not be an Error object.

This is very ugly, but what about this (note: hasProperty comes from @metamask/utils):

Suggested change
const err = error as Error & {
response?: { status: number; statusText: string };
};
return {
error: true,
message: err.message || 'Unknown error',
statusCode: err.response?.status?.toString() || null,
statusText: err.response?.statusText || null,
cachedData: cachedData || [],
cacheTimestamp: cacheTimestamp || Date.now(),
};
const message = error instanceof Error ? error.message : 'Unknown error';
const response =
error instanceof TypeError &&
hasProperty(error, 'response') &&
error.response !== null &&
error.response !== undefined &&
hasProperty(error.response, 'status') &&
typeof error.response.status === 'string' &&
hasProperty(error.response, 'statusText') &&
typeof error.response.statusText !== 'string'
? (error.response as { status: string; statusText: string })
: null;
return {
error: true,
message,
statusCode: response ? response.status.toString() : 'Unknown error',
statusText: response ? response.statusText : null,
cachedData: cachedData || [],
cacheTimestamp: cacheTimestamp || Date.now(),
};


#policy: IPolicy;

#baseUrl = 'https://client-config.api.cx.metamask.io/v1';
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: Should this be a constant?

Copy link
Author

Choose a reason for hiding this comment

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

Good catch, added to a new constant file in 41bb909

cacheTimestamp: Date.now(),
};
} catch (error) {
console.error('Feature flag API request failed:', error);
Copy link
Member

Choose a reason for hiding this comment

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

Generally we should avoid console.error in libraries. If we directly call console here, there is no way for the client to control logging.

This case is a good example; upon failure we already have a network failure in the dev console. Adding an additional log might clutter the dev console even further without adding any more detail.

For debug information, I'd recommend using this debug logger: https://github.com/MetaMask/utils/blob/main/src/logging.ts
It lets you add log statements that can be easily enabled in development, but which are hidden by default.

message: string;
statusCode: string | null;
statusText: string | null;
cachedData: FeatureFlags;
Copy link
Member

Choose a reason for hiding this comment

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

Two questions:

  • It looks like cachedData is the only property here that is used. What are the other properties for?
  • Why is this called cachedData? Is there any non-cached data that this is differentiated from? It looks like this property contains the feature flag configuration.

@DDDDDanica DDDDDanica force-pushed the feature/27254-feature-flag-controller branch 5 times, most recently from c19b249 to f13e7b4 Compare November 27, 2024 17:03
@DDDDDanica DDDDDanica force-pushed the feature/27254-feature-flag-controller branch from f13e7b4 to 6ea8e51 Compare November 27, 2024 17:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
Status: Needs more work from the author
Development

Successfully merging this pull request may close these issues.

5 participants