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

refactor: feature toggle using hathor unleash client #421

Merged
merged 4 commits into from
Feb 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"@fortawesome/free-regular-svg-icons": "6.4.0",
"@fortawesome/free-solid-svg-icons": "6.4.0",
"@fortawesome/react-native-fontawesome": "0.2.7",
"@hathor/unleash-client": "0.1.0",
"@hathor/wallet-lib": "1.0.1",
"@notifee/react-native": "5.7.0",
"@react-native-async-storage/async-storage": "1.19.0",
Expand Down
129 changes: 32 additions & 97 deletions src/sagas/featureToggle.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,29 +7,25 @@

import { Platform } from 'react-native';
import VersionNumber from 'react-native-version-number';
import { UnleashClient, EVENTS as UnleashEvents } from 'unleash-proxy-client';
import UnleashClient, { FetchTogglesStatus } from '@hathor/unleash-client';
import { get } from 'lodash';

import {
takeEvery,
all,
call,
delay,
put,
cancelled,
select,
race,
take,
fork,
spawn,
takeEvery,
} from 'redux-saga/effects';
import { eventChannel } from 'redux-saga';
import { getUniqueId } from 'react-native-device-info';
import {
types,
setUnleashClient,
setFeatureToggles,
featureToggleInitialized,
types,
} from '../actions';
import {
UNLEASH_URL,
Expand All @@ -40,7 +36,6 @@ import {
} from '../constants';
import { disableFeaturesIfNeeded } from './helpers';

const CONNECT_TIMEOUT = 10000;
const MAX_RETRIES = 5;

export function* handleInitFailed(currentRetry) {
Expand Down Expand Up @@ -71,9 +66,10 @@ export function* fetchTogglesRoutine() {
const unleashClient = yield select((state) => state.unleashClient);

try {
// This call always make unleash to emit the event 'UPDATE',
// which by its turn triggers the action 'FEATURE_TOGGLE_UPDATE'
yield call(() => unleashClient.fetchToggles());
const state = yield call(() => unleashClient.fetchToggles());
if (state === FetchTogglesStatus.Updated) {
yield put({ type: types.FEATURE_TOGGLE_UPDATE });
}
} catch (e) {
// No need to do anything here as it will try again automatically in
// UNLEASH_POLLING_INTERVAL. Just prevent it from crashing the saga.
Expand All @@ -82,15 +78,19 @@ export function* fetchTogglesRoutine() {
}
}

export function* monitorFeatureFlags(currentRetry = 0) {
const unleashClient = new UnleashClient({
url: UNLEASH_URL,
clientKey: UNLEASH_CLIENT_KEY,
refreshInterval: -1,
disableRefresh: true, // Disable it, we will handle it ourselves
appName: `wallet-mobile-${Platform.OS}`,
});
export function* handleToggleUpdate() {
console.log('Handling feature toggle update');
const unleashClient = yield select((state) => state.unleashClient);
const networkSettings = yield select((state) => state.networkSettings);

const toggles = unleashClient.getToggles();
const featureToggles = disableFeaturesIfNeeded(networkSettings, mapFeatureToggles(toggles));

yield put(setFeatureToggles(featureToggles));
yield put({ type: types.FEATURE_TOGGLE_UPDATED });
}

export function* monitorFeatureFlags(currentRetry = 0) {
const { appVersion } = VersionNumber;

const options = {
Expand All @@ -102,86 +102,37 @@ export function* monitorFeatureFlags(currentRetry = 0) {
},
};

const unleashClient = new UnleashClient({
url: UNLEASH_URL,
clientKey: UNLEASH_CLIENT_KEY,
refreshInterval: -1,
disableRefresh: true, // Disable it, we will handle it ourselves
appName: `wallet-mobile-${Platform.OS}`,
context: options,
});

try {
yield call(() => unleashClient.updateContext(options));
yield put(setUnleashClient(unleashClient));

// Listeners should be set before unleashClient.start so we don't miss
// updates
yield fork(setupUnleashListeners, unleashClient);

// Start without awaiting it so we can listen for the
// READY event
unleashClient.start();

const { error, timeout } = yield race({
error: take(types.FEATURE_TOGGLE_ERROR),
success: take(types.FEATURE_TOGGLE_READY),
timeout: delay(CONNECT_TIMEOUT),
});

if (error || timeout) {
throw new Error('Error or timeout while connecting to unleash proxy.');
}
yield call(() => unleashClient.fetchToggles());

// Fork the routine to download toggles.
yield fork(fetchTogglesRoutine);

// At this point, unleashClient.start() already fetched the toggles
const featureToggles = mapFeatureToggles(unleashClient.toggles);
// At this point, unleashClient.fetchToggles() already fetched the toggles
// (this will throw if it hasn't)
const featureToggles = mapFeatureToggles(unleashClient.getToggles());

yield put(setFeatureToggles(featureToggles));
yield put(featureToggleInitialized());
} catch (e) {
console.error('Error initializing unleash');
unleashClient.stop();

yield put(setUnleashClient(null));

// Wait 500ms before retrying
yield delay(500);

// Spawn so it's detached from the current thread
yield spawn(handleInitFailed, currentRetry);
} finally {
if (yield cancelled()) {
yield call(() => unleashClient.stop());
}
}
}

export function* setupUnleashListeners(unleashClient) {
const channel = eventChannel((emitter) => {
const l1 = () => emitter({ type: types.FEATURE_TOGGLE_UPDATE });
const l2 = () => emitter({ type: types.FEATURE_TOGGLE_READY });
const l3 = (err) => emitter({ type: types.FEATURE_TOGGLE_ERROR, data: err });

unleashClient.on(UnleashEvents.UPDATE, l1);
unleashClient.on(UnleashEvents.READY, l2);
unleashClient.on(UnleashEvents.ERROR, l3);

return () => {
// XXX: This should be a cleanup but removeListener does not exist
// This will throw an error and it will interfere with other sagas
// Since it works without the cleanup i will leave this method empty
// until have determined the best cleanup approach
};
});

try {
while (true) {
const message = yield take(channel);

yield put({
type: message.type,
payload: message.data,
});
}
} finally {
if (yield cancelled()) {
// When we close the channel, it will remove the event listener
channel.close();
}
}
}

Expand All @@ -198,22 +149,6 @@ function mapFeatureToggles(toggles) {
}, {});
}

export function* handleToggleUpdate() {
const unleashClient = yield select((state) => state.unleashClient);
const featureTogglesInitialized = yield select((state) => state.featureTogglesInitialized);
const networkSettings = yield select((state) => state.networkSettings);

if (!unleashClient || !featureTogglesInitialized) {
return;
}

const { toggles } = unleashClient;
const featureToggles = disableFeaturesIfNeeded(networkSettings, mapFeatureToggles(toggles));

yield put(setFeatureToggles(featureToggles));
yield put({ type: types.FEATURE_TOGGLE_UPDATED });
}

export function* saga() {
yield all([
fork(monitorFeatureFlags),
Expand Down