1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
|
import log from 'electron-log';
import moment from 'moment';
import { AccountToken, IAccountData } from '../shared/daemon-rpc-types';
import consumePromise from '../shared/promise';
const EXPIRED_ACCOUNT_REFRESH_PERIOD = 60_000;
export enum AccountFetchRetryAction {
stop,
retry,
}
interface IAccountFetchWatcher {
onFinish: () => void;
onError: (error: Error) => AccountFetchRetryAction;
}
// An account data cache that helps to throttle RPC requests to get_account_data and retain the
// cached value for 1 minute.
export default class AccountDataCache {
private currentAccount?: AccountToken;
private expiresAt?: Date;
private fetchAttempt = 0;
private fetchRetryTimeout?: NodeJS.Timeout;
private watchers: IAccountFetchWatcher[] = [];
constructor(
private fetchHandler: (token: AccountToken) => Promise<IAccountData>,
private updateHandler: (data?: IAccountData) => void,
) {}
public fetch(accountToken: AccountToken, watcher?: IAccountFetchWatcher) {
// invalidate cache if account token has changed
if (accountToken !== this.currentAccount) {
this.invalidate();
this.currentAccount = accountToken;
}
// Only fetch is value has expired
if (this.isExpired()) {
if (watcher) {
this.watchers.push(watcher);
}
consumePromise(this.performFetch(accountToken));
} else if (watcher) {
watcher.onFinish();
}
}
public invalidate() {
if (this.fetchRetryTimeout) {
clearTimeout(this.fetchRetryTimeout);
this.fetchRetryTimeout = undefined;
this.fetchAttempt = 0;
}
this.expiresAt = undefined;
this.updateHandler();
this.notifyWatchers((watcher) => {
watcher.onError(new Error('Cancelled'));
});
}
private setValue(value: IAccountData) {
this.expiresAt = new Date(Date.now() + 60 * 1000); // 60s expiration
this.updateHandler(value);
this.notifyWatchers((watcher) => watcher.onFinish());
}
private isExpired() {
return !this.expiresAt || this.expiresAt < new Date();
}
private async performFetch(accountToken: AccountToken) {
try {
// it's possible for invalidate() to be called or for a fetch for a different account token
// to start before this fetch completes, so checking if the current account token is the one
// used is necessary below.
const accountData = await this.fetchHandler(accountToken);
if (this.currentAccount === accountToken) {
this.setValue(accountData);
this.scheduleRefetchIfExpired(accountToken, accountData);
}
} catch (error) {
if (this.currentAccount === accountToken) {
this.handleFetchError(accountToken, error);
}
}
}
private scheduleRefetchIfExpired(accountToken: AccountToken, accountData: IAccountData) {
const hasExpired = moment(accountData.expiry).isSameOrBefore(new Date());
if (hasExpired) {
this.scheduleFetch(accountToken, EXPIRED_ACCOUNT_REFRESH_PERIOD);
}
}
private handleFetchError(accountToken: AccountToken, error: Error) {
let shouldRetry = true;
this.notifyWatchers((watcher) => {
if (watcher.onError(error) === AccountFetchRetryAction.stop) {
shouldRetry = false;
}
});
if (shouldRetry) {
this.scheduleRetry(accountToken);
}
}
private scheduleRetry(accountToken: AccountToken) {
this.fetchAttempt += 1;
// Max delay: 2^11 = 2048
const delay = Math.pow(2, Math.min(this.fetchAttempt + 2, 11)) * 1000;
log.warn(`Failed to fetch account data. Retrying in ${delay} ms`);
this.scheduleFetch(accountToken, delay);
}
private scheduleFetch(accountToken: AccountToken, delay: number) {
this.fetchRetryTimeout = global.setTimeout(() => {
this.fetchRetryTimeout = undefined;
consumePromise(this.performFetch(accountToken));
}, delay);
}
private notifyWatchers(notify: (watcher: IAccountFetchWatcher) => void) {
this.watchers.splice(0).forEach(notify);
}
}
|