From a62243fe5c45b7dd9be6e92f82ebdf0b64bd8f0d Mon Sep 17 00:00:00 2001 From: Cafe137 <77121044+Cafe137@users.noreply.github.com> Date: Thu, 12 Aug 2021 14:40:33 +0200 Subject: [PATCH] feat: add retry to accounting (#166) * feat: add retry to accounting * fix: fix off by one bug in retry logic * docs: add jsdocs to new utility functions * style: rename DepositModal to CheckoutModal --- src/components/CashoutModal.tsx | 8 ++-- src/hooks/accounting.ts | 15 +++---- src/utils/index.ts | 74 +++++++++++++++++++++++++++++++++ 3 files changed, 85 insertions(+), 12 deletions(-) diff --git a/src/components/CashoutModal.tsx b/src/components/CashoutModal.tsx index 47b889c..7c89f50 100644 --- a/src/components/CashoutModal.tsx +++ b/src/components/CashoutModal.tsx @@ -1,15 +1,13 @@ -import { ReactElement, useState } from 'react' +import { CircularProgress, Container } from '@material-ui/core' import Button from '@material-ui/core/Button' import Dialog from '@material-ui/core/Dialog' import DialogActions from '@material-ui/core/DialogActions' import DialogContent from '@material-ui/core/DialogContent' import DialogContentText from '@material-ui/core/DialogContentText' import DialogTitle from '@material-ui/core/DialogTitle' -import { Container, CircularProgress } from '@material-ui/core' import { useSnackbar } from 'notistack' - +import { ReactElement, useState } from 'react' import { beeDebugApi } from '../services/bee' - import EthereumAddress from './EthereumAddress' interface Props { @@ -17,7 +15,7 @@ interface Props { uncashedAmount: string } -export default function DepositModal({ peerId, uncashedAmount }: Props): ReactElement { +export default function CheckoutModal({ peerId, uncashedAmount }: Props): ReactElement { const [open, setOpen] = useState(false) const [loadingCashout, setLoadingCashout] = useState(false) const { enqueueSnackbar } = useSnackbar() diff --git a/src/hooks/accounting.ts b/src/hooks/accounting.ts index 6371ecf..6480f97 100644 --- a/src/hooks/accounting.ts +++ b/src/hooks/accounting.ts @@ -2,6 +2,7 @@ import { LastCashoutActionResponse } from '@ethersphere/bee-js' import { useEffect, useState } from 'react' import { Token } from '../models/Token' import { beeDebugApi } from '../services/bee' +import { makeRetriablePromise, unwrapPromiseSettlements } from '../utils' import { Balance, Settlement, useApiPeerBalances, useApiSettlements } from './apiHooks' interface UseAccountingHook { @@ -80,11 +81,10 @@ export const useAccounting = (): UseAccountingHook => { const settlements = useApiSettlements() const balances = useApiPeerBalances() - const [err, setErr] = useState(null) const [isLoadingUncashed, setIsloadingUncashed] = useState(false) const [uncashedAmounts, setUncashedAmounts] = useState(undefined) - const error = balances.error || settlements.error || err + const error = balances.error || settlements.error useEffect(() => { // We don't have any settlements loaded yet or we are already loading/have loaded the uncashed amounts @@ -93,12 +93,13 @@ export const useAccounting = (): UseAccountingHook => { setIsloadingUncashed(true) const promises = settlements.settlements.settlements .filter(({ received }) => received.toBigNumber.gt('0')) - .map(({ peer }) => beeDebugApi.chequebook.getPeerLastCashout(peer)) + .map(({ peer }) => makeRetriablePromise(() => beeDebugApi.chequebook.getPeerLastCashout(peer))) - Promise.all(promises) - .then(setUncashedAmounts) - .catch(setErr) - .finally(() => setIsloadingUncashed(false)) + Promise.allSettled(promises).then(settlements => { + const results = unwrapPromiseSettlements(settlements) + setUncashedAmounts(results.fulfilled) + setIsloadingUncashed(false) + }) }, [settlements, isLoadingUncashed, uncashedAmounts, error]) const accounting = mergeAccounting(balances.peerBalances, settlements.settlements?.settlements, uncashedAmounts) diff --git a/src/utils/index.ts b/src/utils/index.ts index 618cfd4..7dfef23 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -32,3 +32,77 @@ export function makeBigNumber(value: BigNumber | BigInt | number | string): BigN throw new TypeError(`Not a BigNumber or BigNumber convertible value. Type: ${typeof value} value: ${value}`) } + +export type PromiseSettlements = { + fulfilled: PromiseFulfilledResult[] + rejected: PromiseRejectedResult[] +} + +export type UnwrappedPromiseSettlements = { + fulfilled: T[] + rejected: string[] +} + +export async function sleepMs(ms: number): Promise { + await new Promise(resolve => + setTimeout(() => { + resolve() + }, ms), + ) +} + +/** + * Maps the returned results of `Promise.allSettled` to an object + * with `fulfilled` and `rejected` arrays for easy access. + * + * The results still need to be unwrapped to get the fulfilled values or rejection reasons. + */ +export function mapPromiseSettlements(promises: PromiseSettledResult[]): PromiseSettlements { + const fulfilled = promises.filter(promise => promise.status === 'fulfilled') as PromiseFulfilledResult[] + const rejected = promises.filter(promise => promise.status === 'rejected') as PromiseRejectedResult[] + + return { fulfilled, rejected } +} + +/** + * Maps the returned values of `Promise.allSettled` to an object + * with `fulfilled` and `rejected` arrays for easy access. + * + * For rejected promises, the value is the stringified `reason`, + * or `'Unknown error'` string when it is unavailable. + */ +export function unwrapPromiseSettlements( + promiseSettledResults: PromiseSettledResult[], +): UnwrappedPromiseSettlements { + const values = mapPromiseSettlements(promiseSettledResults) + const fulfilled = values.fulfilled.map(x => x.value) + const rejected = values.rejected.map(x => (x.reason ? String(x.reason) : 'Unknown error')) + + return { fulfilled, rejected } +} + +/** + * Wraps a `Promise` or async function inside a new `Promise`, + * which retries the original function up to `maxRetries` times, + * waiting `delayMs` milliseconds between failed attempts. + * + * If all attempts fail, then this `Promise` also rejects. + */ +export function makeRetriablePromise(fn: () => Promise, maxRetries = 3, delayMs = 1000): Promise { + return new Promise(async (resolve, reject) => { + for (let tries = 0; tries < maxRetries; tries++) { + try { + const results = await fn() + resolve(results) + + return + } catch (error) { + if (tries < maxRetries - 1) { + await sleepMs(delayMs) + } else { + reject(error) + } + } + } + }) +}