Skip to content

Commit

Permalink
feat: add support for delay function
Browse files Browse the repository at this point in the history
Adds support for providing a delay function which returns the delay value allowing customization of the polling interval. This can be utilized to implement polling with exponential backoff (i.e. where the polling interval steadily increases over time).
  • Loading branch information
kleinfreund committed Nov 27, 2021
1 parent 5de38f5 commit 19ca02d
Show file tree
Hide file tree
Showing 5 changed files with 155 additions and 116 deletions.
93 changes: 81 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,30 @@

A simple poll function based on async, await, and an infinite loop.

Links:
**Features**:

- Asynchronous callback function
- Delay function to customize the polling interval (e.g. to implement exponential backoff)
- Cancellation function to stop polling altogether (e.g. stop polling after 10 cycles or once a certain condition is fulfilled)

**Links**:

- [**npmjs.com**/package/poll](https://www.npmjs.com/package/poll)
- [on BundlePhobia](https://bundlephobia.com/result?p=poll)
- [**github.com**/kleinfreund/poll](https://github.com/kleinfreund/poll)

## Contents

- [Installation & usage](#installation--usage)
- [Installation & usage](#installation-&-usage)
- [npm package](#npm-package)
- [Plain file](#plain-file)
- [Documentation](#documentation)
- [Syntax](#syntax)
- [Examples](#examples)
- [Minimal](#minimal)
- [Stop polling](#stop-polling)
- [Stop polling using asynchronous `shouldStopPolling` function](#stop-polling-using-asynchronous-shouldstoppolling-function)
- [Exponential backoff: increase polling interval with every cycle](#exponential-backoff-increase-polling-interval-with-every-cycle)
- [Versioning](#versioning)
- [Update package version](#update-package-version)

Expand Down Expand Up @@ -75,15 +88,15 @@ poll(function, delay[, shouldStopPolling])
**Type**: `() => any`<br>
**Required**: Yes<br>
**Description**: A function to be called every `delay` milliseconds. No parameters are passed to `fn` upon calling it.
- **Name**: `delay`<br>
**Type**: `number`<br>
- **Name**: `delayOrDelayCallback`<br>
**Type**: `number | (() => number)`<br>
**Required**: Yes<br>
**Description**: The delay (in milliseconds) to wait before calling the function again. If `delay` is negative, zero will be used instead.
**Description**: The delay (in milliseconds) to wait before calling the function `fn` again. If a function is provided instead of a number, it is evaluated during every polling cycle right before the wait period. If the delay is a negative number, zero will be used instead.
- **Name**: `shouldStopPolling`<br>
**Type**: `() => boolean | Promise<boolean>`<br>
**Required**: No<br>
**Default**: `() => false`<br>
**Description**: A function (or a promise resolving to a function) indicating whether to stop the polling process by returning a truthy value (e.g. `true`). The `shouldStopPolling` callback function is evaluated twice during one polling cycle:
**Description**: A function (or a promise resolving to a function) indicating whether to stop the polling process by returning a truthy value (e.g. `true`). The `shouldStopPolling` callback function is called twice during one polling cycle:
- After the result of the call to `fn` was successfully awaited (right before triggering a new delay period).
- After the `delay` has passed (right before calling `fn` again).
Expand All @@ -99,26 +112,28 @@ None.
## Examples
### Minimal
The `poll` function expects two parameters: A callback function and a delay. After calling `poll` with these parameters, the callback function will be called. After it’s done being executed, the `poll` function will wait for the specified `delay`. After the delay, the process starts from the beginning.
```js
const pollDelayInMinutes = 10
async function getStatusUpdates() {
const response = await fetch('/api/status')
console.log(response)
const pokemonId = Math.floor(Math.random() * 151 + 1)
const response = await fetch(`https://pokeapi.co/api/v2/pokemon/${pokemonId}/`)
const pokemon = await response.json()
console.log(pokemon.name)
}
poll(getStatusUpdates, pollDelayInMinutes * 60 * 1000)
```
Note that `poll` will not cause a second call to the callback function if the first call is still not finished. For example, it the endpoint `/status` does not respond and the server doesn’t time out the connection, `poll` will still be waiting for the callback function to fully resolve. It will not start the delay until the callback function is finished.
Note that `poll` will not cause a second call to the callback function if the first call is never finishing. For example, if the endpoint `/status` does not respond and the server doesn’t time out the connection, `poll` will still be waiting for the callback function to resolve until the dusk of time.
### Stop polling
You can pass a callback function to `poll` for its last parameter. Its evaluated before and after calls to the polled function. If it evaluates to `true`, the `poll` function’s loop will stop and the function returns.
In the following example, the `shouldStopPolling` callback function evaluates to `true` after the `setTimeout` function called its anonymous callback function which sets `stopPolling` to `true`. The next time `shouldStopPolling` is evaluated, it will cause `poll` to exit normally.
You can pass a callback function to `poll` for its third parameter. It’s evaluated before and after calls to the polled function. If it evaluates to a truthy value, the `poll` function’s loop will stop and the function returns.
```js
let stopPolling = false
Expand All @@ -135,6 +150,60 @@ setTimeout(() => {
poll(fn, 50, shouldStopPolling)
```
In this example, the `shouldStopPolling` callback function evaluates to `true` after the `setTimeout` function causes `stopPolling` to be set to `true` after 1000 milliseconds. The next time `shouldStopPolling` is evaluated, it will cause `poll` to exit normally.
### Stop polling using asynchronous `shouldStopPolling` function
You can also provide an asynchronous function for the `shouldStopPolling` callback function.
```js
let stopPolling = false
const shouldStopPolling = () => new Promise((resolve) => {
setTimeout(() => {
resolve(stopPolling)
}, 100)
})
function fn() {
console.log('Hello, beautiful!')
}
setTimeout(() => {
stopPolling = true
}, 1000)
poll(fn, 50, shouldStopPolling)
```
Beware that this function will be called *twice* per polling cycle.
### Exponential backoff: increase polling interval with every cycle
By providing a function that returns the delay value instead of the delay value itself, you can customize the behavior of the polling interval. In the following example, the delay doubles with each polling cycle.
```js
const pollDelayInMinutes = 1
let delay = pollDelayInMinutes * 60 * 1000
const startTime = Date.now()
async function getStatusUpdates() {
const pokemonId = Math.floor(Math.random() * 151 + 1)
const response = await fetch(`https://pokeapi.co/api/v2/pokemon/${pokemonId}/`)
const pokemon = await response.json()
const seconds = (Date.now() - startTime) / 1000
console.log('Seconds passed:', seconds, pokemon.name)
}
const delayCallback = () => {
const currentDelay = delay
delay *= 2
return currentDelay
}
poll(getStatusUpdates, delayCallback)
```
## Versioning
This package uses [semantic versioning](https://semver.org).
Expand Down
2 changes: 1 addition & 1 deletion dist/poll.js
Original file line number Diff line number Diff line change
@@ -1 +1 @@
async function a(a,i,t=(()=>!1)){i=Math.max(0,i);do{if(await a(),await t())break;await new Promise((a=>setTimeout(a,i)))}while(!await t())}export{a as poll};
async function a(a,t,e=(()=>!1)){do{if(await a(),await e())break;const i="number"==typeof t?t:t();await new Promise((a=>setTimeout(a,Math.max(0,i))))}while(!await e())}export{a as poll};
163 changes: 67 additions & 96 deletions src/poll.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,38 @@
import { poll } from './poll'

/**
* Advances the jest timers by the polling delay and any artificial delay introduced via the `shouldStopPolling` callback function if it’s asynchronous.
*
* @param numberOfIterations
* @param delay
* @param shouldStopPollingDelay
*/
async function advanceJestTimersByPollCycles(
numberOfIterations: number,
delayOrDelayCallback: number | (() => number),
shouldStopPollingDelay: number = 0
): Promise<void> {
for (let i = 0; i < numberOfIterations; i++) {
// Clear micro task queue for awaiting `fn`
await Promise.resolve()

// Advance timers for asynchronous `shouldStopPolling` routine
jest.advanceTimersByTime(shouldStopPollingDelay)
// Clear micro task queue for awaiting `shouldStopPolling`
await Promise.resolve()

const delay = typeof delayOrDelayCallback === 'function' ? delayOrDelayCallback() : delayOrDelayCallback
jest.advanceTimersByTime(delay)
// Clear micro task queue for awaiting polling delay setTimeout call
await Promise.resolve()

// Advance timers for asynchronous `shouldStopPolling` routine
jest.advanceTimersByTime(shouldStopPollingDelay)
// Clear micro task queue for awaiting `shouldStopPolling`
await Promise.resolve()
}
}

describe('poll', () => {
beforeEach(() => {
jest.useFakeTimers()
Expand All @@ -10,7 +43,17 @@ describe('poll', () => {
jest.useRealTimers()
})

test('… works with synchronous function', async () => {
test('throws error when polled function throws error', async () => {
function fn() {
throw new Error('I’m not happy with the overall situation.')
}

const pollPromise = poll(fn, 25)

await expect(pollPromise).rejects.toThrow(Error)
})

test('works with synchronous function', async () => {
const fn = jest.fn()
const delay = 50
poll(fn, delay)
Expand All @@ -19,19 +62,13 @@ describe('poll', () => {
expect(fn).toHaveBeenCalledTimes(1)

const numberOfIterations = 3
for (let i = 0; i < numberOfIterations; i++) {
await Promise.resolve()
await Promise.resolve()
jest.advanceTimersByTime(delay)
await Promise.resolve()
await Promise.resolve()
}
await advanceJestTimersByPollCycles(numberOfIterations, delay)

// Advancing the jest timers `numberOfIterations` times by the `delay` should also add `numberOfIterations` times more calls to the callback function.
expect(fn).toHaveBeenCalledTimes(1 + numberOfIterations)
})

test('works with asynchronous function', async () => {
test('works with asynchronous function', async () => {
let pollingShouldBeStopped = false
const shouldStopPolling = () => pollingShouldBeStopped

Expand All @@ -43,21 +80,15 @@ describe('poll', () => {
expect(fn).toHaveBeenCalledTimes(1)

const numberOfIterations = 3
for (let i = 0; i < numberOfIterations; i++) {
await Promise.resolve()
await Promise.resolve()
jest.advanceTimersByTime(delay)
await Promise.resolve()
await Promise.resolve()
}
await advanceJestTimersByPollCycles(numberOfIterations, delay)

// Advancing the jest timers `numberOfIterations` times by the `delay` should also add `numberOfIterations` times more calls to the callback function.
expect(fn).toHaveBeenCalledTimes(1 + numberOfIterations)

pollingShouldBeStopped = true
})

test('can be stopped synchronously', async () => {
test('can be stopped synchronously', async () => {
let pollingShouldBeStopped = false
const shouldStopPolling = () => pollingShouldBeStopped

Expand All @@ -67,49 +98,19 @@ describe('poll', () => {

expect(fn).toHaveBeenCalledTimes(1)

// Clear micro task queue for awaiting `fn`
await Promise.resolve()

// Clear micro task queue for awaiting `shouldStopPolling`
await Promise.resolve()

// Advance timers for polling delay routine
jest.advanceTimersByTime(delay)
// Clear micro task queue for awaiting polling delay setTimeout call
await Promise.resolve()

// Clear micro task queue for awaiting `shouldStopPolling`
await Promise.resolve()

// The polling function now completed 1 cycle
await advanceJestTimersByPollCycles(1, delay)

expect(fn).toHaveBeenCalledTimes(2)

// Clear micro task queue for awaiting `fn`
await Promise.resolve()

// Disabling this should make the test fail because after completing another cycle, the polling function will have been called a third time which is what is asserted at the end of the test.
pollingShouldBeStopped = true

// Advance timers for 2 asynchronous `shouldStopPolling` routines and 1 polling delay routine to pass another complete cycle:

// Clear micro task queue for awaiting `shouldStopPolling`
await Promise.resolve()

// Advance timers for polling delay routine
jest.advanceTimersByTime(delay)
// Clear micro task queue for awaiting polling delay setTimeout call
await Promise.resolve()

// Clear micro task queue for awaiting `shouldStopPolling`
await Promise.resolve()

// The polling function now completed 2 cycles
await advanceJestTimersByPollCycles(1, delay)

expect(fn).toHaveBeenCalledTimes(2)
})

test('can be stopped asynchronously', async () => {
test('can be stopped asynchronously', async () => {
let pollingShouldBeStopped = false
const shouldStopPollingDelay = 100
const shouldStopPolling = () => new Promise<boolean>((resolve) => {
Expand All @@ -124,63 +125,33 @@ describe('poll', () => {

expect(fn).toHaveBeenCalledTimes(1)

// Clear micro task queue for awaiting `fn`
await Promise.resolve()

// Advance timers for asynchronous `shouldStopPolling` routine
jest.advanceTimersByTime(shouldStopPollingDelay)
// Clear micro task queue for awaiting `shouldStopPolling`
await Promise.resolve()

// Advance timers for polling delay routine
jest.advanceTimersByTime(delay)
// Clear micro task queue for awaiting polling delay setTimeout call
await Promise.resolve()

// Advance timers for asynchronous `shouldStopPolling` routine
jest.advanceTimersByTime(shouldStopPollingDelay)
// Clear micro task queue for awaiting `shouldStopPolling`
await Promise.resolve()

// The polling function now completed 1 cycle
await advanceJestTimersByPollCycles(1, delay, shouldStopPollingDelay)

expect(fn).toHaveBeenCalledTimes(2)

// Clear micro task queue for awaiting `fn`
await Promise.resolve()

// Disabling this should make the test fail because after completing another cycle, the polling function will have been called a third time which is what is asserted at the end of the test.
pollingShouldBeStopped = true

// Advance timers for 2 asynchronous `shouldStopPolling` routines and 1 polling delay routine to pass another complete cycle:

// Advance timers for asynchronous `shouldStopPolling` routine
jest.advanceTimersByTime(shouldStopPollingDelay)
// Clear micro task queue for awaiting `shouldStopPolling`
await Promise.resolve()

// Advance timers for polling delay routine
jest.advanceTimersByTime(delay)
// Clear micro task queue for awaiting polling delay setTimeout call
await Promise.resolve()

// Advance timers for asynchronous `shouldStopPolling` routine
jest.advanceTimersByTime(shouldStopPollingDelay)
// Clear micro task queue for awaiting `shouldStopPolling`
await Promise.resolve()

// The polling function now completed 2 cycles
await advanceJestTimersByPollCycles(1, delay, shouldStopPollingDelay)

expect(fn).toHaveBeenCalledTimes(2)
})

test('… throws error when polled function throws error', async () => {
function fn() {
throw new Error('I’m not happy with the overall situation.')
test('accepts delay function (exponential backoff)', async () => {
const fn = jest.fn()
let delay = 50
const delayCallback = () => {
delay *= 2
return delay
}
poll(fn, delayCallback)

const pollRef = poll(fn, 25)
// The first call happens immediately before any timers run.
expect(fn).toHaveBeenCalledTimes(1)

const numberOfIterations = 3
await advanceJestTimersByPollCycles(numberOfIterations, () => delay)

await expect(pollRef).rejects.toThrow(Error)
// Advancing the jest timers `numberOfIterations` times by the `delay` should also add `numberOfIterations` times more calls to the callback function.
expect(fn).toHaveBeenCalledTimes(1 + numberOfIterations)
})
})
Loading

0 comments on commit 19ca02d

Please sign in to comment.