Skip to content
5 changes: 5 additions & 0 deletions packages/remote-feature-flag-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- Add custom backoff interval support in `ClientConfigApiService` ([#6922](https://github.com/MetaMask/core/pull/6922))
- New `customBackoffInterval` parameter allows specifying exact retry intervals in seconds
- Example: `[100, 200, 300]` means 1st retry after 100s, 2nd after 200s, 3rd after 300s
- Falls back to default exponential backoff when no custom intervals provided
- Includes validation to ensure intervals array is compatible with retry configuration
- Bump `@metamask/base-controller` from `^8.4.1` to `^8.4.2` ([#6917](https://github.com/MetaMask/core/pull/6917))

## [1.9.0]
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ClientConfigApiService } from './client-config-api-service';
import { BASE_URL } from '../constants';
import type {
ApiDataResponse,
Expand All @@ -8,7 +9,6 @@ import {
DistributionType,
EnvironmentType,
} from '../remote-feature-flag-controller-types';
import { ClientConfigApiService } from './client-config-api-service';

const mockServerFeatureFlagsResponse: ApiDataResponse = [
{ feature1: false },
Expand Down Expand Up @@ -245,10 +245,144 @@ describe('ClientConfigApiService', () => {
expect(onDegraded).toHaveBeenCalled();
}, 7000);
});

describe('customBackoffInterval', () => {
it('should accept and use custom backoff intervals', async () => {
const mockFetch = createMockFetch({ error: networkError });
const customIntervals = [1, 2, 3]; // 1s, 2s, 3s

const clientConfigApiService = new ClientConfigApiService({
fetch: mockFetch,
retries: 3,
customBackoffInterval: customIntervals,
config: {
client: ClientType.Extension,
distribution: DistributionType.Main,
environment: EnvironmentType.Production,
},
});

await expect(
clientConfigApiService.fetchRemoteFeatureFlags(),
).rejects.toThrow(networkError);

// Verify that fetch was called the expected number of times
expect(mockFetch).toHaveBeenCalledTimes(4); // Initial + 3 retries
});

it('should validate custom backoff intervals array', () => {
const mockFetch = createMockFetch();

// Test empty array
expect(() => {
new ClientConfigApiService({
fetch: mockFetch,
retries: 3,
customBackoffInterval: [],
config: {
client: ClientType.Extension,
distribution: DistributionType.Main,
environment: EnvironmentType.Production,
},
});
}).toThrow('customBackoffInterval array cannot be empty');

// Test non-positive numbers
expect(() => {
new ClientConfigApiService({
fetch: mockFetch,
retries: 3,
customBackoffInterval: [1, -2, 3],
config: {
client: ClientType.Extension,
distribution: DistributionType.Main,
environment: EnvironmentType.Production,
},
});
}).toThrow('All customBackoffInterval values must be positive numbers');

// Test array longer than retries
expect(() => {
new ClientConfigApiService({
fetch: mockFetch,
retries: 2,
customBackoffInterval: [1, 2, 3, 4],
config: {
client: ClientType.Extension,
distribution: DistributionType.Main,
environment: EnvironmentType.Production,
},
});
}).toThrow(
'customBackoffInterval array length (4) must be equal to maxRetries (2)',
);

// Test array shorter than retries
expect(() => {
new ClientConfigApiService({
fetch: mockFetch,
retries: 3,
customBackoffInterval: [1, 2],
config: {
client: ClientType.Extension,
distribution: DistributionType.Main,
environment: EnvironmentType.Production,
},
});
}).toThrow(
'customBackoffInterval array length (2) must be equal to maxRetries (3)',
);
});

it('should fall back to exponential backoff when no custom intervals provided', async () => {
const mockFetch = createMockFetch({ error: networkError });

const clientConfigApiService = new ClientConfigApiService({
fetch: mockFetch,
retries: 2,
config: {
client: ClientType.Extension,
distribution: DistributionType.Main,
environment: EnvironmentType.Production,
},
});

await expect(
clientConfigApiService.fetchRemoteFeatureFlags(),
).rejects.toThrow(networkError);

// Verify that fetch was called the expected number of times
expect(mockFetch).toHaveBeenCalledTimes(3); // Initial + 2 retries
});

it('should use custom backoff intervals that exactly match max retries', async () => {
const mockFetch = createMockFetch({ error: networkError });
const customIntervals = [1, 2, 3]; // Exactly 3 intervals for 3 retries

const clientConfigApiService = new ClientConfigApiService({
fetch: mockFetch,
retries: 3,
customBackoffInterval: customIntervals,
config: {
client: ClientType.Extension,
distribution: DistributionType.Main,
environment: EnvironmentType.Production,
},
});

await expect(
clientConfigApiService.fetchRemoteFeatureFlags(),
).rejects.toThrow(networkError);

// Should retry the full number of times using the custom intervals
expect(mockFetch).toHaveBeenCalledTimes(4); // Initial + 3 retries
});
});
});

/**
* Creates a mock fetch function with configurable response data and options
*
* @template T - The type of data to be returned by the fetch response
* @param params - Configuration parameters
* @param params.response - Optional Response properties to override defaults
Expand All @@ -257,20 +391,26 @@ describe('ClientConfigApiService', () => {
* @returns A Jest mock function that resolves with a fetch-like Response object (or rejects with error if provided)
*/
function createMockFetch({
response,
response = {
ok: true,
status: 200,
json: async () => mockServerFeatureFlagsResponse,
},
error,
delay = 0,
}: {
response?: Partial<Response>;
error?: Error;
delay?: number;
}) {
} = {}) {
if (error) {
return jest
.fn()
.mockImplementation(
() =>
new Promise((_, reject) => setTimeout(() => reject(error), delay)),
new Promise((_resolve, reject) =>
setTimeout(() => reject(error), delay),
),
);
}

Expand Down
Loading
Loading