Skip to content

Commit f37ef95

Browse files
refactor(util): merge withRetries and waitUntil
Problem: We had 2 different implementation of similar code, between withRetries and waitUntil. We couldn't easily merge them though since behavior was slightly different when it came to expected return types, and since one threw and the other did not. - waitUntil returns undefined if it never properly resolves - withRetries throws if it never properly resolves Solution: As a solution we keep both methods but they share the same underlying code. - waitUntil now has a backoff field to increase the delta between intervals - withRetries also has this - withRetries will retry even on a thrown exeception - on timeout it will throw the last exception it encountered Signed-off-by: nkomonen-amazon <[email protected]>
1 parent 86cd492 commit f37ef95

File tree

7 files changed

+221
-158
lines changed

7 files changed

+221
-158
lines changed

packages/core/src/notifications/controller.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,11 @@ import { NotificationsNode } from './panelNode'
2424
import { Commands } from '../shared/vscode/commands2'
2525
import { RuleEngine } from './rules'
2626
import { TreeNode } from '../shared/treeview/resourceTreeDataProvider'
27-
import { withRetries } from '../shared/utilities/functionUtils'
2827
import { FileResourceFetcher } from '../shared/resourcefetcher/fileResourceFetcher'
2928
import { isAmazonQ } from '../shared/extensionUtilities'
3029
import { telemetry } from '../shared/telemetry/telemetry'
3130
import { randomUUID } from '../shared/crypto'
31+
import { waitUntil } from '../shared/utilities/timeoutUtils'
3232

3333
const logger = getLogger('notifications')
3434

@@ -266,8 +266,8 @@ export interface NotificationFetcher {
266266
}
267267

268268
export class RemoteFetcher implements NotificationFetcher {
269-
public static readonly retryNumber = 5
270269
public static readonly retryIntervalMs = 30000
270+
public static readonly retryTimeout = RemoteFetcher.retryIntervalMs * 5
271271

272272
private readonly startUpEndpoint: string =
273273
'https://idetoolkits-hostedfiles.amazonaws.com/Notifications/VSCode/startup/1.x.json'
@@ -286,7 +286,7 @@ export class RemoteFetcher implements NotificationFetcher {
286286
})
287287
logger.verbose('Attempting to fetch notifications for category: %s at endpoint: %s', category, endpoint)
288288

289-
return withRetries(
289+
return waitUntil(
290290
async () => {
291291
try {
292292
return await fetcher.getNewETagContent(versionTag)
@@ -296,8 +296,9 @@ export class RemoteFetcher implements NotificationFetcher {
296296
}
297297
},
298298
{
299-
maxRetries: RemoteFetcher.retryNumber,
300-
delay: RemoteFetcher.retryIntervalMs,
299+
interval: RemoteFetcher.retryIntervalMs,
300+
timeout: RemoteFetcher.retryTimeout,
301+
retryOnFail: true,
301302
// No exponential backoff - necessary?
302303
}
303304
)

packages/core/src/shared/crashMonitoring.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ import fs from './fs/fs'
1717
import { getLogger } from './logger/logger'
1818
import { crashMonitoringDirName } from './constants'
1919
import { throwOnUnstableFileSystem } from './filesystemUtilities'
20-
import { withRetries } from './utilities/functionUtils'
2120
import { truncateUuid } from './crypto'
21+
import { waitUntil } from './utilities/timeoutUtils'
2222

2323
const className = 'CrashMonitoring'
2424

@@ -489,7 +489,12 @@ export class FileSystemState {
489489
this.deps.devLogger?.debug(`HEARTBEAT sent for ${truncateUuid(this.ext.sessionId)}`)
490490
}
491491
const funcWithCtx = () => withFailCtx('sendHeartbeatState', func)
492-
const funcWithRetries = withRetries(funcWithCtx, { maxRetries: 6, delay: 100, backoff: 2 })
492+
const funcWithRetries = waitUntil(funcWithCtx, {
493+
timeout: 15_000,
494+
interval: 100,
495+
backoff: 2,
496+
retryOnFail: true,
497+
})
493498

494499
return funcWithRetries
495500
} catch (e) {
@@ -542,7 +547,12 @@ export class FileSystemState {
542547
await nodeFs.rm(filePath, { force: true })
543548
}
544549
const funcWithCtx = () => withFailCtx(ctx, func)
545-
const funcWithRetries = withRetries(funcWithCtx, { maxRetries: 6, delay: 100, backoff: 2 })
550+
const funcWithRetries = waitUntil(funcWithCtx, {
551+
timeout: 15_000,
552+
interval: 100,
553+
backoff: 2,
554+
retryOnFail: true,
555+
})
546556
await funcWithRetries
547557
}
548558

@@ -609,7 +619,7 @@ export class FileSystemState {
609619
}
610620
const funcWithIgnoreBadFile = () => ignoreBadFileError(loadExtFromDisk)
611621
const funcWithRetries = () =>
612-
withRetries(funcWithIgnoreBadFile, { maxRetries: 6, delay: 100, backoff: 2 })
622+
waitUntil(funcWithIgnoreBadFile, { timeout: 15_000, interval: 100, backoff: 2, retryOnFail: true })
613623
const funcWithCtx = () => withFailCtx('parseRunningExtFile', funcWithRetries)
614624
const ext: ExtInstanceHeartbeat | undefined = await funcWithCtx()
615625

packages/core/src/shared/utilities/functionUtils.ts

Lines changed: 1 addition & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
* SPDX-License-Identifier: Apache-2.0
44
*/
55

6-
import { sleep, Timeout } from './timeoutUtils'
6+
import { Timeout } from './timeoutUtils'
77

88
/**
99
* Creates a function that always returns a 'shared' Promise.
@@ -145,34 +145,3 @@ export function cancellableDebounce<T, U extends any[]>(
145145
cancel: cancel,
146146
}
147147
}
148-
149-
/**
150-
* Executes the given function, retrying if it throws.
151-
*
152-
* @param opts - if no opts given, defaults are used
153-
*/
154-
export async function withRetries<T>(
155-
fn: () => Promise<T>,
156-
opts?: { maxRetries?: number; delay?: number; backoff?: number }
157-
): Promise<T> {
158-
const maxRetries = opts?.maxRetries ?? 3
159-
const delay = opts?.delay ?? 0
160-
const backoff = opts?.backoff ?? 1
161-
162-
let retryCount = 0
163-
let latestDelay = delay
164-
while (true) {
165-
try {
166-
return await fn()
167-
} catch (err) {
168-
retryCount++
169-
if (retryCount >= maxRetries) {
170-
throw err
171-
}
172-
if (latestDelay > 0) {
173-
await sleep(latestDelay)
174-
latestDelay = latestDelay * backoff
175-
}
176-
}
177-
}
178-
}

packages/core/src/shared/utilities/timeoutUtils.ts

Lines changed: 81 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
* SPDX-License-Identifier: Apache-2.0
44
*/
55

6+
import { ToolkitError } from '../errors'
67
import globals from '../extensionGlobals'
78
import { CancellationToken, EventEmitter, Event } from 'vscode'
89

@@ -219,43 +220,111 @@ interface WaitUntilOptions {
219220
readonly interval?: number
220221
/** Wait for "truthy" result, else wait for any defined result including `false` (default: true) */
221222
readonly truthy?: boolean
223+
/** A backoff multiplier for how long the next interval will be (default: None, i.e 1) */
224+
readonly backoff?: number
225+
/**
226+
* Only retries when an error is thrown, otherwise returning the result regardless of truthiness.
227+
* - Ignores 'truthy' arg
228+
* - If the timeout is reached it will throw the last error
229+
* - default: false
230+
*/
231+
readonly retryOnFail?: boolean
222232
}
223233

234+
export const waitUntilDefaultTimeout = 2000
235+
export const waitUntilDefaultInterval = 500
236+
224237
/**
225238
* Invokes `fn()` until it returns a truthy value (or non-undefined if `truthy:false`).
226239
*
240+
* Also look at {@link withRetries} to be able to retry on failures.
241+
*
227242
* @param fn Function whose result is checked
228243
* @param options See {@link WaitUntilOptions}
229244
*
230245
* @returns Result of `fn()`, or `undefined` if timeout was reached.
231246
*/
247+
export async function waitUntil<T>(fn: () => Promise<T>, options: WaitUntilOptions & { retryOnFail: true }): Promise<T>
248+
export async function waitUntil<T>(
249+
fn: () => Promise<T>,
250+
options: WaitUntilOptions & { retryOnFail: false }
251+
): Promise<T | undefined>
252+
export async function waitUntil<T>(
253+
fn: () => Promise<T>,
254+
options: Omit<WaitUntilOptions, 'retryOnFail'>
255+
): Promise<T | undefined>
232256
export async function waitUntil<T>(fn: () => Promise<T>, options: WaitUntilOptions): Promise<T | undefined> {
233-
const opt = { timeout: 5000, interval: 500, truthy: true, ...options }
257+
// set default opts
258+
const opt = {
259+
timeout: waitUntilDefaultTimeout,
260+
interval: waitUntilDefaultInterval,
261+
truthy: true,
262+
backoff: 1,
263+
retryOnFail: false,
264+
...options,
265+
}
266+
267+
let interval = opt.interval
268+
let lastError: Error | undefined
269+
let elapsed: number = 0
270+
let remaining = opt.timeout
271+
234272
for (let i = 0; true; i++) {
235273
const start: number = globals.clock.Date.now()
236274
let result: T
237275

238-
// Needed in case a caller uses a 0 timeout (function is only called once)
239-
if (opt.timeout > 0) {
240-
result = await Promise.race([fn(), new Promise<T>((r) => globals.clock.setTimeout(r, opt.timeout))])
241-
} else {
242-
result = await fn()
276+
try {
277+
// Needed in case a caller uses a 0 timeout (function is only called once)
278+
if (remaining > 0) {
279+
result = await Promise.race([fn(), new Promise<T>((r) => globals.clock.setTimeout(r, remaining))])
280+
} else {
281+
result = await fn()
282+
}
283+
284+
if (opt.retryOnFail || (opt.truthy && result) || (!opt.truthy && result !== undefined)) {
285+
return result
286+
}
287+
} catch (e) {
288+
if (!opt.retryOnFail) {
289+
throw e
290+
}
291+
292+
// Unlikely to hit this, but exists for typing
293+
if (!(e instanceof Error)) {
294+
throw e
295+
}
296+
297+
lastError = e
243298
}
244299

245300
// Ensures that we never overrun the timeout
246-
opt.timeout -= globals.clock.Date.now() - start
301+
remaining -= globals.clock.Date.now() - start
302+
303+
// If the sleep will exceed the timeout, abort early
304+
if (elapsed + interval >= remaining) {
305+
if (!opt.retryOnFail) {
306+
return undefined
307+
}
247308

248-
if ((opt.truthy && result) || (!opt.truthy && result !== undefined)) {
249-
return result
309+
throw lastError
250310
}
251-
if (i * opt.interval >= opt.timeout) {
252-
return undefined
311+
312+
// when testing, this saves the need to progress the stubbed clock
313+
if (interval > 0) {
314+
await sleep(interval)
253315
}
254316

255-
await sleep(opt.interval)
317+
elapsed += interval
318+
interval = interval * opt.backoff
256319
}
257320
}
258321

322+
/**
323+
* Implies the function failed due to an external timeout, the function
324+
* could have been successful if given more time.
325+
*/
326+
export const RetryTimeoutError = ToolkitError.named('RetryTimeoutError')
327+
259328
/**
260329
* @deprecated Prefer using event-driven timeout mechanisms over racing promises.
261330
*

packages/core/src/test/notifications/controller.test.ts

Lines changed: 27 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import * as vscode from 'vscode'
77
import * as FakeTimers from '@sinonjs/fake-timers'
88
import assert from 'assert'
9-
import sinon from 'sinon'
9+
import sinon, { createSandbox } from 'sinon'
1010
import globals from '../../shared/extensionGlobals'
1111
import { randomUUID } from '../../shared/crypto'
1212
import { getContext } from '../../shared/vscode/setContext'
@@ -27,6 +27,8 @@ import {
2727
import { HttpResourceFetcher } from '../../shared/resourcefetcher/httpResourceFetcher'
2828
import { NotificationsNode } from '../../notifications/panelNode'
2929
import { RuleEngine } from '../../notifications/rules'
30+
import { RetryTimeoutError } from '../../shared/utilities/timeoutUtils'
31+
import Sinon from 'sinon'
3032

3133
// one test node to use across different tests
3234
export const panelNode: NotificationsNode = NotificationsNode.instance
@@ -512,43 +514,46 @@ describe('Notifications Controller', function () {
512514

513515
describe('RemoteFetcher', function () {
514516
let clock: FakeTimers.InstalledClock
517+
let sandbox: Sinon.SinonSandbox
515518

516519
before(function () {
517520
clock = installFakeClock()
521+
sandbox = createSandbox()
518522
})
519523

520524
afterEach(function () {
521525
clock.reset()
526+
sandbox.restore()
522527
})
523528

524529
after(function () {
525530
clock.uninstall()
526531
})
527532

528533
it('retries and throws error', async function () {
529-
const httpStub = sinon.stub(HttpResourceFetcher.prototype, 'getNewETagContent')
534+
// Setup
535+
const httpStub = sandbox.stub(HttpResourceFetcher.prototype, 'getNewETagContent')
530536
httpStub.throws(new Error('network error'))
531537

532-
const runClock = (async () => {
533-
await clock.tickAsync(1)
534-
for (let n = 1; n <= RemoteFetcher.retryNumber; n++) {
535-
assert.equal(httpStub.callCount, n)
536-
await clock.tickAsync(RemoteFetcher.retryIntervalMs)
537-
}
538-
539-
// Stop trying
540-
await clock.tickAsync(RemoteFetcher.retryNumber)
541-
assert.equal(httpStub.callCount, RemoteFetcher.retryNumber)
542-
})()
543-
544-
const fetcher = new RemoteFetcher()
545-
await fetcher
546-
.fetch('startUp', 'any')
547-
.then(() => assert.ok(false, 'Did not throw exception.'))
548-
.catch(() => assert.ok(true))
549-
await runClock
550-
551-
httpStub.restore()
538+
// Start function under test
539+
const fetcher = new RemoteFetcher().fetch('startUp', 'any').then(() => assert.fail('Did not throw exception.'))
540+
541+
// Progresses the clock, allowing the fetcher logic to break out of sleep for each iteration of withRetries()
542+
assert.strictEqual(httpStub.callCount, 1) // 0
543+
await clock.tickAsync(RemoteFetcher.retryIntervalMs)
544+
assert.strictEqual(httpStub.callCount, 2) // 30_000
545+
await clock.tickAsync(RemoteFetcher.retryIntervalMs)
546+
assert.strictEqual(httpStub.callCount, 3) // 60_000
547+
await clock.tickAsync(RemoteFetcher.retryIntervalMs)
548+
assert.strictEqual(httpStub.callCount, 4) // 120_000
549+
httpStub.throws(new Error('last error'))
550+
await clock.tickAsync(RemoteFetcher.retryIntervalMs)
551+
assert.strictEqual(httpStub.callCount, 5) // 150_000
552+
553+
// We hit timeout so the last error will be thrown
554+
await assert.rejects(fetcher, (e) => {
555+
return e instanceof RetryTimeoutError && e.cause?.message === 'last error'
556+
})
552557
})
553558
})
554559

0 commit comments

Comments
 (0)