diff --git a/.gitignore b/.gitignore index cf6e2d0..8a9d2eb 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ lib-cov # Coverage directory used by tools like istanbul coverage +.nyc_output # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) .grunt diff --git a/lib/bigcommerce.js b/lib/bigcommerce.js index b01909d..fef1e2a 100644 --- a/lib/bigcommerce.js +++ b/lib/bigcommerce.js @@ -24,7 +24,8 @@ const isIp = require('is-ip'); const logger = require('debug')('node-bigcommerce:bigcommerce'), crypto = require('crypto'), - Request = require('./request'); + Request = require('./request'), + { Semaphore, TokenBucket } = require('./limiter'); class BigCommerce { constructor(config) { @@ -37,6 +38,12 @@ class BigCommerce { this.config = config; this.apiVersion = this.config.apiVersion || 'v2'; + + if (config.limiter) { + const { maxConcurrent, requestsPerSecond } = config.limiter; + this._semaphore = maxConcurrent ? new Semaphore(maxConcurrent) : null; + this._tokenBucket = requestsPerSecond ? new TokenBucket(requestsPerSecond) : null; + } } /** Verify legacy signed_payload (can be ignored in favor of JWT) **/ @@ -187,7 +194,8 @@ class BigCommerce { 'X-Auth-Token': this.config.accessToken }, this.config.headers || {}), failOnLimitReached: this.config.failOnLimitReached, - agent: this.config.agent + agent: this.config.agent, + maxRetries: this.config.maxRetries }); } @@ -199,19 +207,26 @@ class BigCommerce { ); } - const extension = this.config.responseType === 'xml' ? '.xml' : ''; - const version = this.apiVersion; + if (this._tokenBucket) await this._tokenBucket.consume(); + if (this._semaphore) await this._semaphore.acquire(); - const request = this.createAPIRequest(); + try { + const extension = this.config.responseType === 'xml' ? '.xml' : ''; + const version = this.apiVersion; - let fullPath = `/stores/${this.config.storeHash}/${version}`; - if (version !== 'v3') { - fullPath += path.replace(/(\?|$)/, extension + '$1'); - } else { - fullPath += path; - } + const request = this.createAPIRequest(); + + let fullPath = `/stores/${this.config.storeHash}/${version}`; + if (version !== 'v3') { + fullPath += path.replace(/(\?|$)/, extension + '$1'); + } else { + fullPath += path; + } - return await request.run(type, fullPath, data); + return await request.run(type, fullPath, data); + } finally { + if (this._semaphore) this._semaphore.release(); + } } getTime() { diff --git a/lib/limiter.js b/lib/limiter.js new file mode 100644 index 0000000..3a5c54e --- /dev/null +++ b/lib/limiter.js @@ -0,0 +1,67 @@ +'use strict'; + +/** + * Rate limiter primitives for controlling API request concurrency and throughput. + */ + +/** + * Semaphore limits the number of concurrent in-flight operations. + */ +class Semaphore { + constructor(limit) { + this.limit = limit; + this.running = 0; + this.queue = []; + } + + acquire() { + if (this.running < this.limit) { + this.running++; + return Promise.resolve(); + } + return new Promise(resolve => { + this.queue.push(() => { + this.running++; + resolve(); + }); + }); + } + + release() { + this.running--; + const next = this.queue.shift(); + if (next) next(); + } +} + +/** + * TokenBucket limits the rate of operations to tokensPerSecond. + * Starts full, allowing an initial burst up to capacity. + */ +class TokenBucket { + constructor(tokensPerSecond) { + this.tokensPerSecond = tokensPerSecond; + this.tokens = tokensPerSecond; + this.lastRefill = Date.now(); + } + + async consume() { + this._refill(); + if (this.tokens >= 1) { + this.tokens -= 1; + return; + } + const waitMs = 1000 / this.tokensPerSecond; + await new Promise(resolve => setTimeout(resolve, waitMs)); + return this.consume(); + } + + _refill() { + const now = Date.now(); + const elapsed = (now - this.lastRefill) / 1000; + this.tokens = Math.min(this.tokensPerSecond, this.tokens + (elapsed * this.tokensPerSecond)); + this.lastRefill = now; + } +} + +module.exports = { Semaphore, TokenBucket }; diff --git a/lib/request.js b/lib/request.js index e68009d..4cbe27a 100644 --- a/lib/request.js +++ b/lib/request.js @@ -35,16 +35,22 @@ function parseResponse(res, body, resolve, reject) { } class Request { - constructor(hostname, { headers = { }, failOnLimitReached = false, agent = null } = { }) { + constructor(hostname, { + headers = { }, + failOnLimitReached = false, + agent = null, + maxRetries = 10 + } = { }) { if (!hostname) throw new Error('The hostname is required to make the call to the server.'); this.hostname = hostname; this.headers = headers; this.failOnLimitReached = failOnLimitReached; this.agent = agent; + this.maxRetries = maxRetries; } - run(method, path, data) { + run(method, path, data, _attempt = 0) { logger(`Requesting Data from: https://${this.hostname}${path} Using the ${method} method`); const dataString = JSON.stringify(data); @@ -92,15 +98,26 @@ class Request { return reject(err); } - logger(`You have reached the rate limit for the BigCommerce API, we will retry again in ${timeToWait} seconds.`); + if (_attempt >= this.maxRetries) { + const err = new Error(`Rate limit reached: retries exhausted after ${this.maxRetries} attempts. Please retry in ${timeToWait} seconds.`); + err.retryAfter = Number(timeToWait); + err.code = 429; + + return reject(err); + } + + const jitter = Math.floor(Math.random() * 500); + const delay = (timeToWait * 1000) + jitter; + + logger(`You have reached the rate limit for the BigCommerce API, we will retry again in ${timeToWait} seconds (attempt ${_attempt + 1}/${this.maxRetries}).`); return setTimeout(() => { logger('Restarting request call after suggested time'); - this.run(method, path, data) + this.run(method, path, data, _attempt + 1) .then(resolve) .catch(reject); - }, timeToWait * 1000); + }, delay); } res.on('data', chunk => body += chunk); diff --git a/test/bigcommerce.js b/test/bigcommerce.js index 6a7894b..e3167f2 100644 --- a/test/bigcommerce.js +++ b/test/bigcommerce.js @@ -38,6 +38,32 @@ describe('BigCommerce', () => { it('should set api version to a default', () => { new BigCommerce({ apiVersion: 'v3' }).apiVersion.should.equal('v3'); }); + + context('given limiter config', () => { + it('should create a semaphore when maxConcurrent is provided', () => { + const limitedBc = new BigCommerce({ limiter: { maxConcurrent: 3 } }); + should.exist(limitedBc._semaphore); + limitedBc._semaphore.limit.should.equal(3); + }); + + it('should create a token bucket when requestsPerSecond is provided', () => { + const limitedBc = new BigCommerce({ limiter: { requestsPerSecond: 5 } }); + should.exist(limitedBc._tokenBucket); + limitedBc._tokenBucket.tokensPerSecond.should.equal(5); + }); + + it('should create both gates when both options are provided', () => { + const limitedBc = new BigCommerce({ limiter: { maxConcurrent: 5, requestsPerSecond: 10 } }); + should.exist(limitedBc._semaphore); + should.exist(limitedBc._tokenBucket); + }); + + it('should not create any gates when limiter is omitted', () => { + const plainBc = new BigCommerce({ test: true }); + should.not.exist(plainBc._semaphore); + should.not.exist(plainBc._tokenBucket); + }); + }); }); describe('#verify', () => { @@ -348,4 +374,64 @@ describe('BigCommerce', () => { }); }); }); + + describe('#request with limiter', () => { + beforeEach(() => { + self.requestStub = self.sandbox.stub(Request.prototype, 'run') + .returns(Promise.resolve({ ok: true })); + }); + + it('should pass maxRetries config through to the Request via createAPIRequest', () => { + const bcWithRetries = new BigCommerce({ + accessToken: '123456', + storeHash: '12abc', + maxRetries: 5 + }); + bcWithRetries.config.maxRetries.should.equal(5); + const req = bcWithRetries.createAPIRequest(); + req.maxRetries.should.equal(5); + }); + + it('should acquire and release the semaphore on each request', () => { + const pacedBc = new BigCommerce({ + accessToken: '123456', + storeHash: '12abc', + limiter: { maxConcurrent: 2 } + }); + + const acquireSpy = self.sandbox.spy(pacedBc._semaphore, 'acquire'); + const releaseSpy = self.sandbox.spy(pacedBc._semaphore, 'release'); + + return pacedBc.get('/products').then(() => { + sinon.assert.calledOnce(acquireSpy); + sinon.assert.calledOnce(releaseSpy); + }); + }); + + it('should release the semaphore even when the request fails', () => { + const pacedBc = new BigCommerce({ + accessToken: '123456', + storeHash: '12abc', + limiter: { maxConcurrent: 2 } + }); + + self.requestStub.returns(Promise.reject(new Error('network error'))); + const releaseSpy = self.sandbox.spy(pacedBc._semaphore, 'release'); + + return pacedBc.get('/products') + .catch(() => { + sinon.assert.calledOnce(releaseSpy); + }); + }); + + it('should make requests normally without pacing config', () => { + const normalBc = new BigCommerce({ + accessToken: '123456', + storeHash: '12abc' + }); + return normalBc.get('/products').then(res => { + res.should.deep.equal({ ok: true }); + }); + }); + }); }); diff --git a/test/limiter.js b/test/limiter.js new file mode 100644 index 0000000..edb68b3 --- /dev/null +++ b/test/limiter.js @@ -0,0 +1,116 @@ +'use strict'; + +const { Semaphore, TokenBucket } = require('../lib/limiter'); + +require('chai').should(); + +describe('Semaphore', () => { + context('given a limit of 2', () => { + it('should resolve immediately when under the limit', () => { + const sem = new Semaphore(2); + return sem.acquire() + .then(() => sem.acquire()) + .then(() => sem.running.should.equal(2)); + }); + + it('should block the third acquire until a release', done => { + const sem = new Semaphore(2); + + sem.acquire(); + sem.acquire(); + + let resolved = false; + sem.acquire().then(() => { resolved = true; }); + + setTimeout(() => { + resolved.should.equal(false); + sem.release(); + + setTimeout(() => { + resolved.should.equal(true); + done(); + }, 10); + }, 20); + }); + + it('should decrement running on release', () => { + const sem = new Semaphore(1); + return sem.acquire() + .then(() => { + sem.running.should.equal(1); + sem.release(); + sem.running.should.equal(0); + }); + }); + + it('should process queued acquires in order', () => { + const sem = new Semaphore(1); + const order = []; + + return sem.acquire().then(() => { + const p1 = sem.acquire().then(() => { order.push(1); sem.release(); }); + const p2 = sem.acquire().then(() => { order.push(2); sem.release(); }); + + sem.release(); + + return Promise.all([p1, p2]).then(() => { + order.should.deep.equal([1, 2]); + }); + }); + }); + }); +}); + +describe('TokenBucket', () => { + context('given a rate of 10 tokens per second', () => { + it('should consume up to capacity immediately', () => { + const bucket = new TokenBucket(10); + const start = Date.now(); + const consumptions = []; + for (let i = 0; i < 10; i++) { + consumptions.push(bucket.consume()); + } + return Promise.all(consumptions).then(() => { + (Date.now() - start).should.be.below(100); + }); + }); + + it('should wait when tokens are exhausted', () => { + const bucket = new TokenBucket(10); + const consumptions = []; + for (let i = 0; i < 10; i++) { + consumptions.push(bucket.consume()); + } + return Promise.all(consumptions).then(() => { + const start = Date.now(); + return bucket.consume().then(() => { + (Date.now() - start).should.be.at.least(80); + }); + }); + }); + + it('should refill tokens over time', done => { + const bucket = new TokenBucket(10); + const consumptions = []; + for (let i = 0; i < 10; i++) { + consumptions.push(bucket.consume()); + } + Promise.all(consumptions).then(() => { + setTimeout(() => { + bucket._refill(); + bucket.tokens.should.be.at.least(1.5); + done(); + }, 200); + }); + }); + + it('should not exceed capacity on refill', done => { + const bucket = new TokenBucket(5); + setTimeout(() => { + bucket._refill(); + bucket.tokens.should.be.at.most(5); + done(); + }, 300); + }); + }); +}); diff --git a/test/request.js b/test/request.js index 0214029..46fa0ec 100644 --- a/test/request.js +++ b/test/request.js @@ -51,8 +51,37 @@ describe('Request', () => { }); }); }); + + context('given maxRetries is exhausted', () => { + beforeEach(() => { + nock.cleanAll(); + nock('https://api.bigcommerce.com') + .post('/orders') + .reply(429, { }, { 'X-Retry-After': 0.1 }) + .post('/orders') + .reply(429, { }, { 'X-Retry-After': 0.1 }) + .post('/orders') + .reply(429, { }, { 'X-Retry-After': 0.1 }); + }); + + it('should reject after exhausting all retries', () => { + const limitedRequest = new Request('api.bigcommerce.com', { + headers: { 'Content-Type': 'application/json' }, + maxRetries: 2 + }); + + return limitedRequest.run('post', '/orders') + .then(() => should.fail('You shall not pass!')) + .catch(e => { + e.message.should.match(/retries exhausted/); + e.code.should.equal(429); + e.retryAfter.should.equal(0.1); + }); + }); + }); }); + context('given a bad request or internal error is returned', () => { beforeEach(() => { nock('https://api.bigcommerce.com') diff --git a/yarn.lock b/yarn.lock index df05040..e460284 100644 --- a/yarn.lock +++ b/yarn.lock @@ -220,6 +220,11 @@ browser-stdout@1.3.1: resolved "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz#baa559ee14ced73452229bad7326467c61fabd60" integrity sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw== +buffer-equal-constant-time@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819" + integrity sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA== + buffer-from@^1.0.0: version "1.1.1" resolved "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz#32713bc028f75c02fdb710d7c7bcec1f2c6070ef" @@ -536,6 +541,13 @@ doctrine@^2.1.0: dependencies: esutils "^2.0.2" +ecdsa-sig-formatter@1.0.11: + version "1.0.11" + resolved "https://registry.yarnpkg.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz#ae0f0fa2d85045ef14a817daa3ce9acd0489e5bf" + integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ== + dependencies: + safe-buffer "^5.0.1" + emoji-regex@^7.0.1: version "7.0.3" resolved "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz#933a04052860c85e83c122479c4748a8e4c72156" @@ -1035,6 +1047,11 @@ invert-kv@^2.0.0: resolved "https://registry.npmjs.org/invert-kv/-/invert-kv-2.0.0.tgz#7393f5afa59ec9ff5f67a27620d11c226e3eec02" integrity sha512-wPVv/y/QQ/Uiirj/vh3oP+1Ww+AWehmi1g5fFWGPF6IpCBCDVrhgHRMvrLfdYcwDh3QJbGXDW4JAuzxElLSqKA== +ip-regex@^4.0.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/ip-regex/-/ip-regex-4.3.0.tgz#687275ab0f57fa76978ff8f4dddc8a23d5990db5" + integrity sha512-B9ZWJxHHOHUhUjCPrMpLD4xEq35bUTClHM1S6CBU5ixQnkZmwipwgc96vAd7AAGM9TGHvJR+Uss+/Ak6UphK+Q== + is-arrayish@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" @@ -1072,6 +1089,13 @@ is-fullwidth-code-point@^2.0.0: resolved "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f" integrity sha1-o7MKXE8ZkYMWeqq5O+764937ZU8= +is-ip@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/is-ip/-/is-ip-3.1.0.tgz#2ae5ddfafaf05cb8008a62093cf29734f657c5d8" + integrity sha512-35vd5necO7IitFPjd/YBeqwWnyDWbuLH9ZXQdMfDA8TEo7pv5X8yfrvVO3xbJbLUlERCMvf6X0hTUamQxCYJ9Q== + dependencies: + ip-regex "^4.0.0" + is-promise@^2.1.0: version "2.1.0" resolved "https://registry.npmjs.org/is-promise/-/is-promise-2.1.0.tgz#79a2a9ece7f096e80f36d2b2f3bc16c1ff4bf3fa" @@ -1205,6 +1229,39 @@ json-stringify-safe@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" +jsonwebtoken@^8.5.1: + version "8.5.1" + resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-8.5.1.tgz#00e71e0b8df54c2121a1f26137df2280673bcc0d" + integrity sha512-XjwVfRS6jTMsqYs0EsuJ4LGxXV14zQybNd4L2r0UvbVnSF9Af8x7p5MzbJ90Ioz/9TI41/hTCvznF/loiSzn8w== + dependencies: + jws "^3.2.2" + lodash.includes "^4.3.0" + lodash.isboolean "^3.0.3" + lodash.isinteger "^4.0.4" + lodash.isnumber "^3.0.3" + lodash.isplainobject "^4.0.6" + lodash.isstring "^4.0.1" + lodash.once "^4.0.0" + ms "^2.1.1" + semver "^5.6.0" + +jwa@^1.4.2: + version "1.4.2" + resolved "https://registry.yarnpkg.com/jwa/-/jwa-1.4.2.tgz#16011ac6db48de7b102777e57897901520eec7b9" + integrity sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw== + dependencies: + buffer-equal-constant-time "^1.0.1" + ecdsa-sig-formatter "1.0.11" + safe-buffer "^5.0.1" + +jws@^3.2.2: + version "3.2.3" + resolved "https://registry.yarnpkg.com/jws/-/jws-3.2.3.tgz#5ac0690b460900a27265de24520526853c0b8ca1" + integrity sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g== + dependencies: + jwa "^1.4.2" + safe-buffer "^5.0.1" + lcid@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/lcid/-/lcid-2.0.0.tgz#6ef5d2df60e52f82eb228a4c373e8d1f397253cf" @@ -1263,6 +1320,41 @@ lodash.flattendeep@^4.4.0: resolved "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz#fb030917f86a3134e5bc9bec0d69e0013ddfedb2" integrity sha1-+wMJF/hqMTTlvJvsDWngAT3f7bI= +lodash.includes@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f" + integrity sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w== + +lodash.isboolean@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6" + integrity sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg== + +lodash.isinteger@^4.0.4: + version "4.0.4" + resolved "https://registry.yarnpkg.com/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz#619c0af3d03f8b04c31f5882840b77b11cd68343" + integrity sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA== + +lodash.isnumber@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz#3ce76810c5928d03352301ac287317f11c0b1ffc" + integrity sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw== + +lodash.isplainobject@^4.0.6: + version "4.0.6" + resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb" + integrity sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA== + +lodash.isstring@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz#d527dfb5456eca7cc9bb95d5daeaf88ba54a5451" + integrity sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw== + +lodash.once@^4.0.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac" + integrity sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg== + lodash@^4.17.11, lodash@^4.17.4, lodash@^4.17.5, lodash@^4.3.0: version "4.17.15" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548"