fetch-engine
A smart request-making library that makes sure your Javascript client is a good citizen of your distributed system.
install
To get fetch-engine
, install it with yarn:
yarn add fetch-engine
Getting started
fetch-engine
is designed for use in a web browser, and you'll need a build tool like webpack
or browerify
to include it in your project:
import fetchEngine from 'fetch-engine';
Note: fetch-engine
relies on window.fetch
internally. This API is not implemented in all browsers so you might need to polyfill it with a module like cross-fetch.
The default export, fetchEngine
, is a function. When called, it returns an implementation of the fetch API — a request-making function that you might call fetch
:
const fetch = fetchEngine();
The magic of fetch-engine
is that you can pass plugins and filters that augment the network requests to do things like:
- retry on network-failure
- log request time per URL
- throttle requests to a rate-limited API endpoint
- respect common HTTP error codes (eg. 503 retry with exponential backoff to a limit)
- deduplicate identical in-flight requests
Here's a simple example that logs every request using the willFetch
lifecycle method:
class LoggerPlugin {
willFetch(request) {
console.log(request.method, request.url);
}
}
const fetch = fetchEngine({
plugins: [
new LoggerPlugin()
]
});
fetch-engine
Using Note: fetch-engine
relies on window.fetch
internally. This API is not implemented in all browsers so you might need to polyfill it with a module like cross-fetch.
fetch-engine
allows you to combine plugins in groups or sequences to add behaviour to the Fetch API. The basic building block is the plugin.
Plugins
Plugins can intercept and modify HTTP requests and responses. They're simple objects, so this is a valid plugin:
const logger = {
willFetch(request) {
console.log(request.method, request.url);
}
};
But to be able to store state in the plugins, or to give them a proper name for debugging, you can create a simple class and use new
:
class Logger {
willFetch(request) {
console.log(request.method, request.url);
}
}
That willFetch
method is one of many lifecyle
methods you can attach behaviour to.
The full list, in the order they run:
shouldFetch
getRequest
willFetch
fetch
fetching
getResponse
didFetch
shouldFetch
shouldFetch
allows plugins to prevent or allow a request by returning a boolean, or a Promise
for a boolean. It's passed the candidate Request
object.
class RateLimitPlugin {
isRateLimited(request) {
/* ... */
return false;
}
shouldFetch(request) {
return !this.isRateLimited(request);
}
}
getRequest
getRequest
allows plugins to add data to Request
, or produce an entirely new Request
. After this method has run, the subsequent lifecycle methods will use the new or changed Request
. getRequest
should return a Request
or a Promise
for one.
class CORSAuthPlugin {
constructor(csrfToken) {
this.csrfToken = csrfToken;
}
getRequest(request) {
return new Request(request, {
mode: 'cors',
credentials: 'include',
headers: {
...request.headers,
'X-Csrf-Token': this.csrfToken
}
});
}
}
willFetch
willFetch
allows a plugin to react to a Request
just before it is made, but not affect it, becuase the return value is be ignored.
class RequestMetricsPlugin {
willFetch(request) {
trackRequest(request);
}
}
fetch
fetch
takes two arguments: the input request
and a next
method that takes no arguments, but causes the request to be passed to the next plugin and eventually hit the network.
The fetch
method allows plugins to intercept the Request that's about to be made and return a Response
without hitting the network.
It should return (a Promise
for) a Response
.
class CachePlugin {
fetch(request, next) {
if (this.isCached(request)) {
return this.getFromCache(request);
}
return next();
}
isCached(request) {
// ...
}
getFromCache(request) {
// ...
}
}
fetching
Allows plugins to react to the completion of the fetch
but not affect it, or cancel
the request.
It's passed an object of the form { request, promise, cancel }
and the return value is ignored.
class TimeoutPlugin {
constructor(time=1000) {
this.time = time;
}
fetching({ cancel }) {
setTimeout(cancel, this.time);
}
}
getResponse
Allows plugins to add data to Response
, produce an entirely new Response
, or retry the whole fetch.
It's passed the current Response
object, a retry
function, and should return a Promise
for a Response
.
class SuperResponseWrapperPlugin {
getResponse(response, retry) {
return new SuperResponse(response);
}
}
Use the retry
function to start the whole request again:
// Note: your retry plugin needs to be smarter than this as this implementation
// could cause a retry-storm or self-DDoS.
class RetryPlugin {
getResponse(response, retry) {
if (response.status === 503) {
return retry();
} else {
return response;
}
}
}
Or to try a different request:
// Note: your retry plugin needs to be smarter than this as this implementation
// could cause a retry-storm or self-DDoS.
class RetryPlugin {
getResponse(response, retry) {
if (response.status === 503) {
return retry(new Request("/somewhere-else"));
} else {
return response;
}
}
}
didFetch
Allows a plugin to react to a response arriving, without affecting it.
Passed the current Response
object. Return value would be ignored.
class ResponseMetricsPlugin {
didFetch(response) {
trackResponse(response);
}
}
Groups
Groups of plugins can be created using something called a FetchGroup
. A group lets you pull together functionality in a way that can be reused as plugin:
import fetchEngine, { FetchGroup } from 'fetch-engine';
const combinedMetricsGroup = new FetchGroup({
plugins: [
new RequestMetricsPlugin(),
new ResponseMetricsPlugin()
]
});
FetchGroup
instances are plugins too, so you can pass them to fetchEngine
like any other plugin:
const fetch = fetchEngine({
plugins: [
combinedMetricsGroup
]
});
Reusable groups
Some kinds of Group need to be stateful, for functionality like rate limiting and tracking retries.
fetch-engine
exports a utility to help you to this: makeFetchGroup
.
makeFetchGroup
takes a function that, when called, should return an object of FetchGroup
options, and it returns a constructor function.
The first argument passed to the group constructor is forwared to the options function, allowing you to customise the configuration of the group on an instance-by-instance basis.
Here's two examples:
const RateLimitGroup = makeFetchGroup(() => ({
filters: [
new MethodFilter("GET")
],
plugins: [
new RateLimitPlugin()
]
}));
const fetch = fetchEngine({
plugins: [
new RateLimitGroup()
]
});
const PathRateLimitGroup = makeFetchGroup((opts = {}) => ({
filters: [
new MethodFilter("GET"),
new PathFilter(opts.path || /.*/))
],
plugins: [
new RateLimitPlugin()
]
}));
const fetch = fetchEngine({
plugins: [
new PathRateLimitGroup({
path: /^\/1\.1/
})
]
});
Filters
Filters are used to decide whether to apply a set of plugins. They combine with plugins as part of a FetchGroup
, deciding whether the plugins will run. If the filter says no, the request is passed to the next plugin for processing.
In the example below, the RateLimitPlugin
will only be applied to requests that match the testRequest
method of the PathPrefixFilter
.
class PathPrefixFilter {
constructor(prefix) {
this.prefix = prefix;
}
testRequest(request) {
const url = new URL(request.url);
return url.pathname.startsWith(this.prefix);
}
}
const rateLimitGroup = new FetchGroup({
filters: [ new PathPrefixFilter('/1.1/') ],
plugins: [ new RateLimitPlugin() ]
});
Filters can intercept requests and responses, to decide which portion of the plugin methods run. Filters can implement testRequest
and testResponse
. They should return true
, false
or a promise for true
or false
.
Rationale
The idea for this project comes from TweetDeck, a very 'chatty' native web app. The client-side is an oft-forgetten but important component of a distributed system, capable of bringing down servers with large amounts of traffic.
To mitigate the risk that TweetDeck contributes to a system failure we have developed a fairly complex networking layer. In various places, often ad-hoc, we do some of the following:
- use a OAuth1-signing server to authenticate to our requests
- de-duplicate in-flight requests
- poll rate-limited API endpoints as fast as the limits allow
- retry requests if they fail due to lack of network connection
- retry, with exponential back-off, requests that return an error from the server
- add authentication data in different ways based on app-configuration
- track network request performance
- timeout requests
- fall-back from a stream connection to polling
However, these behaviours can require some manual work. Not all are used for all network requests, even if they should be. In particular, retry + exponential backoff with timeouts are a general good practice that we don't always apply. They can prevent cascading failures, or compounding of issues as they arise.
In many places on the server-side, this is assumed behaviour and done automatically. For example, Twitter's Finagle comes with timeouts, retries and other features out-of-the-box.
This project aims to make it easy to build JavaScript clients that are good citizens of distributed systems.
Example
import fetchEngine, { FetchGroup } from 'fetch-engine';
class PathPrefixFilter {
constructor(prefix) {
this.prefix = prefix;
}
testRequest(request) {
const url = new URL(request.url);
return url.pathname.startsWith(this.prefix);
}
}
class CORSAuthPlugin {
constructor(csrfToken) {
this.csrfToken = csrfToken;
}
getRequest(request) {
return new Request(request, {
mode: 'cors',
credentials: 'include',
headers: Object.assign(request.headers, {
'X-Csrf-Token': this.csrfToken
})
});
}
}
class RateLimitPlugin {
isRateLimited(request) {
/* ... */
return false;
}
shouldFetch(request) {
return !this.isRateLimited(request);
}
}
class TimeoutPlugin {
constructor(time=1000) {
this.time = time;
}
fetching({ cancel }) {
setTimeout(cancel, this.time);
}
}
class MetricsPlugin {
willFetch(request) {
trackRequest(request);
}
didFetch(response) {
trackResponse(response);
}
}
class CachePlugin {
fetch(request, next) {
if (this.isCached(request)) {
return this.get(request);
}
return next();
}
isCached(request) {
// ...
}
get(request) {
// ...
}
}
const fetch = fetchEngine({
plugins: [
new CachePlugin(),
new TimeoutPlugin(5000),
new CORSAuthPlugin(),
new FetchGroup({
filters: [ new PathPrefixFilter('/1.1/') ],
plugins: [ new RateLimitPlugin() ]
}),
new MetricsPlugin()
]
});
Developing Fetch Engine
- Clone the repo:
git clone https://github.com/tgvashworth/fetch-engine.git
cd fetch-engine
yarn
yarn test
to check it's all working
fetch-engine
uses TypeScript. To help you write great code, I'd recommend that you get a plugin for your editor or use an IDE like VS Code.
Every commit should pass yarn test
. We use ghooks to enforce this.
Compiling & running tests locally
$ jest -w
Your editor may do this for you.
Goals of the project
These were the original goals of the project:
- Highly configurable
- Do nothing by default
- Support plug-ins to add functionality
- Provide useful plugins to solve common problems:
- respect common HTTP error codes (eg. 503 retry with exponential backoff to a limit)
- throttle requests to a rate-limited API endpoint
- deduplicate identical in-flight requests
- retry on network-failure
- Instrumentable for performance and behaviour monitoring
- Work in IE9+ (stretch-goal, IE6+)
- Node-compatible
- Drop-in replacement for
fetch