Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions packages/core/src/notifications/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,11 @@ import { NotificationsNode } from './panelNode'
import { Commands } from '../shared/vscode/commands2'
import { RuleEngine } from './rules'
import { TreeNode } from '../shared/treeview/resourceTreeDataProvider'
import { withRetries } from '../shared/utilities/functionUtils'
import { FileResourceFetcher } from '../shared/resourcefetcher/fileResourceFetcher'
import { isAmazonQ } from '../shared/extensionUtilities'
import { telemetry } from '../shared/telemetry/telemetry'
import { randomUUID } from '../shared/crypto'
import { waitUntil } from '../shared/utilities/timeoutUtils'

const logger = getLogger('notifications')

Expand Down Expand Up @@ -266,8 +266,8 @@ export interface NotificationFetcher {
}

export class RemoteFetcher implements NotificationFetcher {
public static readonly retryNumber = 5
public static readonly retryIntervalMs = 30000
public static readonly retryTimeout = RemoteFetcher.retryIntervalMs * 5

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

return withRetries(
return waitUntil(
async () => {
try {
return await fetcher.getNewETagContent(versionTag)
Expand All @@ -296,8 +296,9 @@ export class RemoteFetcher implements NotificationFetcher {
}
},
{
maxRetries: RemoteFetcher.retryNumber,
delay: RemoteFetcher.retryIntervalMs,
interval: RemoteFetcher.retryIntervalMs,
timeout: RemoteFetcher.retryTimeout,
retryOnFail: true,
// No exponential backoff - necessary?
}
)
Expand Down
18 changes: 14 additions & 4 deletions packages/core/src/shared/crashMonitoring.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ import fs from './fs/fs'
import { getLogger } from './logger/logger'
import { crashMonitoringDirName } from './constants'
import { throwOnUnstableFileSystem } from './filesystemUtilities'
import { withRetries } from './utilities/functionUtils'
import { truncateUuid } from './crypto'
import { waitUntil } from './utilities/timeoutUtils'

const className = 'CrashMonitoring'

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

return funcWithRetries
} catch (e) {
Expand Down Expand Up @@ -542,7 +547,12 @@ export class FileSystemState {
await nodeFs.rm(filePath, { force: true })
}
const funcWithCtx = () => withFailCtx(ctx, func)
const funcWithRetries = withRetries(funcWithCtx, { maxRetries: 6, delay: 100, backoff: 2 })
const funcWithRetries = waitUntil(funcWithCtx, {
timeout: 15_000,
interval: 100,
backoff: 2,
retryOnFail: true,
})
await funcWithRetries
}

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,8 @@
import { VSCODE_EXTENSION_ID } from '../extensions'
import { getLogger, Logger } from '../logger'
import { ResourceFetcher } from './resourcefetcher'
import { Timeout, CancelEvent } from '../utilities/timeoutUtils'
import { Timeout, CancelEvent, waitUntil } from '../utilities/timeoutUtils'
import request, { RequestError } from '../request'
import { withRetries } from '../utilities/functionUtils'

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

Expand All @@ -30,7 +29,6 @@ export class HttpResourceFetcher implements ResourceFetcher<Response> {
showUrl: boolean
friendlyName?: string
timeout?: Timeout
retries?: number
}
) {}

Expand Down Expand Up @@ -97,7 +95,7 @@ export class HttpResourceFetcher implements ResourceFetcher<Response> {
}

private async getResponseFromGetRequest(timeout?: Timeout, headers?: RequestHeaders): Promise<Response> {
return withRetries(
return waitUntil(
() => {
const req = request.fetch('GET', this.url, {
headers: this.buildRequestHeaders(headers),
Expand All @@ -111,7 +109,10 @@ export class HttpResourceFetcher implements ResourceFetcher<Response> {
return req.response.finally(() => cancelListener?.dispose())
},
{
maxRetries: this.params.retries ?? 1,
timeout: 3000,
interval: 100,
backoff: 2,
retryOnFail: true,
}
)
}
Expand Down
33 changes: 1 addition & 32 deletions packages/core/src/shared/utilities/functionUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* SPDX-License-Identifier: Apache-2.0
*/

import { sleep, Timeout } from './timeoutUtils'
import { Timeout } from './timeoutUtils'

/**
* Creates a function that always returns a 'shared' Promise.
Expand Down Expand Up @@ -145,34 +145,3 @@ export function cancellableDebounce<T, U extends any[]>(
cancel: cancel,
}
}

/**
* Executes the given function, retrying if it throws.
*
* @param opts - if no opts given, defaults are used
*/
export async function withRetries<T>(
fn: () => Promise<T>,
opts?: { maxRetries?: number; delay?: number; backoff?: number }
): Promise<T> {
const maxRetries = opts?.maxRetries ?? 3
const delay = opts?.delay ?? 0
const backoff = opts?.backoff ?? 1

let retryCount = 0
let latestDelay = delay
while (true) {
try {
return await fn()
} catch (err) {
retryCount++
if (retryCount >= maxRetries) {
throw err
}
if (latestDelay > 0) {
await sleep(latestDelay)
latestDelay = latestDelay * backoff
}
}
}
}
89 changes: 75 additions & 14 deletions packages/core/src/shared/utilities/timeoutUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,40 +219,101 @@ interface WaitUntilOptions {
readonly interval?: number
/** Wait for "truthy" result, else wait for any defined result including `false` (default: true) */
readonly truthy?: boolean
/** A backoff multiplier for how long the next interval will be (default: None, i.e 1) */
readonly backoff?: number
/**
* Only retries when an error is thrown, otherwise returning the immediate result.
* - 'truthy' arg is ignored
* - If the timeout is reached it throws the last error
* - default: false
*/
readonly retryOnFail?: boolean
}

export const waitUntilDefaultTimeout = 2000
export const waitUntilDefaultInterval = 500

/**
* Invokes `fn()` until it returns a truthy value (or non-undefined if `truthy:false`).
* Invokes `fn()` on an interval based on the given arguments. This can be used for retries, or until
* an expected result is given. Read {@link WaitUntilOptions} carefully.
*
* @param fn Function whose result is checked
* @param options See {@link WaitUntilOptions}
*
* @returns Result of `fn()`, or `undefined` if timeout was reached.
* @returns Result of `fn()`, or possibly `undefined` depending on the arguments.
*/
export async function waitUntil<T>(fn: () => Promise<T>, options: WaitUntilOptions & { retryOnFail: true }): Promise<T>
export async function waitUntil<T>(
fn: () => Promise<T>,
options: WaitUntilOptions & { retryOnFail: false }
): Promise<T | undefined>
export async function waitUntil<T>(
fn: () => Promise<T>,
options: Omit<WaitUntilOptions, 'retryOnFail'>
): Promise<T | undefined>
export async function waitUntil<T>(fn: () => Promise<T>, options: WaitUntilOptions): Promise<T | undefined> {
const opt = { timeout: 5000, interval: 500, truthy: true, ...options }
// set default opts
const opt = {
timeout: waitUntilDefaultTimeout,
interval: waitUntilDefaultInterval,
truthy: true,
backoff: 1,
retryOnFail: false,
...options,
}

let interval = opt.interval
let lastError: Error | undefined
let elapsed: number = 0
let remaining = opt.timeout

for (let i = 0; true; i++) {
const start: number = globals.clock.Date.now()
let result: T

// Needed in case a caller uses a 0 timeout (function is only called once)
if (opt.timeout > 0) {
result = await Promise.race([fn(), new Promise<T>((r) => globals.clock.setTimeout(r, opt.timeout))])
} else {
result = await fn()
try {
// Needed in case a caller uses a 0 timeout (function is only called once)
if (remaining > 0) {
result = await Promise.race([fn(), new Promise<T>((r) => globals.clock.setTimeout(r, remaining))])
} else {
result = await fn()
}

if (opt.retryOnFail || (opt.truthy && result) || (!opt.truthy && result !== undefined)) {
return result
}
} catch (e) {
if (!opt.retryOnFail) {
throw e
}

// Unlikely to hit this, but exists for typing
if (!(e instanceof Error)) {
throw e
}

lastError = e
}

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

// If the sleep will exceed the timeout, abort early
if (elapsed + interval >= remaining) {
if (!opt.retryOnFail) {
return undefined
}

if ((opt.truthy && result) || (!opt.truthy && result !== undefined)) {
return result
throw lastError
}
if (i * opt.interval >= opt.timeout) {
return undefined

// when testing, this avoids the need to progress the stubbed clock
if (interval > 0) {
await sleep(interval)
}

await sleep(opt.interval)
elapsed += interval
interval = interval * opt.backoff
}
}

Expand Down
44 changes: 24 additions & 20 deletions packages/core/src/test/notifications/controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import * as vscode from 'vscode'
import * as FakeTimers from '@sinonjs/fake-timers'
import assert from 'assert'
import sinon from 'sinon'
import sinon, { createSandbox } from 'sinon'
import globals from '../../shared/extensionGlobals'
import { randomUUID } from '../../shared/crypto'
import { getContext } from '../../shared/vscode/setContext'
Expand All @@ -27,6 +27,7 @@ import {
import { HttpResourceFetcher } from '../../shared/resourcefetcher/httpResourceFetcher'
import { NotificationsNode } from '../../notifications/panelNode'
import { RuleEngine } from '../../notifications/rules'
import Sinon from 'sinon'

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

describe('RemoteFetcher', function () {
let clock: FakeTimers.InstalledClock
let sandbox: Sinon.SinonSandbox

before(function () {
clock = installFakeClock()
sandbox = createSandbox()
})

afterEach(function () {
clock.reset()
sandbox.restore()
})

after(function () {
clock.uninstall()
})

it('retries and throws error', async function () {
const httpStub = sinon.stub(HttpResourceFetcher.prototype, 'getNewETagContent')
// Setup
const httpStub = sandbox.stub(HttpResourceFetcher.prototype, 'getNewETagContent')
httpStub.throws(new Error('network error'))

const runClock = (async () => {
await clock.tickAsync(1)
for (let n = 1; n <= RemoteFetcher.retryNumber; n++) {
assert.equal(httpStub.callCount, n)
await clock.tickAsync(RemoteFetcher.retryIntervalMs)
}

// Stop trying
await clock.tickAsync(RemoteFetcher.retryNumber)
assert.equal(httpStub.callCount, RemoteFetcher.retryNumber)
})()
// Start function under test
const fetcher = assert.rejects(new RemoteFetcher().fetch('startUp', 'any'), (e) => {
return e instanceof Error && e.message === 'last error'
})

const fetcher = new RemoteFetcher()
// Progresses the clock, allowing the fetcher logic to break out of sleep for each iteration of withRetries()
assert.strictEqual(httpStub.callCount, 1) // 0
await clock.tickAsync(RemoteFetcher.retryIntervalMs)
assert.strictEqual(httpStub.callCount, 2) // 30_000
await clock.tickAsync(RemoteFetcher.retryIntervalMs)
assert.strictEqual(httpStub.callCount, 3) // 60_000
await clock.tickAsync(RemoteFetcher.retryIntervalMs)
assert.strictEqual(httpStub.callCount, 4) // 120_000
httpStub.throws(new Error('last error'))
await clock.tickAsync(RemoteFetcher.retryIntervalMs)
assert.strictEqual(httpStub.callCount, 5) // 150_000

// We hit timeout so the last error will be thrown
await fetcher
.fetch('startUp', 'any')
.then(() => assert.ok(false, 'Did not throw exception.'))
.catch(() => assert.ok(true))
await runClock

httpStub.restore()
})
})

Expand Down
Loading
Loading