Skip to content

Commit 978ed1a

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 f192573 commit 978ed1a

File tree

8 files changed

+219
-163
lines changed

8 files changed

+219
-163
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/resourcefetcher/httpResourceFetcher.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,8 @@
66
import { VSCODE_EXTENSION_ID } from '../extensions'
77
import { getLogger, Logger } from '../logger'
88
import { ResourceFetcher } from './resourcefetcher'
9-
import { Timeout, CancelEvent } from '../utilities/timeoutUtils'
9+
import { Timeout, CancelEvent, waitUntil } from '../utilities/timeoutUtils'
1010
import request, { RequestError } from '../request'
11-
import { withRetries } from '../utilities/functionUtils'
1211

1312
type RequestHeaders = { eTag?: string; gZip?: boolean }
1413

@@ -30,7 +29,6 @@ export class HttpResourceFetcher implements ResourceFetcher<Response> {
3029
showUrl: boolean
3130
friendlyName?: string
3231
timeout?: Timeout
33-
retries?: number
3432
}
3533
) {}
3634

@@ -39,8 +37,11 @@ export class HttpResourceFetcher implements ResourceFetcher<Response> {
3937
*/
4038
public get(): Promise<Response | undefined> {
4139
this.logger.verbose(`downloading: ${this.logText()}`)
42-
return withRetries(() => this.downloadRequest(), {
43-
maxRetries: this.params.retries ?? 1,
40+
return waitUntil(() => this.downloadRequest(), {
41+
timeout: 2000,
42+
interval: 100,
43+
backoff: 2,
44+
retryOnFail: true,
4445
})
4546
}
4647

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: 74 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -219,40 +219,102 @@ interface WaitUntilOptions {
219219
readonly interval?: number
220220
/** Wait for "truthy" result, else wait for any defined result including `false` (default: true) */
221221
readonly truthy?: boolean
222+
/** A backoff multiplier for how long the next interval will be (default: None, i.e 1) */
223+
readonly backoff?: number
224+
/**
225+
* Only retries when an error is thrown, otherwise returning the result regardless of truthiness.
226+
* - Ignores 'truthy' arg
227+
* - If the timeout is reached it will throw the last error
228+
* - default: false
229+
*/
230+
readonly retryOnFail?: boolean
222231
}
223232

233+
export const waitUntilDefaultTimeout = 2000
234+
export const waitUntilDefaultInterval = 500
235+
224236
/**
225237
* Invokes `fn()` until it returns a truthy value (or non-undefined if `truthy:false`).
226238
*
239+
* Also look at {@link withRetries} to be able to retry on failures.
240+
*
227241
* @param fn Function whose result is checked
228242
* @param options See {@link WaitUntilOptions}
229243
*
230244
* @returns Result of `fn()`, or `undefined` if timeout was reached.
231245
*/
246+
export async function waitUntil<T>(fn: () => Promise<T>, options: WaitUntilOptions & { retryOnFail: true }): Promise<T>
247+
export async function waitUntil<T>(
248+
fn: () => Promise<T>,
249+
options: WaitUntilOptions & { retryOnFail: false }
250+
): Promise<T | undefined>
251+
export async function waitUntil<T>(
252+
fn: () => Promise<T>,
253+
options: Omit<WaitUntilOptions, 'retryOnFail'>
254+
): Promise<T | undefined>
232255
export async function waitUntil<T>(fn: () => Promise<T>, options: WaitUntilOptions): Promise<T | undefined> {
233-
const opt = { timeout: 5000, interval: 500, truthy: true, ...options }
256+
// set default opts
257+
const opt = {
258+
timeout: waitUntilDefaultTimeout,
259+
interval: waitUntilDefaultInterval,
260+
truthy: true,
261+
backoff: 1,
262+
retryOnFail: false,
263+
...options,
264+
}
265+
266+
let interval = opt.interval
267+
let lastError: Error | undefined
268+
let elapsed: number = 0
269+
let remaining = opt.timeout
270+
234271
for (let i = 0; true; i++) {
235272
const start: number = globals.clock.Date.now()
236273
let result: T
237274

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

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

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

255-
await sleep(opt.interval)
316+
elapsed += interval
317+
interval = interval * opt.backoff
256318
}
257319
}
258320

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

Lines changed: 26 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,7 @@ import {
2727
import { HttpResourceFetcher } from '../../shared/resourcefetcher/httpResourceFetcher'
2828
import { NotificationsNode } from '../../notifications/panelNode'
2929
import { RuleEngine } from '../../notifications/rules'
30+
import Sinon from 'sinon'
3031

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

513514
describe('RemoteFetcher', function () {
514515
let clock: FakeTimers.InstalledClock
516+
let sandbox: Sinon.SinonSandbox
515517

516518
before(function () {
517519
clock = installFakeClock()
520+
sandbox = createSandbox()
518521
})
519522

520523
afterEach(function () {
521524
clock.reset()
525+
sandbox.restore()
522526
})
523527

524528
after(function () {
525529
clock.uninstall()
526530
})
527531

528532
it('retries and throws error', async function () {
529-
const httpStub = sinon.stub(HttpResourceFetcher.prototype, 'getNewETagContent')
533+
// Setup
534+
const httpStub = sandbox.stub(HttpResourceFetcher.prototype, 'getNewETagContent')
530535
httpStub.throws(new Error('network error'))
531536

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()
537+
// Start function under test
538+
const fetcher = new RemoteFetcher().fetch('startUp', 'any').then(() => assert.fail('Did not throw exception.'))
539+
540+
// Progresses the clock, allowing the fetcher logic to break out of sleep for each iteration of withRetries()
541+
assert.strictEqual(httpStub.callCount, 1) // 0
542+
await clock.tickAsync(RemoteFetcher.retryIntervalMs)
543+
assert.strictEqual(httpStub.callCount, 2) // 30_000
544+
await clock.tickAsync(RemoteFetcher.retryIntervalMs)
545+
assert.strictEqual(httpStub.callCount, 3) // 60_000
546+
await clock.tickAsync(RemoteFetcher.retryIntervalMs)
547+
assert.strictEqual(httpStub.callCount, 4) // 120_000
548+
httpStub.throws(new Error('last error'))
549+
await clock.tickAsync(RemoteFetcher.retryIntervalMs)
550+
assert.strictEqual(httpStub.callCount, 5) // 150_000
551+
552+
// We hit timeout so the last error will be thrown
553+
await assert.rejects(fetcher, (e) => {
554+
return e instanceof Error && e.message === 'last error'
555+
})
552556
})
553557
})
554558

0 commit comments

Comments
 (0)