diff --git a/standalone/src/init.ts b/standalone/src/init.ts index 603b5d181..1ff94d22d 100644 --- a/standalone/src/init.ts +++ b/standalone/src/init.ts @@ -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())]; @@ -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`); diff --git a/standalone/src/recording/index.tsx b/standalone/src/recording/index.tsx index 0ffed4da8..25cc2c3af 100644 --- a/standalone/src/recording/index.tsx +++ b/standalone/src/recording/index.tsx @@ -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'; @@ -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 { @@ -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( { @@ -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); } @@ -129,7 +128,7 @@ async function wsHandlerRecording(store: Store, ev: WebSocketMessage getProfilesByIds(store.getState(), [data.userID])); store.dispatch({ type: RECEIVED_CALL_PROFILE_IMAGES, data: { @@ -153,11 +152,11 @@ function deinitRecording() { delete window.callsClient; } -init({ +runWithRetry(() => init({ name: 'recording', reducer: recordingReducer, initStore: initRecordingStore, initCb: initRecording, wsHandler: wsHandlerRecording, closeCb: deinitRecording, -}); +})); diff --git a/webapp/src/utils.test.ts b/webapp/src/utils.test.ts index 1d2916496..fbfe1eafb 100644 --- a/webapp/src/utils.test.ts +++ b/webapp/src/utils.test.ts @@ -6,6 +6,9 @@ import { getWSConnectionURL, shouldRenderDesktopWidget, toHuman, + sleep, + runWithRetry, + maxAttemptsReachedErr, } from './utils'; describe('utils', () => { @@ -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); + }); + }); }); diff --git a/webapp/src/utils.ts b/webapp/src/utils.ts index 5e1b70fcb..6900c3a4d 100644 --- a/webapp/src/utils.ts +++ b/webapp/src/utils.ts @@ -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; +}