A smart request-making library that makes sure your Javascript client is a good citizen of your distributed system.
Current status: Design
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 good-citizen JavaScript network clients.
- 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
Taken from #1.
There'd be two main constructors that come with fetch-engine
: FetchEngine
and FetchPreset
. What they'd do is documented below:
const fetch = new FetchEngine({
plugins: [ ... ]
});
- on creation, compose each plugin method into one method
- on fetch(...), run methods in order:
shouldFetch
- if
false
, bail with CancelledError
- if
getRequest
- produces a (
Promise
for a)Request
- produces a (
willFetch
- side-effects, ignore return value
- (internal-only)
fetch
- make the
Request
with an innerfetch
implementation
- make the
fetch
- allow plugins to
cancel
- allow plugins to
getResponse
- produce a (
Promise
for a)Response
- produce a (
didFetch
- side-effects only, ignore return value
const preset = new FetchPreset({
filters: [ ... ],
plugins: [ ... ]
});
- compose filter + plugin methods into functions, then act like a plugin
- gates each method based on filter responses
The plugin API would be as below. They're just simple objects with methods. All are optional.
Passed the current Request
object.
Allows a plugin to prevent a request from being made.
Return a (Promise
for a) Boolean. If false
, the request is Cancelled
.
Passed the current Request
object.
Allows plugins to add data to Request
, or produce an entirely new Request
.
Should return a (Promise
for a) Request
.
Passed the current Request
object.
Allows a plugin to react to a request being made, but not affect it.
Return value would be ignored.
Passed an object of the form { promise, cancel }
.
Allows plugins to react to the completion of the fetch
but not affect it, or cancel
the request.
Return value is ignored.
Passed the current Response
object.
Allows plugins to add data to Response
, or produce an entirely new Response
.
Should return a (Promise
for a) Response
.
Passed the current Response
object.
Allows a plugin to react to a response arriving being made, but not affect it.
Return value would be ignored.
The filter API would be as below. Filters are used to gate the application of plugins within a FetchPreset
. They're just simple objects with methods. All are optional.
Passed the current Request
.
Run before shouldFetch
, getRequest
, willFetch
and fetch
, which will not be applied if testRequest
resolves to false
.
Should return a (Promise
for a) Boolean
.
Passed the current Response
.
Run before getResponse
and didFetch
, which will not be applied if testResponse
resolves to false
.
Should return a (Promise
for a) Boolean
.
Here's an example without an implementation, just in case this is super unclear...
import { FetchEngine, FetchPreset } from 'fetch-engine';
class PathPrefixFilter {
constructor(prefix) {
this.prefix = prefix;
}
testRequest(request) {
const url = new URL(request.url);
return url.pathname.startsWith(prefix);
}
}
class CORSAuthPlugin {
getRequest(request) {
return new Request(request, {
mode: 'cors',
credentials: 'include',
headers: Object.assign(request.headers, {
'X-Csrf-Token': getCsrfToken()
})
});
}
}
class RateLimitPlugin {
shouldFetch(request) {
return !isRateLimited(request);
}
}
class TimeoutPlugin {
constructor(time=1000) {
this.time = time;
}
fetch({ cancel }) {
setTimeout(cancel, this.time);
}
}
class MetricsPlugin {
willFetch(request) {
trackRequest(request);
}
didFetch(response) {
trackResponse(response);
}
}
let fetch = new FetchEngine({
plugins: [
new TimeoutPlugin(5000),
new CORSAuthPlugin(),
new FetchPreset({
filters: [ new PathPrefixFilter('/1.1/') ],
plugins: [ new RateLimitPlugin() ]
}),
new MetricsPlugin()
]
});
- To support some of these use cases, there needs to be a place to add metadata to the request (for example, to record when the request was initiated).