@nrk/rfetch

rfetch - fetch with retry

Usage no npm install needed!

<script type="module">
  import nrkRfetch from 'https://cdn.skypack.dev/@nrk/rfetch';
</script>

README

RFetch

Fetch With Retry

Installation

node:

$ npm install @nrk/rfetch

Requirements (Dependencies and/or Polyfills)

By Default we do not install any dependencies, so you will have to install the required dependencies and/or polyfills needed for the intended target(s) of your choice below.

Node

For node environment you will need to install the following dependencies:

$ npm install node-fetch
$ npm install abort-controller

Browser

For web browser environment target you will need to have to polyfill the following builtin and API's if not present:

NOTE: You may want to only polyfill only what is needed depending on your target group browser compatibility needs.

Isomorphic

If you intend to develop an isomorphic app where you target both the web and node targets, you will need to address the depencies litst above for each environment. Also you may want to prefer to install the isomorphic-fetch, which both includes node-fetch and whatwg-fetch.

Bundlers such as browserify, rollup and webpack will lookup the main, module and browser fields in the package.json to lookup the corresponding file to use for the given target either it be node or browser.

$ npm install isomorphic-fetch
$ npm install abort-controller

Dist

A minified umd module for web, is provided under the dist folder, as dist/rfetch.web.min.js with a corresponding source map file. It exports rfetch on to the window

Usage

const rfetch = require('@nrk/rfetch')
const url = 'http://example.com/api/endpoint'

/*
  For a more comprenhensive API reference for the fetchOptions see:

  - https://github.github.io/fetch/
  - https://github.com/bitinn/node-fetch
*/
const fetchOptions = {
  method: 'GET',
  ...
}

const retryOptions = {
  /*
   Optional signalTimeout in ms, how long before each fetch request should timeout.
   The default timeout is 1000ms = 1 second.
  */
  signalTimeout: 1000,

  /*
    Optional resolveOn, can be a single http status code or various http status codes.
    In most use cases we would want to resolve on one given http status.
    The default status code we try to resolve on is http status = 200 OK.
  */
  resolveOn: 200,

  /*
    How many retries before failing.
    The default value is 3 retries to attempt to fetch given url.
  */
  retries: 3,

  /*
    Retry timeout, can be a single timeout number or various timeout numbers to loop over.
    The default value is 100ms, which is how long it should delay before retrying a new fetch request.
  */
  retryTimeout: 100,

  /*
    Which http status codes to retry on, can be a single http status code or various http status codes.
    The deault values is an empty set, and the retry heuristic will retry on every http status code that
    does not match the http status codes to match on.
   */
  retryOn: [],

  /*
    Optional context object for more advanced controls
  */
  context: {
    /*
      The abortController property will be set internally, and is an instance of AbortController.
      This allows you to call it's abort() method, and stop the entire retry fetch loop.
    */
    abortController: null,

    /*
      Optional errors array (by reference), that allows us to inspect all the error(s)
      that were thrown in the retry loop for further processing etc.
    */
    errors: [],

    /*
      Optional sink callback (by reference), that allows us to listen to some of the phases in the
      retry fetch loop and eventually do some code based on the different phases.
     */
    sink (phase, context) {
      if (phase === 'rfetch.fetch.(start|resolved|rejected)') {
        ...
      }
    }
  }
}

try {
  const response = await rfetch(options, fetchOptions, retryOptions)
  const data = await response.json()
} catch (err) {
  console.error(err)
}

Note: both the fetchOptions and retryOptions are optional, and the retry options will default to values explained above

The context.abortController is set internally by the retry fetch loop and allows you to abort the retry loop, since it will abort the current fetch request in the loop via it's assigned abort controller.

ResolveOn and RetryOn heuristics

The resolveOn and retryOn can be one or more http status codes respectively.

  1. Success example - expect response.status = 200 and no retry on value(s) set
const retryOptions = {
  retries: 3,
  resolveOn: 200
}

fetch -> http 503 -> retry
fetch -> http 408 -> retry
fetch -> http 200 -> success -> resolve
  1. Success example - expect response.status in [ 200, 204 ] and no retry on value(s) set
const retryOptions = {
  retries: 3,
  resolveOn: [ 200, 204 ]
}

fetch -> http 418 -> retry
fetch -> http 204 -> success -> resolve
  1. Success example - expect response.status = 200 and retry on = 503
const retryOptions = {
  retries: 3,
  resolveOn: 200,
  retryOn: 503
}

fetch -> http 503 -> retry
fetch -> http 200 -> success -> resolve
  1. Success example - expect response.status = 200 and retry on = [ 503, 408 ]
const retryOptions = {
  retries: 3,
  resolveOn: 200,
  retryOn: [ 503, 408 ]
}

fetch -> http 503 -> retry
fetch -> http 408 -> retry
fetch -> http 200 -> success -> resolve
  1. Failure example, expect esponse.status = 200 and retry on = [ 503, 408 ].
const retryOptions = {
  retries: 3,
  resolveOn: 200,
  retryOn: [ 503, 408 ]
}

fetch -> http 503 -> retry
fetch -> http 418 -> failure -> reject

Retry timeout heuristics

The retryTimeout can either be a single timeout value that will be used as the default delay before starting a new fetch retry request. Or it can be a set of timeout values that we loop over e.g.:

const retryOptions = {
  retries: 5,
  retryTimeout: [100, 200]
}

fetch -> failure -> delay 100
fetch -> failure -> delay 200
fetch -> failure -> delay 100
fetch -> failure -> delay 200
fetch ...

Or you can specify for each retry:

const retryOptions = {
  retries: 4,
  retryTimeout: [100, 250, 300]
}

fetch -> failure -> delay 100
fetch -> failure -> delay 250
fetch -> failure -> delay 300
fetch ...

Signal and aborting the fetch retry loop

The following code will not work as expected e.g.:

let abortController
cancelButton.addEventListener('click',
  // this will not work as expected
  () => abortController.abort()
)

dataButton.addEventLister('click', async () => {
  abortController = new AbortController()
  const fetchOptions = {
    signal: abortController.signal
  }

  try {
    const response = await rfetch('/api/data', fetchOptions)
    ....
  } catch (e) {
    // will never be aborted by the abortController if the user click's the abort button
  }
})

The retry fetch loop will not be aborted (rejected) by the user input as one would expect. If you need to cancel the retry fetch loop you need to pass a context object with the retryOptions e.g.:

const retryOptions = {
  /* by reference */
  context: {}
}

cancelButton.addEventListener('click',
  () => retryOptions.context.abortController.abort()
)

dataButton.addEventLister('click', async () => {
  try {
    const response = await rfetch('/api/data', {}, retryOptions)
    ....
  } catch (e) {
     // will be avorted by the if the user click's the abort button
  }
})

Sink phases

You can pass a context object with the retryOptions, that allows you to pass a sink method by reference to listen to some of the phases in the retry fetch loop e.g.:

function sink (phase, context) {
  if (phase === 'rfetch.fetch.start') {
    console.log(`Start a fetch request url: <(${context.url})>, attempt: <${context.n}> of: <${context.retries}>.`)
  }

  if (phase === 'rfetch.fetch.rejected') {
    console.log(`Failure to fetch request url: <(${context.url})>, attempt: <${context.n}> of: <${context.retries}>, error: <${context.error.toString()}>.`)
  }

  if (phase === 'rfetch.fetch.resolved') {
    console.log(`Succeeded to fetch request url: <(${context.url})>, attempt: <${context.n}> of: <${context.retries}>.`)
  }
}

const url = 'http://example.com/api/endpoint'
const fetchOptions = {
  ...
}
const retryOptions = {
  context: {
    sink
  }
}


try {
  const response = await rfetch(url, fetchOptions, retryOptions)
} catch (error) {
  ...
}

Module Support

We support es6 modules e.g. the following code would also work:

import rfetch from '@nrk/rfetch'

const url = 'http://example.com/api/endpoint'
const fetchOptions = {
  ...
}
const retryOptions = {
  ...
}


try {
  const response = await rfetch(url, fetchOptions, retryOptions)
} catch (error) {
  ...
}

Bundlers such as browserify and rollup, webpack will lookup the corresponding .mjs file, also the latest node works has module support behind the --experimental-modules flag.