summaryrefslogtreecommitdiffhomepage
path: root/gui
diff options
context:
space:
mode:
authorOskar Nyberg <oskar@mullvad.net>2020-04-21 12:58:21 +0200
committerOskar Nyberg <oskar@mullvad.net>2020-04-21 12:58:21 +0200
commit171530b04a5fdbc8c27fa4f29ee37a98545ab335 (patch)
tree09fd98542d83e4da1506c6ebfbf8c6fe21618da1 /gui
parent0e13ac8bf009d6fabfb9039f845791e9b233b856 (diff)
parent513b78e1f665f930397a123764de2b48309da3e3 (diff)
downloadmullvadvpn-171530b04a5fdbc8c27fa4f29ee37a98545ab335.tar.xz
mullvadvpn-171530b04a5fdbc8c27fa4f29ee37a98545ab335.zip
Merge branch 'prevent-account-data-cache-parallel-fetch'
Diffstat (limited to 'gui')
-rw-r--r--gui/src/main/account-data-cache.ts82
-rw-r--r--gui/src/main/index.ts6
-rw-r--r--gui/src/shared/scheduler.ts12
-rw-r--r--gui/test/account-data-cache.spec.ts121
4 files changed, 138 insertions, 83 deletions
diff --git a/gui/src/main/account-data-cache.ts b/gui/src/main/account-data-cache.ts
index 7db2c05ca8..ac829ca0f3 100644
--- a/gui/src/main/account-data-cache.ts
+++ b/gui/src/main/account-data-cache.ts
@@ -2,16 +2,14 @@ import log from 'electron-log';
import moment from 'moment';
import { AccountToken, IAccountData } from '../shared/daemon-rpc-types';
import consumePromise from '../shared/promise';
+import { Scheduler } from '../shared/scheduler';
+import { InvalidAccountError } from './errors';
const EXPIRED_ACCOUNT_REFRESH_PERIOD = 60_000;
-export enum AccountFetchRetryAction {
- stop,
- retry,
-}
interface IAccountFetchWatcher {
onFinish: () => void;
- onError: (error: Error) => AccountFetchRetryAction;
+ onError: (error: Error) => void;
}
// An account data cache that helps to throttle RPC requests to get_account_data and retain the
@@ -19,8 +17,9 @@ interface IAccountFetchWatcher {
export default class AccountDataCache {
private currentAccount?: AccountToken;
private expiresAt?: Date;
- private fetchAttempt = 0;
- private fetchRetryTimeout?: NodeJS.Timeout;
+ private performingFetch = false;
+ private waitStrategy = new WaitStrategy();
+ private fetchRetryScheduler = new Scheduler();
private watchers: IAccountFetchWatcher[] = [];
constructor(
@@ -35,25 +34,30 @@ export default class AccountDataCache {
this.currentAccount = accountToken;
}
- // Only fetch is value has expired
+ // Only fetch if value has expired
if (this.isExpired()) {
if (watcher) {
this.watchers.push(watcher);
}
- consumePromise(this.performFetch(accountToken));
+ this.fetchRetryScheduler.cancel();
+ // If a scheduled retry is cancelled the fetchAttempt shouldn't be increased.
+ this.waitStrategy.decrease();
+
+ // Only fetch if there's no fetch for this account number in progress.
+ if (!this.performingFetch) {
+ consumePromise(this.performFetch(accountToken));
+ }
} else if (watcher) {
watcher.onFinish();
}
}
public invalidate() {
- if (this.fetchRetryTimeout) {
- clearTimeout(this.fetchRetryTimeout);
- this.fetchRetryTimeout = undefined;
- this.fetchAttempt = 0;
- }
+ this.fetchRetryScheduler.cancel();
+ this.waitStrategy.reset();
+ this.performingFetch = false;
this.expiresAt = undefined;
this.updateHandler();
this.notifyWatchers((watcher) => {
@@ -72,6 +76,7 @@ export default class AccountDataCache {
}
private async performFetch(accountToken: AccountToken) {
+ this.performingFetch = true;
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
@@ -81,10 +86,13 @@ export default class AccountDataCache {
if (this.currentAccount === accountToken) {
this.setValue(accountData);
this.scheduleRefetchIfExpired(accountToken, accountData);
+ this.waitStrategy.reset();
+ this.performingFetch = false;
}
} catch (error) {
if (this.currentAccount === accountToken) {
this.handleFetchError(accountToken, error);
+ this.performingFetch = false;
}
}
}
@@ -97,24 +105,15 @@ export default class AccountDataCache {
}
private handleFetchError(accountToken: AccountToken, error: Error) {
- let shouldRetry = true;
-
- this.notifyWatchers((watcher) => {
- if (watcher.onError(error) === AccountFetchRetryAction.stop) {
- shouldRetry = false;
- }
- });
-
- if (shouldRetry) {
+ this.notifyWatchers((w) => w.onError(error));
+ if (!(error instanceof InvalidAccountError)) {
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;
+ this.waitStrategy.increase();
+ const delay = this.waitStrategy.delay();
log.warn(`Failed to fetch account data. Retrying in ${delay} ms`);
@@ -122,8 +121,7 @@ export default class AccountDataCache {
}
private scheduleFetch(accountToken: AccountToken, delay: number) {
- this.fetchRetryTimeout = global.setTimeout(() => {
- this.fetchRetryTimeout = undefined;
+ this.fetchRetryScheduler.schedule(() => {
consumePromise(this.performFetch(accountToken));
}, delay);
}
@@ -132,3 +130,29 @@ export default class AccountDataCache {
this.watchers.splice(0).forEach(notify);
}
}
+
+const MAX_ATTEMPT = 9;
+
+class WaitStrategy {
+ private counter = 0;
+
+ public increase() {
+ if (this.counter < MAX_ATTEMPT) {
+ this.counter += 1;
+ }
+ }
+ public decrease() {
+ if (this.counter > 0) {
+ this.counter -= 1;
+ }
+ }
+
+ public reset() {
+ this.counter = 0;
+ }
+
+ public delay(): number {
+ // Max delay: 2^11 = 2048
+ return Math.pow(2, this.counter + 2) * 1000;
+ }
+}
diff --git a/gui/src/main/index.ts b/gui/src/main/index.ts
index 61b689456c..6f9185688a 100644
--- a/gui/src/main/index.ts
+++ b/gui/src/main/index.ts
@@ -36,7 +36,7 @@ import {
setupLogging,
} from '../shared/logging';
import consumePromise from '../shared/promise';
-import AccountDataCache, { AccountFetchRetryAction } from './account-data-cache';
+import AccountDataCache from './account-data-cache';
import { getOpenAtLogin, setOpenAtLogin } from './autostart';
import {
ConnectionObserver,
@@ -1139,13 +1139,11 @@ class ApplicationMain {
this.accountDataCache.invalidate();
this.accountDataCache.fetch(accountToken, {
onFinish: () => resolve({ status: 'verified' }),
- onError: (error): AccountFetchRetryAction => {
+ onError: (error) => {
if (error instanceof InvalidAccountError) {
reject(error);
- return AccountFetchRetryAction.stop;
} else {
resolve({ status: 'deferred', error });
- return AccountFetchRetryAction.retry;
}
},
});
diff --git a/gui/src/shared/scheduler.ts b/gui/src/shared/scheduler.ts
new file mode 100644
index 0000000000..af28a7e95b
--- /dev/null
+++ b/gui/src/shared/scheduler.ts
@@ -0,0 +1,12 @@
+export class Scheduler {
+ private timer?: number;
+
+ public schedule(action: () => void, delay: number) {
+ this.cancel();
+ this.timer = setTimeout(action, delay);
+ }
+
+ public cancel() {
+ clearTimeout(this.timer);
+ }
+}
diff --git a/gui/test/account-data-cache.spec.ts b/gui/test/account-data-cache.spec.ts
index 4d3dfc2ed3..bec1c6ef91 100644
--- a/gui/test/account-data-cache.spec.ts
+++ b/gui/test/account-data-cache.spec.ts
@@ -1,4 +1,4 @@
-import AccountDataCache, { AccountFetchRetryAction } from '../src/main/account-data-cache';
+import AccountDataCache from '../src/main/account-data-cache';
import { IAccountData } from '../src/shared/daemon-rpc-types';
import sinon from 'sinon';
import { expect, spy } from 'chai';
@@ -28,10 +28,7 @@ describe('IAccountData cache', () => {
const watcher = new Promise((resolve, reject) => {
cache.fetch(dummyAccountToken, {
onFinish: () => resolve(),
- onError: (_error: Error) => {
- reject();
- return AccountFetchRetryAction.stop;
- },
+ onError: (_error: Error) => reject(),
});
});
@@ -47,10 +44,7 @@ describe('IAccountData cache', () => {
const watcher = new Promise((resolve, reject) => {
cache.fetch(dummyAccountToken, {
onFinish: (_reason?: any) => resolve(),
- onError: (_error: Error) => {
- reject();
- return AccountFetchRetryAction.stop;
- },
+ onError: (_error: Error) => reject(),
});
});
@@ -66,10 +60,7 @@ describe('IAccountData cache', () => {
cache.fetch(dummyAccountToken, {
onFinish: () => {},
- onError: (_error: Error) => {
- reject();
- return AccountFetchRetryAction.stop;
- },
+ onError: (_error: Error) => reject(),
});
});
@@ -94,34 +85,7 @@ describe('IAccountData cache', () => {
cache.fetch(dummyAccountToken, {
onFinish: () => reject(),
- onError: (_error: Error) => AccountFetchRetryAction.retry,
- });
- });
-
- return expect(update).to.eventually.be.fulfilled;
- });
-
- it('should not retry if told to stop', async () => {
- const update = new Promise((resolve, reject) => {
- let firstAttempt = true;
- const fetch = () => {
- if (firstAttempt) {
- firstAttempt = false;
- setTimeout(() => clock.tick(14000), 0);
- return Promise.reject(new Error('First attempt fails'));
- } else {
- reject();
- return Promise.resolve(dummyAccountData);
- }
- };
-
- const cache = new AccountDataCache(fetch, () => resolve());
-
- setTimeout(resolve, 12000);
-
- cache.fetch(dummyAccountToken, {
- onFinish: () => {},
- onError: (_error: Error) => AccountFetchRetryAction.stop,
+ onError: (_error: Error) => {},
});
});
@@ -129,7 +93,7 @@ describe('IAccountData cache', () => {
});
it('should cancel first fetch', async () => {
- const firstError = spy((_error: Error) => AccountFetchRetryAction.stop);
+ const firstError = spy((_error: Error) => {});
const secondSuccess = spy();
const update = new Promise<IAccountData>((resolve, reject) => {
@@ -140,10 +104,7 @@ describe('IAccountData cache', () => {
cache.fetch('1231231231', {
onFinish: secondSuccess,
- onError: () => {
- reject();
- return AccountFetchRetryAction.stop;
- },
+ onError: () => reject(),
});
return new Promise<IAccountData>((resolve) => {
@@ -199,10 +160,7 @@ describe('IAccountData cache', () => {
cache.fetch(dummyAccountToken, {
onFinish: () => {},
- onError: (_error: Error) => {
- reject();
- return AccountFetchRetryAction.stop;
- },
+ onError: (_error: Error) => reject(),
});
});
@@ -211,4 +169,67 @@ describe('IAccountData cache', () => {
expect(nonExpiredSpy).to.have.been.called.once;
});
});
+
+ it('should clear scheduled retry if another fetch is performed', async () => {
+ const firstError = spy();
+ const secondSuccess = spy();
+ const updateHandler = spy();
+
+ const update = new Promise((resolve, reject) => {
+ let attempts = 0;
+ const fetch = () => {
+ attempts++;
+ if (attempts === 1) {
+ return Promise.reject(new Error('First attempt fails'));
+ } else if (attempts === 2) {
+ setTimeout(() => clock.tick(8000));
+ return Promise.resolve(dummyAccountData);
+ } else {
+ reject();
+ return Promise.resolve(dummyAccountData);
+ }
+ };
+
+ const cache = new AccountDataCache(fetch, updateHandler);
+
+ cache.fetch(dummyAccountToken, {
+ onFinish: () => {},
+ onError: (_error: Error) => firstError(),
+ });
+ setTimeout(() => {
+ cache.fetch(dummyAccountToken, {
+ onFinish: () => {
+ secondSuccess();
+ setTimeout(resolve);
+ },
+ onError: (_error: Error) => {},
+ });
+ });
+ });
+
+ return expect(update).to.eventually.be.fulfilled.then(() => {
+ expect(firstError).to.have.been.called.once;
+ expect(secondSuccess).to.have.been.called.once;
+ expect(updateHandler).to.have.been.called.twice;
+ });
+ });
+
+ it('should not perform a fetch if called twice synchronously', async () => {
+ const fetchSpy = spy();
+ const update = new Promise((resolve, _reject) => {
+ const fetch = () => {
+ fetchSpy();
+ return Promise.resolve(dummyAccountData);
+ };
+
+ const cache = new AccountDataCache(fetch, () => {});
+ const onError = (_error: Error) => {};
+ cache.fetch(dummyAccountToken, { onFinish: () => {}, onError });
+ cache.fetch(dummyAccountToken, { onFinish: () => resolve(), onError });
+ });
+
+ return expect(update).to.eventually.be.fulfilled.then(() => {
+ expect(fetchSpy).to.have.been.called.once;
+ });
+ });
});