Skip to content

Commit e5c04e3

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 e5c04e3

File tree

7 files changed

+205
-157
lines changed

7 files changed

+205
-157
lines changed

packages/core/src/notifications/controller.ts

Lines changed: 4 additions & 4 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 { withRetries } 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'
@@ -296,8 +296,8 @@ export class RemoteFetcher implements NotificationFetcher {
296296
}
297297
},
298298
{
299-
maxRetries: RemoteFetcher.retryNumber,
300-
delay: RemoteFetcher.retryIntervalMs,
299+
interval: RemoteFetcher.retryIntervalMs,
300+
timeout: RemoteFetcher.retryTimeout,
301301
// No exponential backoff - necessary?
302302
}
303303
)

packages/core/src/shared/crashMonitoring.ts

Lines changed: 4 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 { withRetries } from './utilities/timeoutUtils'
2222

2323
const className = 'CrashMonitoring'
2424

@@ -489,7 +489,7 @@ 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 = withRetries(funcWithCtx, { timeout: 15_000, interval: 100, backoff: 2 })
493493

494494
return funcWithRetries
495495
} catch (e) {
@@ -542,7 +542,7 @@ export class FileSystemState {
542542
await nodeFs.rm(filePath, { force: true })
543543
}
544544
const funcWithCtx = () => withFailCtx(ctx, func)
545-
const funcWithRetries = withRetries(funcWithCtx, { maxRetries: 6, delay: 100, backoff: 2 })
545+
const funcWithRetries = withRetries(funcWithCtx, { timeout: 15_000, interval: 100, backoff: 2 })
546546
await funcWithRetries
547547
}
548548

@@ -609,7 +609,7 @@ export class FileSystemState {
609609
}
610610
const funcWithIgnoreBadFile = () => ignoreBadFileError(loadExtFromDisk)
611611
const funcWithRetries = () =>
612-
withRetries(funcWithIgnoreBadFile, { maxRetries: 6, delay: 100, backoff: 2 })
612+
withRetries(funcWithIgnoreBadFile, { timeout: 15_000, interval: 100, backoff: 2 })
613613
const funcWithCtx = () => withFailCtx('parseRunningExtFile', funcWithRetries)
614614
const ext: ExtInstanceHeartbeat | undefined = await funcWithCtx()
615615

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: 86 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,116 @@ 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+
227+
interface WithRetriesOptions extends WaitUntilOptions {
228+
/**
229+
* Ignore intermediate errors thrown by fn(). If timeout it reached, it will throw the last error.
230+
* (default: true)
231+
*/
232+
readonly catch?: boolean
222233
}
223234

224235
/**
225236
* Invokes `fn()` until it returns a truthy value (or non-undefined if `truthy:false`).
226237
*
238+
* Also look at {@link withRetries} to be able to retry on failures.
239+
*
227240
* @param fn Function whose result is checked
228241
* @param options See {@link WaitUntilOptions}
229242
*
230243
* @returns Result of `fn()`, or `undefined` if timeout was reached.
231244
*/
232245
export async function waitUntil<T>(fn: () => Promise<T>, options: WaitUntilOptions): Promise<T | undefined> {
233-
const opt = { timeout: 5000, interval: 500, truthy: true, ...options }
246+
const opt: WithRetriesOptions = { timeout: 5000, interval: 500, truthy: true, backoff: 1, ...options }
247+
try {
248+
return await withRetries(fn, Object.assign(opt, { catch: false }))
249+
} catch (e) {
250+
if (e instanceof RetryTimeoutError) {
251+
return undefined
252+
}
253+
throw e
254+
}
255+
}
256+
257+
export const withRetriesDefaultTimeout = 2000
258+
export const withRetriesInterval = 500
259+
260+
/**
261+
* Invokes `fn()` until it successfully returns a value, otherwise it throws.
262+
*
263+
* @param fn Function whose result is checked
264+
* @param options See {@link WaitUntilOptions}
265+
*/
266+
export async function withRetries<T>(fn: () => Promise<T>, options: WithRetriesOptions = {}): Promise<T> {
267+
const opt = {
268+
timeout: withRetriesDefaultTimeout,
269+
interval: withRetriesInterval,
270+
truthy: true,
271+
backoff: 1,
272+
catch: true,
273+
...options,
274+
}
275+
let interval = opt.interval
276+
let lastError: Error | undefined
277+
let elapsed: number = 0
278+
let remaining = opt.timeout
234279
for (let i = 0; true; i++) {
235280
const start: number = globals.clock.Date.now()
236281
let result: T
237282

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()
283+
try {
284+
// Needed in case a caller uses a 0 timeout (function is only called once)
285+
if (remaining > 0) {
286+
result = await Promise.race([fn(), new Promise<T>((r) => globals.clock.setTimeout(r, remaining))])
287+
} else {
288+
result = await fn()
289+
}
290+
291+
if ((opt.truthy && result) || (!opt.truthy && result !== undefined)) {
292+
return result
293+
}
294+
} catch (e) {
295+
if (!opt.catch) {
296+
throw e
297+
}
298+
299+
// Unlikely to hit this, but exists for typing
300+
if (!(e instanceof Error)) {
301+
throw e
302+
}
303+
304+
lastError = e
243305
}
244306

245307
// Ensures that we never overrun the timeout
246-
opt.timeout -= globals.clock.Date.now() - start
308+
remaining -= globals.clock.Date.now() - start
247309

248-
if ((opt.truthy && result) || (!opt.truthy && result !== undefined)) {
249-
return result
310+
// If the sleep will exceed the timeout, abort early
311+
if (elapsed + interval >= remaining) {
312+
throw lastError
313+
? new RetryTimeoutError('Last error was:', { cause: lastError })
314+
: new RetryTimeoutError('Function never returned a truthy result')
250315
}
251-
if (i * opt.interval >= opt.timeout) {
252-
return undefined
316+
317+
// when testing we can ignore progressing a stubbed clock
318+
if (interval > 0) {
319+
await sleep(interval)
253320
}
254321

255-
await sleep(opt.interval)
322+
elapsed += interval
323+
interval = interval * opt.backoff
256324
}
257325
}
258326

327+
/**
328+
* Implies the function failed due to an external timeout, the function
329+
* could have been successful if given more time.
330+
*/
331+
export const RetryTimeoutError = ToolkitError.named('RetryTimeoutError')
332+
259333
/**
260334
* @deprecated Prefer using event-driven timeout mechanisms over racing promises.
261335
*

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)