-
-
Notifications
You must be signed in to change notification settings - Fork 188
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
base: main
Are you sure you want to change the base?
Conversation
78af7f4
to
cd93f7a
Compare
No dependency changes detected. Learn more about Socket for GitHub ↗︎ 👍 No dependency changes detected in pull request |
cd93f7a
to
82f7997
Compare
78ca22b
to
a7a3cf1
Compare
0519340
to
fcc71a0
Compare
@metamaskbot publish-preview |
Preview builds have been published. See these instructions for more information about preview builds. Expand for full list of packages and versions.
|
153ebc9
to
0fab160
Compare
a2d5828
to
fe6c0a5
Compare
fe6c0a5
to
6e0f005
Compare
return []; | ||
} | ||
|
||
if (this.#isCacheExpired()) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
if (this.#isCacheExpired()) { | |
if (!this.#isCacheExpired()) { |
Because we only want to return the data cached in state if the cache is NOT expired
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
- Agreed ! when there is no cached data, we should return
[]
- 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
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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)
977ddbf
to
276357d
Compare
276357d
to
b25f847
Compare
7f893dc
to
a09cd92
Compare
There was a problem hiding this 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.
There was a problem hiding this 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 }, |
There was a problem hiding this comment.
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:
state: options.state ?? { remoteFeatureFlags: [], cacheTimestamp: 0 }, | |
state: options.state, |
There was a problem hiding this comment.
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 () => { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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) => ({ |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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)
if (!response || !response.ok) { | |
if (!response.ok) { | |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
addressed in 614997a
statusCode: response?.status?.toString() || null, | ||
statusText: response?.statusText || 'Error', |
There was a problem hiding this comment.
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?
statusCode: response?.status?.toString() || null, | |
statusText: response?.statusText || 'Error', | |
statusCode: response.status.toString(), | |
statusText: response.statusText, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agree, moved 614997a
statusText: response.statusText || 'OK', | ||
cachedData: data || [], |
There was a problem hiding this comment.
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?
statusText: response.statusText || 'OK', | |
cachedData: data || [], | |
statusText: response.statusText, | |
cachedData: data ?? [], |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
See here for more related guidance: https://github.com/MetaMask/contributor-docs/blob/main/docs/testing/unit-testing.md#avoid-the-use-of-beforeeach
There was a problem hiding this comment.
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', |
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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
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(), | ||
}; |
There was a problem hiding this comment.
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
):
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'; |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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.
c19b249
to
f13e7b4
Compare
f13e7b4
to
6ea8e51
Compare
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