Skip to content

Commit cf700b6

Browse files
refactor(util): merge withRetries into waitUntil aws#6387
## Problem: `withRetries` and `waitUntil` are two different interfaces for very similar concepts. ## Solution: - Add `backoff` and `retryOnFail` flags to `waitUntil()`. - `waitUntil(…, {retryOnFail:true})` returns `undefined` if it never properly resolves. - `waitUntil(…, {retryOnFail:false})` throws if it never properly resolves. - Delete `withRetries()`.
1 parent 6dd3f87 commit cf700b6

File tree

8 files changed

+223
-163
lines changed

8 files changed

+223
-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

@@ -97,7 +95,7 @@ export class HttpResourceFetcher implements ResourceFetcher<Response> {
9795
}
9896

9997
private async getResponseFromGetRequest(timeout?: Timeout, headers?: RequestHeaders): Promise<Response> {
100-
return withRetries(
98+
return waitUntil(
10199
() => {
102100
const req = request.fetch('GET', this.url, {
103101
headers: this.buildRequestHeaders(headers),
@@ -111,7 +109,10 @@ export class HttpResourceFetcher implements ResourceFetcher<Response> {
111109
return req.response.finally(() => cancelListener?.dispose())
112110
},
113111
{
114-
maxRetries: this.params.retries ?? 1,
112+
timeout: 3000,
113+
interval: 100,
114+
backoff: 2,
115+
retryOnFail: true,
115116
}
116117
)
117118
}

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: 75 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -219,40 +219,101 @@ 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 immediate result.
226+
* - 'truthy' arg is ignored
227+
* - If the timeout is reached it throws 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
/**
225-
* Invokes `fn()` until it returns a truthy value (or non-undefined if `truthy:false`).
237+
* Invokes `fn()` on an interval based on the given arguments. This can be used for retries, or until
238+
* an expected result is given. Read {@link WaitUntilOptions} carefully.
226239
*
227240
* @param fn Function whose result is checked
228241
* @param options See {@link WaitUntilOptions}
229242
*
230-
* @returns Result of `fn()`, or `undefined` if timeout was reached.
243+
* @returns Result of `fn()`, or possibly `undefined` depending on the arguments.
231244
*/
245+
export async function waitUntil<T>(fn: () => Promise<T>, options: WaitUntilOptions & { retryOnFail: true }): Promise<T>
246+
export async function waitUntil<T>(
247+
fn: () => Promise<T>,
248+
options: WaitUntilOptions & { retryOnFail: false }
249+
): Promise<T | undefined>
250+
export async function waitUntil<T>(
251+
fn: () => Promise<T>,
252+
options: Omit<WaitUntilOptions, 'retryOnFail'>
253+
): Promise<T | undefined>
232254
export async function waitUntil<T>(fn: () => Promise<T>, options: WaitUntilOptions): Promise<T | undefined> {
233-
const opt = { timeout: 5000, interval: 500, truthy: true, ...options }
255+
// set default opts
256+
const opt = {
257+
timeout: waitUntilDefaultTimeout,
258+
interval: waitUntilDefaultInterval,
259+
truthy: true,
260+
backoff: 1,
261+
retryOnFail: false,
262+
...options,
263+
}
264+
265+
let interval = opt.interval
266+
let lastError: Error | undefined
267+
let elapsed: number = 0
268+
let remaining = opt.timeout
269+
234270
for (let i = 0; true; i++) {
235271
const start: number = globals.clock.Date.now()
236272
let result: T
237273

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

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

248-
if ((opt.truthy && result) || (!opt.truthy && result !== undefined)) {
249-
return result
307+
throw lastError
250308
}
251-
if (i * opt.interval >= opt.timeout) {
252-
return undefined
309+
310+
// when testing, this avoids the need to progress the stubbed clock
311+
if (interval > 0) {
312+
await sleep(interval)
253313
}
254314

255-
await sleep(opt.interval)
315+
elapsed += interval
316+
interval = interval * opt.backoff
256317
}
257318
}
258319

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

Lines changed: 24 additions & 20 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-
})()
537+
// Start function under test
538+
const fetcher = assert.rejects(new RemoteFetcher().fetch('startUp', 'any'), (e) => {
539+
return e instanceof Error && e.message === 'last error'
540+
})
543541

544-
const fetcher = new RemoteFetcher()
542+
// Progresses the clock, allowing the fetcher logic to break out of sleep for each iteration of withRetries()
543+
assert.strictEqual(httpStub.callCount, 1) // 0
544+
await clock.tickAsync(RemoteFetcher.retryIntervalMs)
545+
assert.strictEqual(httpStub.callCount, 2) // 30_000
546+
await clock.tickAsync(RemoteFetcher.retryIntervalMs)
547+
assert.strictEqual(httpStub.callCount, 3) // 60_000
548+
await clock.tickAsync(RemoteFetcher.retryIntervalMs)
549+
assert.strictEqual(httpStub.callCount, 4) // 120_000
550+
httpStub.throws(new Error('last error'))
551+
await clock.tickAsync(RemoteFetcher.retryIntervalMs)
552+
assert.strictEqual(httpStub.callCount, 5) // 150_000
553+
554+
// We hit timeout so the last error will be thrown
545555
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()
552556
})
553557
})
554558

0 commit comments

Comments
 (0)