From f5b5d24da8a9015ade1bb2ce53757ced0643ab36 Mon Sep 17 00:00:00 2001 From: Justin Date: Wed, 16 Aug 2017 21:57:23 +0000 Subject: [PATCH] implement requestInterval middleware; close #30 --- README.md | 8 ++- src/FamilySearch.js | 23 +++---- src/middleware/request/index.js | 1 + src/middleware/request/requestInterval.js | 80 +++++++++++++++++++++++ test/node.js | 45 +++++++++++++ 5 files changed, 143 insertions(+), 14 deletions(-) create mode 100644 src/middleware/request/requestInterval.js diff --git a/README.md b/README.md index a1762c6..b251519 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,13 @@ var fs = new FamilySearch({ tokenCookie: 'FS_AUTH_TOKEN', // Maximum number of times that a throttled request will be retried. Defaults to 10. - maxThrottledRetries: 10 + maxThrottledRetries: 10, + + // Optional settings that enforces a minimum time in milliseconds (ms) between + // requests. This is useful for smoothing out bursts of requests and being nice + // to the API servers. When this parameter isn't set (which is the default) + // then all requests are immediately sent. + requestInterval: 1000 }); ``` diff --git a/src/FamilySearch.js b/src/FamilySearch.js index bffd979..8868448 100644 --- a/src/FamilySearch.js +++ b/src/FamilySearch.js @@ -8,19 +8,7 @@ var cookies = require('doc-cookies'), /** * Create an instance of the FamilySearch SDK Client * - * @param {Object} options - * @param {String} options.environment Reference environment: production, beta, - * or integration. Defaults to integration. - * @param {String} options.appKey Application Key - * @param {String} options.redirectUri OAuth2 redirect URI - * @param {String} options.saveAccessToken Save the access token to a cookie - * and automatically load it from that cookie. Defaults to false. - * @param {String} options.tokenCookie Name of the cookie that the access token - * will be saved in when `saveAccessToken` is true. Defaults to 'FS_AUTH_TOKEN'. - * @param {String} options.maxThrottledRetries Maximum number of a times a - * throttled request should be retried. Defaults to 10. - * @param {Array} options.pendingModifications List of pending modifications - * that should be activated. + * @param {Object} options See a description of the possible options in the docs for config(). */ var FamilySearch = function(options){ @@ -67,6 +55,11 @@ var FamilySearch = function(options){ * throttled request should be retried. Defaults to 10. * @param {Array} options.pendingModifications List of pending modifications * that should be activated. + * @param {Integer} options.requestInterval Minimum interval between requests in milliseconds (ms). + * By default this behavior is disabled; i.e. requests are issued immediately. + * When this option is set then requests are queued to ensure there is at least + * {requestInterval} ms between them. This is useful for smoothing out bursts + * of requests and thus playing nice with the API servers. */ FamilySearch.prototype.config = function(options){ this.appKey = options.appKey || this.appKey; @@ -84,6 +77,10 @@ FamilySearch.prototype.config = function(options){ this.addRequestMiddleware(requestMiddleware.pendingModifications(options.pendingModifications)); } + if(parseInt(options.requestInterval, 10)) { + this.addRequestMiddleware(requestMiddleware.requestInterval(parseInt(options.requestInterval, 10))); + } + // When the SDK is configured to save the access token in a cookie and we don't // presently have an access token then we try loading one from the cookie. // diff --git a/src/middleware/request/index.js b/src/middleware/request/index.js index 69b48f8..98bfe4e 100644 --- a/src/middleware/request/index.js +++ b/src/middleware/request/index.js @@ -4,5 +4,6 @@ module.exports = { defaultAcceptHeader: require('./defaultAcceptHeader'), disableAutomaticRedirects: require('./disableAutomaticRedirects'), pendingModifications: require('./pendingModifications'), + requestInterval: require('./requestInterval'), url: require('./url') }; \ No newline at end of file diff --git a/src/middleware/request/requestInterval.js b/src/middleware/request/requestInterval.js new file mode 100644 index 0000000..8feccd4 --- /dev/null +++ b/src/middleware/request/requestInterval.js @@ -0,0 +1,80 @@ +/** + * Enforce a minimum interval between requests. + * See https://github.com/FamilySearch/fs-js-lite/issues/30 + * + * @param {Integer} interval Minimum time between requests, in milliseconds (ms) + * @return {Function} middleware + */ +module.exports = function(interval) { + + var lastRequestTime = 0, + requestQueue = [], + timer; + + /** + * Add a request to the queue + * + * @param {Function} next The next method that was sent to the middleware with the request. + */ + function enqueue(next) { + requestQueue.push(next); + startTimer(); + } + + /** + * Start the timer that checks for when a request in the queue is ready to go. + * This fires every {interval} ms to enforce a minimum time between requests. + * The actual time between requests may be longer. + */ + function startTimer() { + if(!timer) { + timer = setInterval(checkQueue, interval); + } + } + + /** + * Check to see if we're ready to send any requests. + */ + function checkQueue() { + if(!inInterval()) { + if(requestQueue.length) { + var next = requestQueue.shift(); + sendRequest(next); + } else if(timer) { + clearInterval(timer); // No need to leave the timer running if we don't have any requests. + } + } + } + + /** + * Send a request by calling it's next() method and mark the current time so + * that we can accurately enforce the interval. + */ + function sendRequest(next) { + lastRequestTime = Date.now(); + next(); + } + + /** + * Returns true if the most recent request was less then {interval} ms in the past + * + * @return {Boolean} + */ + function inInterval() { + return Date.now() - lastRequestTime < interval; + } + + return function(client, request, next) { + + // If there are any requests in the queue or if the previous request was issued + // too recently then add this request to the queue. + if(requestQueue.length || inInterval()) { + enqueue(next); + } + + else { + sendRequest(next); + } + }; + +}; \ No newline at end of file diff --git a/test/node.js b/test/node.js index 342a0c4..df5964b 100644 --- a/test/node.js +++ b/test/node.js @@ -325,6 +325,51 @@ describe('node', function(){ }); + describe('requestInterval', function(){ + + it('request interval is enforced', function(done){ + // Here we test that requests are propertly enqueued and the timing + // between them is enforced when the requestInterval param is present. + var interval = 1500, + numRequests = 3, + timings = [], + client = apiClient({ + requestInterval: interval + }); + this.timeout(interval * numRequests * 2); + client.addRequestMiddleware(function(client, request, next){ + // This middleware is added after the requestInterval middleware so we + // know that it's called after the requestInterval limit has been enforced. + timings[request.options.num] = Date.now(); + if(timings.length === numRequests) { + done(verifyTimings()); + } + next(); + }); + for(var i = 0; i < numRequests; i++) { + client.get('/foo', { + num: i + }, function(){}); + } + + // Make sure each subsequent request was at least {interval} ms after the + // previous request. Our timing is marked on a different tick in this test + // than it is in the middleware and that can account for many ms of + // difference so we allow a shortcoming of 30 ms below. + function verifyTimings() { + var diff; + for(var i = 1; i < numRequests; i++) { + diff = timings[i] - timings[i-1]; + if(diff < interval - 30) { + console.log(timings); + return new Error(`Request ${i+1} was only ${diff}ms after the request before it; expected ${interval}ms.`); + } + } + } + }); + + }); + }); /**