Commit 1f755e8b authored by Vignesh Mohankumar's avatar Vignesh Mohankumar Committed by GitHub

feat: add retry logic for dynamic imports (#6512)

* feat: add retry logic for lazy import

* try again

* add tests

* refactor: moves retry helper to subfolder

* missing-files

* fix

* doc comment

* tsdoc

* fake timers

* fix

* add eslint rule

* try again?

* try again?

* only dynamic

* try again

* try again

* IT WORKS

* add retry

* fix

* add test

* warn -> error

* lint

* lint

* lint

* add back cache

* rm test

* try again

* real timers but really short intervals

* try returning the promise?

* try returning the promise?

* try this package

* retry

* Update src/utils/retry.ts
Co-authored-by: default avatarZach Pomerantz <zzmp@uniswap.org>

* Update rules/enforce-retry-on-import.js
Co-authored-by: default avatarZach Pomerantz <zzmp@uniswap.org>

* Update rules/enforce-retry-on-import.js
Co-authored-by: default avatarZach Pomerantz <zzmp@uniswap.org>

* eslint_rules

* test fixes

* name

* fix

---------
Co-authored-by: default avatarZach Pomerantz <zzmp@uniswap.org>
parent f45a7f92
......@@ -2,13 +2,18 @@
require('@uniswap/eslint-config/load')
const rulesDirPlugin = require('eslint-plugin-rulesdir')
rulesDirPlugin.RULES_DIR = 'eslint_rules'
module.exports = {
extends: '@uniswap/eslint-config/react',
extends: ['@uniswap/eslint-config/react'],
plugins: ['rulesdir'],
overrides: [
{
files: ['**/*'],
rules: {
'multiline-comment-style': ['error', 'separate-lines'],
'rulesdir/enforce-retry-on-import': 'error',
},
},
{
......
/* eslint-env node */
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'enforce use of retry() for dynamic imports',
category: 'Best Practices',
recommended: false,
},
schema: [],
},
create(context) {
return {
ImportExpression(node) {
const grandParent = node.parent.parent
if (
!(
grandParent &&
grandParent.type === 'CallExpression' &&
// Technically, we are only checking that a function named `retry` wraps the dynamic import.
// We do not go as far as enforcing that it is import('utils/retry').retry
grandParent.callee.name === 'retry' &&
grandParent.arguments.length === 1 &&
grandParent.arguments[0].type === 'ArrowFunctionExpression'
)
) {
context.report({
node,
message: 'Dynamic import should be wrapped in retry (see `utils/retry.ts`): `retry(() => import(...))`',
})
}
},
}
},
}
......@@ -8,10 +8,11 @@ import useAccountRiskCheck from 'hooks/useAccountRiskCheck'
import { lazy } from 'react'
import { useModalIsOpen, useToggleModal } from 'state/application/hooks'
import { ApplicationModal } from 'state/application/reducer'
import { retry } from 'utils/retry'
const Bag = lazy(() => import('nft/components/bag/Bag'))
const TransactionCompleteModal = lazy(() => import('nft/components/collection/TransactionCompleteModal'))
const AirdropModal = lazy(() => import('components/AirdropModal'))
const Bag = lazy(() => retry(() => import('nft/components/bag/Bag')))
const TransactionCompleteModal = lazy(() => retry(() => import('nft/components/collection/TransactionCompleteModal')))
const AirdropModal = lazy(() => retry(() => import('components/AirdropModal')))
export default function TopLevelModals() {
const addressClaimOpen = useModalIsOpen(ApplicationModal.ADDRESS_CLAIM)
......
......@@ -35,6 +35,7 @@ import {
} from 'make-plural/plurals'
import { PluralCategory } from 'make-plural/plurals'
import { ReactNode, useEffect } from 'react'
import { retry } from 'utils/retry'
type LocalePlural = {
[key in SupportedLocale]: (n: number | string, ord?: boolean) => PluralCategory
......@@ -79,7 +80,7 @@ const plurals: LocalePlural = {
export async function dynamicActivate(locale: SupportedLocale) {
i18n.loadLocaleData(locale, { plurals: () => plurals[locale] })
try {
const catalog = await import(`locales/${locale}.js`)
const catalog = await retry(() => import(`locales/${locale}.js`))
// Bundlers will either export it as default or as a named export named default.
i18n.load(locale, catalog.messages || catalog.default.messages)
} catch (error: unknown) {
......
......@@ -18,6 +18,7 @@ import { flexRowNoWrap } from 'theme/styles'
import { Z_INDEX } from 'theme/zIndex'
import { STATSIG_DUMMY_KEY } from 'tracing'
import { getEnvName } from 'utils/env'
import { retry } from 'utils/retry'
import { getCLS, getFCP, getFID, getLCP, Metric } from 'web-vitals'
import { useAnalyticsReporter } from '../components/analytics'
......@@ -45,12 +46,12 @@ import Swap from './Swap'
import { RedirectPathToSwapOnly } from './Swap/redirects'
import Tokens from './Tokens'
const TokenDetails = lazy(() => import('./TokenDetails'))
const Vote = lazy(() => import('./Vote'))
const NftExplore = lazy(() => import('nft/pages/explore'))
const Collection = lazy(() => import('nft/pages/collection'))
const Profile = lazy(() => import('nft/pages/profile/profile'))
const Asset = lazy(() => import('nft/pages/asset/Asset'))
const TokenDetails = lazy(() => retry(() => import('./TokenDetails')))
const Vote = lazy(() => retry(() => import('./Vote')))
const NftExplore = lazy(() => retry(() => import('nft/pages/explore')))
const Collection = lazy(() => retry(() => import('nft/pages/collection')))
const Profile = lazy(() => retry(() => import('nft/pages/profile/profile')))
const Asset = lazy(() => retry(() => import('nft/pages/asset/Asset')))
const BodyWrapper = styled.div`
display: flex;
......
import { retry } from './retry'
describe('retry function', () => {
it('should resolve when function is successful', async () => {
const expectedResult = 'Success'
const mockFn = jest.fn().mockResolvedValue(expectedResult)
const result = await retry(mockFn)
expect(result).toEqual(expectedResult)
expect(mockFn).toHaveBeenCalledTimes(1)
})
it('should retry the specified number of times before rejecting', async () => {
const error = new Error('Failure')
const mockFn = jest.fn().mockRejectedValue(error)
await expect(retry(mockFn, 3, 1)).rejects.toEqual(error)
expect(mockFn).toHaveBeenCalledTimes(3)
})
it('should resolve when function is successful on the second attempt', async () => {
const expectedResult = 'Success'
const mockFn = jest.fn().mockRejectedValueOnce(new Error('Failure')).mockResolvedValue(expectedResult)
const result = await retry(mockFn, 3, 1)
expect(result).toEqual(expectedResult)
expect(mockFn).toHaveBeenCalledTimes(2)
})
})
/**
* Executes a Promise-based function multiple times with exponential backoff (doubling).
* @returns the result of the original function's final attempt.
*
* @example
* ```ts
* const fetchWithRetry = retry(fetchData, 5, 2000);
* fetchWithRetry.then(data => console.log(data)).catch(error => console.error(error));
* ```
*/
export function retry<T>(fn: () => Promise<T>, retries = 3, delay = 1000): Promise<T> {
return new Promise((resolve, reject) => {
const attempt = async (attempts: number, currentDelay: number) => {
try {
const result = await fn()
resolve(result)
} catch (error) {
if (attempts === retries) {
reject(error)
} else {
const exponentialBackoffDelay = currentDelay * 2
setTimeout(() => attempt(attempts + 1, exponentialBackoffDelay), currentDelay)
}
}
}
attempt(1, delay)
})
}
import type { TokenInfo, TokenList } from '@uniswap/token-lists'
import type { ValidateFunction } from 'ajv'
import { retry } from './retry'
enum ValidationSchema {
LIST = 'list',
TOKENS = 'tokens',
......@@ -17,17 +19,17 @@ async function validate(schema: ValidationSchema, data: unknown): Promise<unknow
let validatorImport
switch (schema) {
case ValidationSchema.LIST:
validatorImport = import('utils/__generated__/validateTokenList')
validatorImport = await retry(() => import('utils/__generated__/validateTokenList'))
break
case ValidationSchema.TOKENS:
validatorImport = import('utils/__generated__/validateTokens')
validatorImport = await retry(() => import('utils/__generated__/validateTokens'))
break
default:
throw new Error('No validation function specified for token list schema')
}
const [, validatorModule] = await Promise.all([import('ajv'), validatorImport])
const validator = (await validatorModule.default) as ValidateFunction
const [, validatorModule] = await Promise.all([retry(() => import('ajv')), validatorImport])
const validator = validatorModule.default as ValidateFunction
if (validator?.(data)) {
return data
}
......
......@@ -9943,6 +9943,11 @@ eslint-plugin-react@^7.27.1, eslint-plugin-react@^7.31.11:
semver "^6.3.0"
string.prototype.matchall "^4.0.8"
eslint-plugin-rulesdir@^0.2.2:
version "0.2.2"
resolved "https://registry.yarnpkg.com/eslint-plugin-rulesdir/-/eslint-plugin-rulesdir-0.2.2.tgz#84756ec39cd8503b1fe8af6a02a5da361e2bd076"
integrity sha512-qhBtmrWgehAIQeMDJ+Q+PnOz1DWUZMPeVrI0wE9NZtnpIMFUfh3aPKFYt2saeMSemZRrvUtjWfYwepsC8X+mjQ==
eslint-plugin-simple-import-sort@^8.0.0:
version "8.0.0"
resolved "https://registry.yarnpkg.com/eslint-plugin-simple-import-sort/-/eslint-plugin-simple-import-sort-8.0.0.tgz#9d9a2372b0606e999ea841b10458a370a6ccc160"
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment