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

[MM-54192] Implement re-try logic for recording client #499

Merged
merged 1 commit into from
Aug 28, 2023
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
6 changes: 2 additions & 4 deletions standalone/src/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,8 +269,7 @@ export default async function init(cfg: InitConfig) {
store.dispatch(getCallsConfig()),
]);
} catch (err) {
logErr('failed to fetch channel data', err);
return;
throw new Error(`failed to fetch channel data: ${err}`);
}

const iceConfigs = [...iceServers(store.getState())];
Expand Down Expand Up @@ -357,9 +356,8 @@ export default async function init(cfg: InitConfig) {
try {
await cfg.initCb(store, theme, channelID);
} catch (err) {
logErr('initCb failed', err);
window.callsClient?.destroy();
return;
throw new Error(`initCb failed: ${err}`);
}

logDebug(`${cfg.name} init completed in ${Math.round(performance.now() - initStartTime)}ms`);
Expand Down
39 changes: 19 additions & 20 deletions standalone/src/recording/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {logErr} from 'plugin/log';
import {pluginId} from 'plugin/manifest';
import {voiceConnectedProfilesInChannel, voiceChannelCallStartAt} from 'plugin/selectors';
import {Store} from 'plugin/types/mattermost-webapp';
import {getProfilesByIds, getPluginPath, fetchTranslationsFile, setCallsGlobalCSSVars} from 'plugin/utils';
import {getProfilesByIds, getPluginPath, fetchTranslationsFile, setCallsGlobalCSSVars, runWithRetry} from 'plugin/utils';
import React from 'react';
import ReactDOM from 'react-dom';
import {IntlProvider} from 'react-intl';
Expand All @@ -30,17 +30,17 @@ async function fetchProfileImages(profiles: UserProfile[]) {
const promises = [];
for (const profile of profiles) {
promises.push(
fetch(`${getPluginPath()}/bot/users/${profile.id}/image`,
Client4.getOptions({method: 'get'})).then((res) => {
if (!res.ok) {
throw new Error('fetch failed');
}
return res.blob();
}).then((data) => {
profileImages[profile.id] = URL.createObjectURL(data);
}).catch((err) => {
logErr(err);
}));
runWithRetry(() => {
return fetch(`${getPluginPath()}/bot/users/${profile.id}/image`, Client4.getOptions({method: 'get'})).then((res) => {
if (!res.ok) {
throw new Error('image fetch failed');
}
return res.blob();
}).then((data) => {
profileImages[profile.id] = URL.createObjectURL(data);
});
}),
);
}

try {
Expand All @@ -54,10 +54,9 @@ async function fetchProfileImages(profiles: UserProfile[]) {

async function initRecordingStore(store: Store, channelID: string) {
try {
const channel = await Client4.doFetch(
`${getPluginPath()}/bot/channels/${channelID}`,
{method: 'get'},
);
const channel = await runWithRetry(() => {
return Client4.doFetch(`${getPluginPath()}/bot/channels/${channelID}`, {method: 'get'});
});

store.dispatch(
{
Expand Down Expand Up @@ -102,7 +101,7 @@ async function initRecording(store: Store, theme: Theme, channelID: string) {
let messages;
if (locale !== 'en') {
try {
messages = await fetchTranslationsFile(locale);
messages = await runWithRetry(() => fetchTranslationsFile(locale));
} catch (err) {
logErr('failed to fetch translations files', err);
}
Expand All @@ -129,7 +128,7 @@ async function wsHandlerRecording(store: Store, ev: WebSocketMessage<WebsocketEv
const data = ev.data as UserConnectedData;

try {
const profiles = await getProfilesByIds(store.getState(), [data.userID]);
const profiles = await runWithRetry(() => getProfilesByIds(store.getState(), [data.userID]));
store.dispatch({
type: RECEIVED_CALL_PROFILE_IMAGES,
data: {
Expand All @@ -153,11 +152,11 @@ function deinitRecording() {
delete window.callsClient;
}

init({
runWithRetry(() => init({
name: 'recording',
reducer: recordingReducer,
initStore: initRecordingStore,
initCb: initRecording,
wsHandler: wsHandlerRecording,
closeCb: deinitRecording,
});
}));
43 changes: 43 additions & 0 deletions webapp/src/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import {
getWSConnectionURL,
shouldRenderDesktopWidget,
toHuman,
sleep,
runWithRetry,
maxAttemptsReachedErr,
} from './utils';

describe('utils', () => {
Expand Down Expand Up @@ -197,5 +200,45 @@ describe('utils', () => {
expect(callStartedTimestampFn(intl, testCase.input)).toEqual(testCase.expected);
}));
});

describe('sleep', () => {
test('1s', async () => {
const sleepTimeMs = 500;
const start = Date.now();
await sleep(sleepTimeMs);
expect(Date.now() - start).toBeGreaterThan(sleepTimeMs);
});
});

describe('runWithRetry', () => {
const failsN = (n: number) => {
let failures = 0;
return () => {
if (failures === n) {
return 45;
}
failures++;
throw new Error('request failed');
};
};

test('single failure', async () => {
expect(await runWithRetry(failsN(1))).toEqual(45);
});

test('multiple failures', async () => {
expect(await runWithRetry(failsN(4))).toEqual(45);
});

test('with custom retry time', async () => {
const start = Date.now();
expect(await runWithRetry(failsN(1), 500)).toEqual(45);
expect(Date.now() - start).toBeGreaterThan(500);
});

test('maximum attempts reached', async () => {
await expect(runWithRetry(failsN(3), 10, 3)).rejects.toEqual(maxAttemptsReachedErr);
});
});
});

23 changes: 23 additions & 0 deletions webapp/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -477,3 +477,26 @@ export function userAgent(): string {
export function isDesktopApp(): boolean {
return userAgent().indexOf('Mattermost') !== -1 && userAgent().indexOf('Electron') !== -1;
}

export function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}

export const maxAttemptsReachedErr = new Error('maximum retry attempts reached');

export async function runWithRetry(fn: () => any, retryIntervalMs = 100, maxAttempts = 10) {
for (let i = 1; i < maxAttempts + 1; i++) {
try {
// eslint-disable-next-line no-await-in-loop
return await fn();
} catch (err) {
const waitMs = Math.floor((retryIntervalMs * i) + (Math.random() * retryIntervalMs));
logErr(err);
logDebug(`run failed (${i}), retrying in ${waitMs}ms`);
// eslint-disable-next-line no-await-in-loop
await sleep(waitMs);
}
}

throw maxAttemptsReachedErr;
}