├── .gitignore
├── .travis.yml
├── gulpfile.coffee
├── package.json
├── README.hbs
├── README.md
├── src
└── tokenbucket.coffee
├── lib
└── tokenbucket.js
└── test
└── tokenbucket.spec.coffee
/.gitignore:
--------------------------------------------------------------------------------
1 | coverage
2 | node_modules
3 | npm-debug.log
4 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | node_js:
3 | - "0.10"
4 | services:
5 | - redis-server
6 | after_script:
7 | - npm run coveralls
--------------------------------------------------------------------------------
/gulpfile.coffee:
--------------------------------------------------------------------------------
1 | # Load all required libraries.
2 | gulp = require 'gulp'
3 | gutil = require 'gulp-util'
4 | coffee = require 'gulp-coffee'
5 | istanbul = require 'gulp-istanbul'
6 | mocha = require 'gulp-mocha'
7 | plumber = require 'gulp-plumber'
8 | concat = require 'gulp-concat'
9 | fs = require 'fs'
10 | coveralls = require 'gulp-coveralls'
11 | gulpJsdoc2md = require 'gulp-jsdoc-to-markdown'
12 |
13 | onError = (err) ->
14 | gutil.beep()
15 | gutil.log err.stack
16 |
17 | gulp.task 'coffee', ->
18 | gulp.src 'src/**/*.coffee'
19 | .pipe plumber({errorHandler: onError}) # Pevent pipe breaking caused by errors from gulp plugins
20 | .pipe coffee({bare: true})
21 | .pipe gulp.dest './lib/'
22 |
23 | gulp.task 'test', ['coffee'], ->
24 | gulp.src 'lib/**/*.js'
25 | .pipe istanbul() # Covering files
26 | .pipe istanbul.hookRequire() # Force `require` to return covered files
27 | .on 'finish', ->
28 | gulp.src 'test/**/*.spec.coffee'
29 | .pipe mocha
30 | reporter: 'spec'
31 | compilers: 'coffee:coffee-script'
32 | .pipe istanbul.writeReports() # Creating the reports after tests run
33 |
34 | gulp.task 'coveralls', ->
35 | gulp.src 'coverage/lcov.info'
36 | .pipe coveralls()
37 |
38 | gulp.task 'doc', ->
39 | gulp.src 'lib/**/*.js'
40 | .pipe concat('README.md')
41 | .pipe gulpJsdoc2md({template: fs.readFileSync('README.hbs', 'utf8'), 'param-list-format': 'list'})
42 | .on 'error', (err) ->
43 | gutil.log 'jsdoc2md failed:', err.message
44 | .pipe gulp.dest('.')
45 |
46 | gulp.task 'watch', ->
47 | gulp.watch 'src/**/*.coffee', ['coffee']
48 |
49 | gulp.task 'default', ['coffee', 'watch']
50 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "tokenbucket",
3 | "version": "0.3.2",
4 | "description": "A flexible rate limiter using different variations of the Token Bucket algorithm, with hierarchy support, and optional persistence in Redis. Useful for limiting API requests, or other tasks that need to be throttled.",
5 | "keywords": [
6 | "rate limiter",
7 | "request limiter",
8 | "limit rate",
9 | "limit requests",
10 | "token bucket",
11 | "leaky bucket",
12 | "throttle",
13 | "throttling",
14 | "throttler"
15 | ],
16 | "homepage": "https://github.com/jesucarr/tokenbucket",
17 | "bugs": "https://github.com/jesucarr/tokenbucket/issues",
18 | "author": {
19 | "name": "Jesús Carrera",
20 | "email": "jesus.carrera@frontendmatters.com",
21 | "url": "https://github.com/jesucarr"
22 | },
23 | "main": "./lib/tokenbucket.js",
24 | "repository": {
25 | "type": "git",
26 | "url": "https://github.com/jesucarr/tokenbucket.git"
27 | },
28 | "scripts": {
29 | "test": "gulp test",
30 | "coveralls": "gulp coveralls"
31 | },
32 | "dependencies": {
33 | "bluebird": "2.9.24",
34 | "redis": "^0.12.1"
35 | },
36 | "devDependencies": {
37 | "chai": "2.2.0",
38 | "chai-as-promised": "5.0.0",
39 | "coffee-script": "1.9.2",
40 | "gulp": "3.8.11",
41 | "gulp-coffee": "2.3.1",
42 | "gulp-concat": "2.5.2",
43 | "gulp-coveralls": "0.1.3",
44 | "gulp-istanbul": "0.8.1",
45 | "gulp-jsdoc-to-markdown": "1.1.1",
46 | "gulp-mocha": "2.0.1",
47 | "gulp-plumber": "1.0.0",
48 | "gulp-util": "3.0.4",
49 | "mocha": "2.2.4",
50 | "sinon": "1.14.1",
51 | "sinon-chai": "2.7.0"
52 | },
53 | "engines": {
54 | "node": ">=0.10.0",
55 | "npm": ">=1.2.10"
56 | },
57 | "license": "MIT"
58 | }
59 |
--------------------------------------------------------------------------------
/README.hbs:
--------------------------------------------------------------------------------
1 | [](https://david-dm.org/jesucarr/tokenbucket)
2 | [](https://david-dm.org/jesucarr/tokenbucket#info=devDependencies)
3 | [](https://travis-ci.org/jesucarr/tokenbucket)
4 | [](https://coveralls.io/r/jesucarr/tokenbucket)
5 | [](https://npmjs.org/package/tokenbucket)
6 |
7 | {{#module name="tokenbucket"}}
8 | {{>docs~}}
9 | {{/module}}
10 |
11 | ## Testing
12 |
13 | npm test
14 |
15 | ## Development and Contributing
16 |
17 | The source code is in CoffeeScript, to compile automatically when you save, run
18 |
19 | gulp
20 |
21 | Documentation is inline, using [jsdoc-to-markdown](https://github.com/75lb/jsdoc-to-markdown). To update the README.md file just run
22 |
23 | gulp doc
24 |
25 | Contributions are welcome! Pull requests should have 100% code coverage.
26 |
27 | ## Credits
28 |
29 | Originally inspired by [limiter](https://github.com/jhurliman/node-rate-limiter).
30 |
31 | ## License
32 |
33 | The MIT License (MIT)
34 |
35 | Copyright 2015 Jesús Carrera - [frontendmatters.com](http://frontendmatters.com)
36 |
37 | Permission is hereby granted, free of charge, to any person obtaining a copy
38 | of this software and associated documentation files (the "Software"), to deal
39 | in the Software without restriction, including without limitation the rights
40 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
41 | copies of the Software, and to permit persons to whom the Software is
42 | furnished to do so, subject to the following conditions:
43 |
44 | The above copyright notice and this permission notice shall be included in
45 | all copies or substantial portions of the Software.
46 |
47 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
48 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
49 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
50 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
51 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
52 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
53 | THE SOFTWARE.
54 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [](https://david-dm.org/jesucarr/tokenbucket)
2 | [](https://david-dm.org/jesucarr/tokenbucket#info=devDependencies)
3 | [](https://travis-ci.org/jesucarr/tokenbucket)
4 | [](https://coveralls.io/r/jesucarr/tokenbucket)
5 | [](https://npmjs.org/package/tokenbucket)
6 |
7 |
8 | ## tokenbucket
9 | A flexible rate limiter configurable with different variations of the [Token Bucket algorithm](http://en.wikipedia.org/wiki/Token_bucket), with hierarchy support, and optional persistence in Redis. Useful for limiting API requests, or other tasks that need to be throttled.
10 |
11 | **Author:** Jesús Carrera [@jesucarr](https://twitter.com/jesucarr) - [frontendmatters.com](http://frontendmatters.com)
12 |
13 | **Installation**
14 | ```
15 | npm install tokenbucket
16 | ```
17 | **Example**
18 | Require the library
19 | ```javascript
20 | var TokenBucket = require('tokenbucket');
21 | ```
22 | Create a new tokenbucket instance. See below for possible options.
23 | ```javascript
24 | var tokenBucket = new TokenBucket();
25 | ```
26 |
27 | * [tokenbucket](#module_tokenbucket)
28 | * [TokenBucket](#exp_module_tokenbucket--TokenBucket) ⏏
29 | * [new TokenBucket([options])](#new_module_tokenbucket--TokenBucket_new)
30 | * [.removeTokens(tokensToRemove)](#module_tokenbucket--TokenBucket#removeTokens) ⇒ [Promise](https://github.com/petkaantonov/bluebird)
31 | * [.removeTokensSync(tokensToRemove)](#module_tokenbucket--TokenBucket#removeTokensSync) ⇒ Boolean
32 | * [.save()](#module_tokenbucket--TokenBucket#save) ⇒ [Promise](https://github.com/petkaantonov/bluebird)
33 | * [.loadSaved()](#module_tokenbucket--TokenBucket#loadSaved) ⇒ [Promise](https://github.com/petkaantonov/bluebird)
34 |
35 |
36 | ### TokenBucket ⏏
37 | The class that the module exports and that instantiate a new token bucket with the given options.
38 |
39 | **Kind**: Exported class
40 |
41 | #### new TokenBucket([options])
42 | **Params**
43 | - [options] Object - The options object
44 | - [.size] Number = 1 - Maximum number of tokens to hold in the bucket. Also known as the burst size.
45 | - [.tokensToAddPerInterval] Number = 1 - Number of tokens to add to the bucket in one interval.
46 | - [.interval] Number | String = 1000 - The time passing between adding tokens, in milliseconds or as one of the following strings: 'second', 'minute', 'hour', day'.
47 | - [.lastFill] Number - The timestamp of the last time when tokens where added to the bucket (last interval).
48 | - [.tokensLeft] Number = size - By default it will initialize full of tokens, but you can set here the number of tokens you want to initialize it with.
49 | - [.spread] Boolean = false - By default it will wait the interval, and then add all the tokensToAddPerInterval at once. If you set this to true, it will insert fractions of tokens at any given time, spreading the token addition along the interval.
50 | - [.maxWait] Number | String - The maximum time that we would wait for enough tokens to be added, in milliseconds or as one of the following strings: 'second', 'minute', 'hour', day'. If any of the parents in the hierarchy has `maxWait`, we will use the smallest value.
51 | - [.parentBucket] TokenBucket - A token bucket that will act as the parent of this bucket. Tokens removed in the children, will also be removed in the parent, and if the parent reach its limit, the children will get limited too.
52 | - [.redis] Object - Options object for Redis
53 | - .bucketName String - The name of the bucket to reference it in Redis. This is the only required field to set Redis persistance. The `bucketName` for each bucket **must be unique**.
54 | - [.redisClient] [redisClient](https://github.com/mranney/node_redis#rediscreateclient) - The [Redis client](https://github.com/mranney/node_redis#rediscreateclient) to save the bucket.
55 | - [.redisClientConfig] Object - [Redis client configuration](https://github.com/mranney/node_redis#rediscreateclient) to create the Redis client and save the bucket. If the `redisClient` option is set, this option will be ignored.
56 | - [.port] Number = 6379 - The connection port for the Redis client. See [configuration instructions](https://github.com/mranney/node_redis#rediscreateclient).
57 | - [.host] String = '127.0.0.1' - The connection host for the Redis client. See [configuration instructions](https://github.com/mranney/node_redis#rediscreateclient)
58 | - [.unixSocket] String - The connection unix socket for the Redis client. See [configuration instructions](https://github.com/mranney/node_redis#rediscreateclient)
59 | - [.options] String - The options for the Redis client. See [configuration instructions](https://github.com/mranney/node_redis#rediscreateclient)
60 |
61 | This options will be properties of the class instances. The properties `tokensLeft` and `lastFill` will get updated when we add/remove tokens.
62 |
63 | **Example**
64 | A filled token bucket that can hold 100 tokens, and it will add 30 tokens every minute (all at once).
65 | ```javascript
66 | var tokenBucket = new TokenBucket({
67 | size: 100,
68 | tokensToAddPerInterval: 30,
69 | interval: 'minute'
70 | });
71 | ```
72 | An empty token bucket that can hold 1 token (default), and it will add 1 token (default) every 500ms, spreading the token addition along the interval (so after 250ms it will have 0.5 tokens).
73 | ```javascript
74 | var tokenBucket = new TokenBucket({
75 | tokensLeft: 0,
76 | interval: 500,
77 | spread: true
78 | });
79 | ```
80 | A token bucket limited to 15 requests every 15 minutes, with a parent bucket limited to 1000 requests every 24 hours. The maximum time that we are willing to wait for enough tokens to be added is one hour.
81 | ```javascript
82 | var parentTokenBucket = new TokenBucket({
83 | size: 1000,
84 | interval: 'day'
85 | });
86 | var tokenBucket = new TokenBucket({
87 | size: 15,
88 | tokensToAddPerInterval: 15,
89 | interval: 'minute',
90 | maxWait: 'hour',
91 | parentBucket: parentBucket
92 | });
93 | ```
94 | A token bucket limited to 15 requests every 15 minutes, with a parent bucket limited to 1000 requests every 24 hours. The maximum time that we are willing to wait for enough tokens to be added is 5 minutes.
95 | ```javascript
96 | var parentTokenBucket = new TokenBucket({
97 | size: 1000,
98 | interval: 'day'
99 | maxWait: 1000 * 60 * 5,
100 | });
101 | var tokenBucket = new TokenBucket({
102 | size: 15,
103 | tokensToAddPerInterval: 15,
104 | interval: 'minute',
105 | parentBucket: parentBucket
106 | });
107 | ```
108 | A token bucket with Redis persistance setting the redis client.
109 | ```javascript
110 | redis = require('redis');
111 | redisClient = redis.redisClient();
112 | var tokenBucket = new TokenBucket({
113 | redis: {
114 | bucketName: 'myBucket',
115 | redisClient: redisClient
116 | }
117 | });
118 | ```
119 | A token bucket with Redis persistance setting the redis configuration.
120 | ```javascript
121 | var tokenBucket = new TokenBucket({
122 | redis: {
123 | bucketName: 'myBucket',
124 | redisClientConfig: {
125 | host: 'myhost',
126 | port: 1000,
127 | options: {
128 | auth_pass: 'mypass'
129 | }
130 | }
131 | }
132 | });
133 | ```
134 | Note that setting both `redisClient` or `redisClientConfig`, the redis client will be exposed at `tokenBucket.redis.redisClient`.
135 | This means you can watch for redis events, or execute redis client functions.
136 | For example if we want to close the redis connection we can execute `tokenBucket.redis.redisClient.quit()`.
137 |
138 | #### tokenBucket.removeTokens(tokensToRemove) ⇒ [Promise](https://github.com/petkaantonov/bluebird)
139 | Remove the requested number of tokens. If the bucket (and any parent buckets) contains enough tokens this will happen immediately. Otherwise, it will wait to get enough tokens.
140 |
141 | **Kind**: instance method of [TokenBucket](#exp_module_tokenbucket--TokenBucket)
142 | **Fulfil**: Number - The remaining tokens number, taking into account the parent if it has it.
143 | **Reject**: Error - Operational errors will be returned with the following `name` property, so they can be handled accordingly:
144 | * `'NotEnoughSize'` - The requested tokens are greater than the bucket size.
145 | * `'NoInfinityRemoval'` - It is not possible to remove infinite tokens, because even if the bucket has infinite size, the `tokensLeft` would be indeterminant.
146 | * `'ExceedsMaxWait'` - The time we need to wait to be able to remove the tokens requested exceed the time set in `maxWait` configuration (parent or child).
147 |
148 | .
149 | **Params**
150 | - tokensToRemove Number - The number of tokens to remove.
151 |
152 | **Example**
153 | We have some code that uses 3 API requests, so we would need to remove 3 tokens from our rate limiter bucket.
154 | If we had to wait more than the specified `maxWait` to get enough tokens, we would handle that in certain way.
155 | ```javascript
156 | tokenBucket.removeTokens(3).then(function(remainingTokens) {
157 | console.log('10 tokens removed, ' + remainingTokens + 'tokens left');
158 | // make triple API call
159 | }).catch(function (err) {
160 | console.log(err)
161 | if (err.name === 'ExceedsMaxWait') {
162 | // do something to handle this specific error
163 | }
164 | });
165 | ```
166 |
167 | #### tokenBucket.removeTokensSync(tokensToRemove) ⇒ Boolean
168 | Attempt to remove the requested number of tokens and return inmediately.
169 |
170 | **Kind**: instance method of [TokenBucket](#exp_module_tokenbucket--TokenBucket)
171 | **Returns**: Boolean - If it could remove the tokens inmediately it will return `true`, if not possible or needs to wait, it will return `false`.
172 | **Params**
173 | - tokensToRemove Number - The number of tokens to remove.
174 |
175 | **Example**
176 | ```javascript
177 | if (tokenBucket.removeTokensSync(50)) {
178 | // the tokens were removed
179 | } else {
180 | // the tokens were not removed
181 | }
182 | ```
183 |
184 | #### tokenBucket.save() ⇒ [Promise](https://github.com/petkaantonov/bluebird)
185 | Saves the bucket lastFill and tokensLeft to Redis. If it has any parents with `redis` options, they will get saved too.
186 |
187 | **Kind**: instance method of [TokenBucket](#exp_module_tokenbucket--TokenBucket)
188 | **Fulfil**: true
189 | **Reject**: Error - If we call this function and we didn't set the redis options, the error will have `'NoRedisOptions'` as the `name` property, so it can be handled specifically.
190 | If there is an error with Redis it will be rejected with the error returned by Redis.
191 | **Example**
192 | We have a worker process that uses 1 API requests, so we would need to remove 1 token (default) from our rate limiter bucket.
193 | If we had to wait more than the specified `maxWait` to get enough tokens, we would end the worker process.
194 | We are saving the bucket state in Redis, so we first load from Redis, and before exiting we save the updated bucket state.
195 | Note that if it had parent buckets with Redis options set, they would get saved too.
196 | ```javascript
197 | tokenBucket.loadSaved().then(function () {
198 | // now the bucket has the state it had last time we saved it
199 | return tokenBucket.removeTokens().then(function() {
200 | // make API call
201 | });
202 | }).catch(function (err) {
203 | if (err.name === 'ExceedsMaxWait') {
204 | tokenBucket.save().then(function () {
205 | process.kill(process.pid, 'SIGKILL');
206 | }).catch(function (err) {
207 | if (err.name == 'NoRedisOptions') {
208 | // do something to handle this specific error
209 | }
210 | });
211 | }
212 | });
213 | ```
214 |
215 | #### tokenBucket.loadSaved() ⇒ [Promise](https://github.com/petkaantonov/bluebird)
216 | Loads the bucket lastFill and tokensLeft as it was saved in Redis. If it has any parents with `redis` options, they will get loaded too.
217 |
218 | **Kind**: instance method of [TokenBucket](#exp_module_tokenbucket--TokenBucket)
219 | **Fulfil**: true
220 | **Reject**: Error - If we call this function and we didn't set the redis options, the error will have `'NoRedisOptions'` as the `name` property, so it can be handled specifically.
221 | If there is an error with Redis it will be rejected with the error returned by Redis.
222 | **Example**
223 | See [save](#module_tokenbucket--TokenBucket#save)
224 |
225 | ## Testing
226 |
227 | npm test
228 |
229 | ## Development and Contributing
230 |
231 | The source code is in CoffeeScript, to compile automatically when you save, run
232 |
233 | gulp
234 |
235 | Documentation is inline, using [jsdoc-to-markdown](https://github.com/75lb/jsdoc-to-markdown). To update the README.md file just run
236 |
237 | gulp doc
238 |
239 | Contributions are welcome! Pull requests should have 100% code coverage.
240 |
241 | ## Credits
242 |
243 | Originally inspired by [limiter](https://github.com/jhurliman/node-rate-limiter).
244 |
245 | ## License
246 |
247 | The MIT License (MIT)
248 |
249 | Copyright 2015 Jesús Carrera - [frontendmatters.com](http://frontendmatters.com)
250 |
251 | Permission is hereby granted, free of charge, to any person obtaining a copy
252 | of this software and associated documentation files (the "Software"), to deal
253 | in the Software without restriction, including without limitation the rights
254 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
255 | copies of the Software, and to permit persons to whom the Software is
256 | furnished to do so, subject to the following conditions:
257 |
258 | The above copyright notice and this permission notice shall be included in
259 | all copies or substantial portions of the Software.
260 |
261 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
262 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
263 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
264 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
265 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
266 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
267 | THE SOFTWARE.
268 |
--------------------------------------------------------------------------------
/src/tokenbucket.coffee:
--------------------------------------------------------------------------------
1 | 'use strict'
2 | Promise = require 'bluebird'
3 | redis = require 'redis'
4 | ###*
5 | * @module tokenbucket
6 | * @desc A flexible rate limiter configurable with different variations of the [Token Bucket algorithm](http://en.wikipedia.org/wiki/Token_bucket), with hierarchy support, and optional persistence in Redis. Useful for limiting API requests, or other tasks that need to be throttled.
7 | * @author Jesús Carrera [@jesucarr](https://twitter.com/jesucarr) - [frontendmatters.com](http://frontendmatters.com)
8 | *
9 | * **Installation**
10 | * ```
11 | * npm install tokenbucket
12 | * ```
13 | *
14 | * @example
15 | * Require the library
16 | * ```javascript
17 | * var TokenBucket = require('tokenbucket');
18 | * ```
19 | * Create a new tokenbucket instance. See below for possible options.
20 | * ```javascript
21 | * var tokenBucket = new TokenBucket();
22 | * ```
23 | ###
24 | ###*
25 | * @class
26 | * @alias module:tokenbucket
27 | * @classdesc The class that the module exports and that instantiate a new token bucket with the given options.
28 | *
29 | * @param {Object} [options] - The options object
30 | * @param {Number} [options.size=1] - Maximum number of tokens to hold in the bucket. Also known as the burst size.
31 | * @param {Number} [options.tokensToAddPerInterval=1] - Number of tokens to add to the bucket in one interval.
32 | * @param {Number|String} [options.interval=1000] - The time passing between adding tokens, in milliseconds or as one of the following strings: 'second', 'minute', 'hour', day'.
33 | * @param {Number} [options.lastFill] - The timestamp of the last time when tokens where added to the bucket (last interval).
34 | * @param {Number} [options.tokensLeft=size] - By default it will initialize full of tokens, but you can set here the number of tokens you want to initialize it with.
35 | * @param {Boolean} [options.spread=false] - By default it will wait the interval, and then add all the tokensToAddPerInterval at once. If you set this to true, it will insert fractions of tokens at any given time, spreading the token addition along the interval.
36 | * @param {Number|String} [options.maxWait] - The maximum time that we would wait for enough tokens to be added, in milliseconds or as one of the following strings: 'second', 'minute', 'hour', day'. If any of the parents in the hierarchy has `maxWait`, we will use the smallest value.
37 | * @param {TokenBucket} [options.parentBucket] - A token bucket that will act as the parent of this bucket. Tokens removed in the children, will also be removed in the parent, and if the parent reach its limit, the children will get limited too.
38 | * @param {Object} [options.redis] - Options object for Redis
39 | * @param {String} options.redis.bucketName - The name of the bucket to reference it in Redis. This is the only required field to set Redis persistance. The `bucketName` for each bucket **must be unique**.
40 | * @param {external:redisClient} [options.redis.redisClient] - The [Redis client](https://github.com/mranney/node_redis#rediscreateclient) to save the bucket.
41 | * @param {Object} [options.redis.redisClientConfig] - [Redis client configuration](https://github.com/mranney/node_redis#rediscreateclient) to create the Redis client and save the bucket. If the `redisClient` option is set, this option will be ignored.
42 | * @param {Number} [options.redis.redisClientConfig.port=6379] - The connection port for the Redis client. See [configuration instructions](https://github.com/mranney/node_redis#rediscreateclient).
43 | * @param {String} [options.redis.redisClientConfig.host='127.0.0.1'] - The connection host for the Redis client. See [configuration instructions](https://github.com/mranney/node_redis#rediscreateclient)
44 | * @param {String} [options.redis.redisClientConfig.unixSocket] - The connection unix socket for the Redis client. See [configuration instructions](https://github.com/mranney/node_redis#rediscreateclient)
45 | * @param {String} [options.redis.redisClientConfig.options] - The options for the Redis client. See [configuration instructions](https://github.com/mranney/node_redis#rediscreateclient)
46 | *
47 | * This options will be properties of the class instances. The properties `tokensLeft` and `lastFill` will get updated when we add/remove tokens.
48 | *
49 | * @example
50 | *
51 | * A filled token bucket that can hold 100 tokens, and it will add 30 tokens every minute (all at once).
52 | * ```javascript
53 | * var tokenBucket = new TokenBucket({
54 | * size: 100,
55 | * tokensToAddPerInterval: 30,
56 | * interval: 'minute'
57 | * });
58 | * ```
59 | * An empty token bucket that can hold 1 token (default), and it will add 1 token (default) every 500ms, spreading the token addition along the interval (so after 250ms it will have 0.5 tokens).
60 | * ```javascript
61 | * var tokenBucket = new TokenBucket({
62 | * tokensLeft: 0,
63 | * interval: 500,
64 | * spread: true
65 | * });
66 | * ```
67 | * A token bucket limited to 15 requests every 15 minutes, with a parent bucket limited to 1000 requests every 24 hours. The maximum time that we are willing to wait for enough tokens to be added is one hour.
68 | * ```javascript
69 | * var parentTokenBucket = new TokenBucket({
70 | * size: 1000,
71 | * interval: 'day'
72 | * });
73 | * var tokenBucket = new TokenBucket({
74 | * size: 15,
75 | * tokensToAddPerInterval: 15,
76 | * interval: 'minute',
77 | * maxWait: 'hour',
78 | * parentBucket: parentBucket
79 | * });
80 | * ```
81 | * A token bucket limited to 15 requests every 15 minutes, with a parent bucket limited to 1000 requests every 24 hours. The maximum time that we are willing to wait for enough tokens to be added is 5 minutes.
82 | * ```javascript
83 | * var parentTokenBucket = new TokenBucket({
84 | * size: 1000,
85 | * interval: 'day'
86 | * maxWait: 1000 * 60 * 5,
87 | * });
88 | * var tokenBucket = new TokenBucket({
89 | * size: 15,
90 | * tokensToAddPerInterval: 15,
91 | * interval: 'minute',
92 | * parentBucket: parentBucket
93 | * });
94 | * ```
95 | * A token bucket with Redis persistance setting the redis client.
96 | * ```javascript
97 | * redis = require('redis');
98 | * redisClient = redis.redisClient();
99 | * var tokenBucket = new TokenBucket({
100 | * redis: {
101 | * bucketName: 'myBucket',
102 | * redisClient: redisClient
103 | * }
104 | * });
105 | * ```
106 | * A token bucket with Redis persistance setting the redis configuration.
107 | * ```javascript
108 | * var tokenBucket = new TokenBucket({
109 | * redis: {
110 | * bucketName: 'myBucket',
111 | * redisClientConfig: {
112 | * host: 'myhost',
113 | * port: 1000,
114 | * options: {
115 | * auth_pass: 'mypass'
116 | * }
117 | * }
118 | * }
119 | * });
120 | * ```
121 | * Note that setting both `redisClient` or `redisClientConfig`, the redis client will be exposed at `tokenBucket.redis.redisClient`.
122 | * This means you can watch for redis events, or execute redis client functions.
123 | * For example if we want to close the redis connection we can execute `tokenBucket.redis.redisClient.quit()`.
124 | ###
125 |
126 | class TokenBucket
127 |
128 | # Private members
129 |
130 | errors =
131 | noRedisOptions: 'Redis options missing.'
132 | notEnoughSize: (tokensToRemove, size) -> 'Requested tokens (' + tokensToRemove + ') exceed bucket size (' + size + ')'
133 | noInfinityRemoval: 'Not possible to remove infinite tokens.'
134 | exceedsMaxWait: 'It will exceed maximum waiting time'
135 |
136 | # Add new tokens to the bucket if possible.
137 | addTokens = ->
138 | now = +new Date()
139 | timeSinceLastFill = Math.max(now - @lastFill, 0)
140 | if timeSinceLastFill
141 | tokensSinceLastFill = timeSinceLastFill * (@tokensToAddPerInterval / @interval)
142 | else
143 | tokensSinceLastFill = 0
144 | if @spread or (timeSinceLastFill >= @interval)
145 | @lastFill = now
146 | @tokensLeft = Math.min(@tokensLeft + tokensSinceLastFill, @size)
147 |
148 | constructor: (config) ->
149 | {@size, @tokensToAddPerInterval, @interval, @tokensLeft, @lastFill, @spread, @redis, @parentBucket, @maxWait} = config if config
150 | if @redis? and @redis.bucketName?
151 | if @redis.redisClient?
152 | delete @redis.redisClientConfig
153 | else
154 | @redis.redisClientConfig ?= {}
155 | if @redis.redisClientConfig.unixSocket?
156 | @redis.redisClient = redis.createClient @redis.redisClientConfig.unixSocket, @redis.redisClientConfig.options
157 | else
158 | @redis.redisClientConfig.port ?= 6379
159 | @redis.redisClientConfig.host ?= '127.0.0.1'
160 | @redis.redisClientConfig.options ?= {}
161 | @redis.redisClient = redis.createClient @redis.redisClientConfig.port, @redis.redisClientConfig.host, @redis.redisClientConfig.options
162 | else
163 | delete @redis
164 | if @size != Number.POSITIVE_INFINITY then @size ?= 1
165 | @tokensLeft ?= @size
166 | @tokensToAddPerInterval ?= 1
167 | if !@interval?
168 | @interval = 1000
169 | else if typeof @interval == 'string'
170 | switch @interval
171 | when 'second' then @interval = 1000
172 | when 'minute' then @interval = 1000 * 60
173 | when 'hour' then @interval = 1000 * 60 * 60
174 | when 'day' then @interval = 1000 * 60 * 60 * 24
175 | if typeof @maxWait == 'string'
176 | switch @maxWait
177 | when 'second' then @maxWait = 1000
178 | when 'minute' then @maxWait = 1000 * 60
179 | when 'hour' then @maxWait = 1000 * 60 * 60
180 | when 'day' then @maxWait = 1000 * 60 * 60 * 24
181 | @lastFill ?= +new Date()
182 |
183 | # Public API
184 |
185 | ###*
186 | * @desc Remove the requested number of tokens. If the bucket (and any parent buckets) contains enough tokens this will happen immediately. Otherwise, it will wait to get enough tokens.
187 | * @param {Number} tokensToRemove - The number of tokens to remove.
188 | * @returns {external:Promise}
189 | * @fulfil {Number} - The remaining tokens number, taking into account the parent if it has it.
190 | * @reject {Error} - Operational errors will be returned with the following `name` property, so they can be handled accordingly:
191 | * * `'NotEnoughSize'` - The requested tokens are greater than the bucket size.
192 | * * `'NoInfinityRemoval'` - It is not possible to remove infinite tokens, because even if the bucket has infinite size, the `tokensLeft` would be indeterminant.
193 | * * `'ExceedsMaxWait'` - The time we need to wait to be able to remove the tokens requested exceed the time set in `maxWait` configuration (parent or child).
194 | *
195 | * .
196 | * @example
197 | * We have some code that uses 3 API requests, so we would need to remove 3 tokens from our rate limiter bucket.
198 | * If we had to wait more than the specified `maxWait` to get enough tokens, we would handle that in certain way.
199 | * ```javascript
200 | * tokenBucket.removeTokens(3).then(function(remainingTokens) {
201 | * console.log('10 tokens removed, ' + remainingTokens + 'tokens left');
202 | * // make triple API call
203 | * }).catch(function (err) {
204 | * console.log(err)
205 | * if (err.name === 'ExceedsMaxWait') {
206 | * // do something to handle this specific error
207 | * }
208 | * });
209 | * ```
210 | ###
211 | removeTokens: (tokensToRemove) =>
212 | resolver = Promise.pending()
213 | tokensToRemove ||= 1
214 | # Make sure the bucket can hold the requested number of tokens
215 | if tokensToRemove > @size
216 | error = new Error(errors.notEnoughSize tokensToRemove, @size)
217 | Object.defineProperty error, 'name', {value: 'NotEnoughSize'}
218 | resolver.reject error
219 | return resolver.promise
220 | # Not possible to remove infitine tokens because even if the bucket has infinite size, the tokensLeft would be indeterminant
221 | if tokensToRemove == Number.POSITIVE_INFINITY
222 | error = new Error errors.noInfinityRemoval
223 | Object.defineProperty error, 'name', {value: 'NoInfinityRemoval'}
224 | resolver.reject error
225 | return resolver.promise
226 | # Add new tokens into this bucket if necessary
227 | addTokens.call(@)
228 | # Calculates the waiting time necessary to get enough tokens for the specified bucket
229 | calculateWaitInterval = (bucket) ->
230 | tokensNeeded = tokensToRemove - bucket.tokensLeft
231 | timeSinceLastFill = Math.max(+new Date() - bucket.lastFill, 0)
232 | if bucket.spread
233 | timePerToken = bucket.interval / bucket.tokensToAddPerInterval
234 | waitInterval = Math.ceil(tokensNeeded * timePerToken - timeSinceLastFill)
235 | else
236 | # waitInterval = @interval - timeSinceLastFill
237 | intervalsNeeded = tokensNeeded / bucket.tokensToAddPerInterval
238 | waitInterval = Math.ceil(intervalsNeeded * bucket.interval - timeSinceLastFill)
239 | Math.max(waitInterval, 0)
240 | # Calculate the wait time to get enough tokens taking into account the parents
241 | bucketWaitInterval = calculateWaitInterval(@)
242 | hierarchyWaitInterval = bucketWaitInterval
243 | if @maxWait? then hierarchyMaxWait = @maxWait
244 | parentBucket = @parentBucket
245 | while parentBucket?
246 | hierarchyWaitInterval += calculateWaitInterval(parentBucket)
247 | if parentBucket.maxWait?
248 | if hierarchyMaxWait?
249 | hierarchyMaxWait = Math.min(parentBucket.maxWait, hierarchyMaxWait)
250 | else
251 | hierarchyMaxWait = parentBucket.maxWait
252 | parentBucket = parentBucket.parentBucket
253 | # If we need to wait longer than maxWait, reject with the right error
254 | if hierarchyMaxWait? and (hierarchyWaitInterval > hierarchyMaxWait)
255 | error = new Error errors.exceedsMaxWait
256 | Object.defineProperty error, 'name', {value: 'ExceedsMaxWait'}
257 | resolver.reject error
258 | return resolver.promise
259 | # Times out to get enough tokens
260 | wait = =>
261 | waitResolver = Promise.pending()
262 | setTimeout ->
263 | waitResolver.resolve(true)
264 | , bucketWaitInterval
265 | waitResolver.promise
266 | # If we don't have enough tokens in this bucket, wait to get them
267 | if tokensToRemove > @tokensLeft
268 | return wait().then =>
269 | @removeTokens tokensToRemove
270 | else
271 | if @parentBucket
272 | # Remove the requested tokens from the parent bucket first
273 | parentLastFill = @parentBucket.lastFill
274 | return @parentBucket.removeTokens tokensToRemove
275 | .then =>
276 | # Add tokens after the wait for the parent
277 | addTokens.call(@)
278 | # Check that we still have enough tokens in this bucket, if not, reset removal from parent, wait for tokens, and start over
279 | if tokensToRemove > @tokensLeft
280 | @parentBucket.tokensLeft += tokensToRemove
281 | @parentBucket.lastFill = parentLastFill
282 | return wait().then =>
283 | @removeTokens tokensToRemove
284 | else
285 | # Tokens were removed from the parent bucket, now remove them from this bucket and return
286 | @tokensLeft -= tokensToRemove
287 | return Math.min @tokensLeft, @parentBucket.tokensLeft
288 | else
289 | # Remove the requested tokens from this bucket and resolve
290 | @tokensLeft -= tokensToRemove
291 | resolver.resolve(@tokensLeft)
292 | resolver.promise
293 |
294 | ###*
295 | * @desc Attempt to remove the requested number of tokens and return inmediately.
296 | * @param {Number} tokensToRemove - The number of tokens to remove.
297 | * @returns {Boolean} If it could remove the tokens inmediately it will return `true`, if not possible or needs to wait, it will return `false`.
298 | *
299 | * @example
300 | * ```javascript
301 | * if (tokenBucket.removeTokensSync(50)) {
302 | * // the tokens were removed
303 | * } else {
304 | * // the tokens were not removed
305 | * }
306 | * ```
307 | ###
308 | removeTokensSync: (tokensToRemove) =>
309 | tokensToRemove ||= 1
310 | # Add new tokens into this bucket if necessary
311 | addTokens.call(@)
312 | # Make sure the bucket can hold the requested number of tokens
313 | if tokensToRemove > @size then return false
314 | # If we don't have enough tokens in this bucket, return false
315 | if tokensToRemove > @tokensLeft then return false
316 | # Try to remove the requested tokens from the parent bucket
317 | if @parentBucket and !@parentBucket.removeTokensSync tokensToRemove then return false
318 | # Remove the requested tokens from this bucket and return
319 | @tokensLeft -= tokensToRemove
320 | true
321 |
322 | ###*
323 | * @desc Saves the bucket lastFill and tokensLeft to Redis. If it has any parents with `redis` options, they will get saved too.
324 | *
325 | * @returns {external:Promise}
326 | * @fulfil {true}
327 | * @reject {Error} - If we call this function and we didn't set the redis options, the error will have `'NoRedisOptions'` as the `name` property, so it can be handled specifically.
328 | * If there is an error with Redis it will be rejected with the error returned by Redis.
329 | * @example
330 | * We have a worker process that uses 1 API requests, so we would need to remove 1 token (default) from our rate limiter bucket.
331 | * If we had to wait more than the specified `maxWait` to get enough tokens, we would end the worker process.
332 | * We are saving the bucket state in Redis, so we first load from Redis, and before exiting we save the updated bucket state.
333 | * Note that if it had parent buckets with Redis options set, they would get saved too.
334 | * ```javascript
335 | * tokenBucket.loadSaved().then(function () {
336 | * // now the bucket has the state it had last time we saved it
337 | * return tokenBucket.removeTokens().then(function() {
338 | * // make API call
339 | * });
340 | * }).catch(function (err) {
341 | * if (err.name === 'ExceedsMaxWait') {
342 | * tokenBucket.save().then(function () {
343 | * process.kill(process.pid, 'SIGKILL');
344 | * }).catch(function (err) {
345 | * if (err.name == 'NoRedisOptions') {
346 | * // do something to handle this specific error
347 | * }
348 | * });
349 | * }
350 | * });
351 | * ```
352 | ###
353 | save: =>
354 | resolver = Promise.pending()
355 | if !@redis
356 | error = new Error errors.noRedisOptions
357 | Object.defineProperty error, 'name', {value: 'NoRedisOptions'}
358 | resolver.reject error
359 | else
360 | set = =>
361 | @redis.redisClient.mset 'tokenbucket:' + @redis.bucketName + ':lastFill', @lastFill, 'tokenbucket:' + @redis.bucketName + ':tokensLeft', @tokensLeft, (err, reply) ->
362 | if err
363 | resolver.reject new Error err
364 | else
365 | resolver.resolve(true)
366 | if @parentBucket and @parentBucket.redis?
367 | return @parentBucket.save().then set
368 | else
369 | set()
370 | resolver.promise
371 |
372 | ###*
373 | * @desc Loads the bucket lastFill and tokensLeft as it was saved in Redis. If it has any parents with `redis` options, they will get loaded too.
374 | * @returns {external:Promise}
375 | * @fulfil {true}
376 | * @reject {Error} - If we call this function and we didn't set the redis options, the error will have `'NoRedisOptions'` as the `name` property, so it can be handled specifically.
377 | * If there is an error with Redis it will be rejected with the error returned by Redis.
378 | * @example @lang off
379 | * See {@link module:tokenbucket#save}
380 | ###
381 | loadSaved: =>
382 | resolver = Promise.pending()
383 | if !@redis
384 | error = new Error errors.noRedisOptions
385 | Object.defineProperty error, 'name', {value: 'NoRedisOptions'}
386 | resolver.reject error
387 | else
388 | get = =>
389 | @redis.redisClient.mget 'tokenbucket:' + @redis.bucketName + ':lastFill', 'tokenbucket:' + @redis.bucketName + ':tokensLeft', (err, reply) =>
390 | if err
391 | resolver.reject new Error err
392 | else
393 | @lastFill = +reply[0] if reply[0]
394 | @tokensLeft = +reply[1] if reply[1]
395 | resolver.resolve(true)
396 | if @parentBucket and @parentBucket.redis?
397 | return @parentBucket.loadSaved().then get
398 | else
399 | get()
400 | resolver.promise
401 |
402 | module.exports = TokenBucket
403 |
404 | ###*
405 | * @external Promise
406 | * @see https://github.com/petkaantonov/bluebird
407 | ###
408 | ###*
409 | * @external redisClient
410 | * @see https://github.com/mranney/node_redis#rediscreateclient
411 | ###
412 | ###*
413 | * @external redisClientCofig
414 | * @see https://github.com/mranney/node_redis#rediscreateclient
415 | ###
416 |
--------------------------------------------------------------------------------
/lib/tokenbucket.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 | var Promise, TokenBucket, redis,
3 | bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; };
4 |
5 | Promise = require('bluebird');
6 |
7 | redis = require('redis');
8 |
9 |
10 | /**
11 | * @module tokenbucket
12 | * @desc A flexible rate limiter configurable with different variations of the [Token Bucket algorithm](http://en.wikipedia.org/wiki/Token_bucket), with hierarchy support, and optional persistence in Redis. Useful for limiting API requests, or other tasks that need to be throttled.
13 | * @author Jesús Carrera [@jesucarr](https://twitter.com/jesucarr) - [frontendmatters.com](http://frontendmatters.com)
14 | *
15 | * **Installation**
16 | * ```
17 | * npm install tokenbucket
18 | * ```
19 | *
20 | * @example
21 | * Require the library
22 | * ```javascript
23 | * var TokenBucket = require('tokenbucket');
24 | * ```
25 | * Create a new tokenbucket instance. See below for possible options.
26 | * ```javascript
27 | * var tokenBucket = new TokenBucket();
28 | * ```
29 | */
30 |
31 |
32 | /**
33 | * @class
34 | * @alias module:tokenbucket
35 | * @classdesc The class that the module exports and that instantiate a new token bucket with the given options.
36 | *
37 | * @param {Object} [options] - The options object
38 | * @param {Number} [options.size=1] - Maximum number of tokens to hold in the bucket. Also known as the burst size.
39 | * @param {Number} [options.tokensToAddPerInterval=1] - Number of tokens to add to the bucket in one interval.
40 | * @param {Number|String} [options.interval=1000] - The time passing between adding tokens, in milliseconds or as one of the following strings: 'second', 'minute', 'hour', day'.
41 | * @param {Number} [options.lastFill] - The timestamp of the last time when tokens where added to the bucket (last interval).
42 | * @param {Number} [options.tokensLeft=size] - By default it will initialize full of tokens, but you can set here the number of tokens you want to initialize it with.
43 | * @param {Boolean} [options.spread=false] - By default it will wait the interval, and then add all the tokensToAddPerInterval at once. If you set this to true, it will insert fractions of tokens at any given time, spreading the token addition along the interval.
44 | * @param {Number|String} [options.maxWait] - The maximum time that we would wait for enough tokens to be added, in milliseconds or as one of the following strings: 'second', 'minute', 'hour', day'. If any of the parents in the hierarchy has `maxWait`, we will use the smallest value.
45 | * @param {TokenBucket} [options.parentBucket] - A token bucket that will act as the parent of this bucket. Tokens removed in the children, will also be removed in the parent, and if the parent reach its limit, the children will get limited too.
46 | * @param {Object} [options.redis] - Options object for Redis
47 | * @param {String} options.redis.bucketName - The name of the bucket to reference it in Redis. This is the only required field to set Redis persistance. The `bucketName` for each bucket **must be unique**.
48 | * @param {external:redisClient} [options.redis.redisClient] - The [Redis client](https://github.com/mranney/node_redis#rediscreateclient) to save the bucket.
49 | * @param {Object} [options.redis.redisClientConfig] - [Redis client configuration](https://github.com/mranney/node_redis#rediscreateclient) to create the Redis client and save the bucket. If the `redisClient` option is set, this option will be ignored.
50 | * @param {Number} [options.redis.redisClientConfig.port=6379] - The connection port for the Redis client. See [configuration instructions](https://github.com/mranney/node_redis#rediscreateclient).
51 | * @param {String} [options.redis.redisClientConfig.host='127.0.0.1'] - The connection host for the Redis client. See [configuration instructions](https://github.com/mranney/node_redis#rediscreateclient)
52 | * @param {String} [options.redis.redisClientConfig.unixSocket] - The connection unix socket for the Redis client. See [configuration instructions](https://github.com/mranney/node_redis#rediscreateclient)
53 | * @param {String} [options.redis.redisClientConfig.options] - The options for the Redis client. See [configuration instructions](https://github.com/mranney/node_redis#rediscreateclient)
54 | *
55 | * This options will be properties of the class instances. The properties `tokensLeft` and `lastFill` will get updated when we add/remove tokens.
56 | *
57 | * @example
58 | *
59 | * A filled token bucket that can hold 100 tokens, and it will add 30 tokens every minute (all at once).
60 | * ```javascript
61 | * var tokenBucket = new TokenBucket({
62 | * size: 100,
63 | * tokensToAddPerInterval: 30,
64 | * interval: 'minute'
65 | * });
66 | * ```
67 | * An empty token bucket that can hold 1 token (default), and it will add 1 token (default) every 500ms, spreading the token addition along the interval (so after 250ms it will have 0.5 tokens).
68 | * ```javascript
69 | * var tokenBucket = new TokenBucket({
70 | * tokensLeft: 0,
71 | * interval: 500,
72 | * spread: true
73 | * });
74 | * ```
75 | * A token bucket limited to 15 requests every 15 minutes, with a parent bucket limited to 1000 requests every 24 hours. The maximum time that we are willing to wait for enough tokens to be added is one hour.
76 | * ```javascript
77 | * var parentTokenBucket = new TokenBucket({
78 | * size: 1000,
79 | * interval: 'day'
80 | * });
81 | * var tokenBucket = new TokenBucket({
82 | * size: 15,
83 | * tokensToAddPerInterval: 15,
84 | * interval: 'minute',
85 | * maxWait: 'hour',
86 | * parentBucket: parentBucket
87 | * });
88 | * ```
89 | * A token bucket limited to 15 requests every 15 minutes, with a parent bucket limited to 1000 requests every 24 hours. The maximum time that we are willing to wait for enough tokens to be added is 5 minutes.
90 | * ```javascript
91 | * var parentTokenBucket = new TokenBucket({
92 | * size: 1000,
93 | * interval: 'day'
94 | * maxWait: 1000 * 60 * 5,
95 | * });
96 | * var tokenBucket = new TokenBucket({
97 | * size: 15,
98 | * tokensToAddPerInterval: 15,
99 | * interval: 'minute',
100 | * parentBucket: parentBucket
101 | * });
102 | * ```
103 | * A token bucket with Redis persistance setting the redis client.
104 | * ```javascript
105 | * redis = require('redis');
106 | * redisClient = redis.redisClient();
107 | * var tokenBucket = new TokenBucket({
108 | * redis: {
109 | * bucketName: 'myBucket',
110 | * redisClient: redisClient
111 | * }
112 | * });
113 | * ```
114 | * A token bucket with Redis persistance setting the redis configuration.
115 | * ```javascript
116 | * var tokenBucket = new TokenBucket({
117 | * redis: {
118 | * bucketName: 'myBucket',
119 | * redisClientConfig: {
120 | * host: 'myhost',
121 | * port: 1000,
122 | * options: {
123 | * auth_pass: 'mypass'
124 | * }
125 | * }
126 | * }
127 | * });
128 | * ```
129 | * Note that setting both `redisClient` or `redisClientConfig`, the redis client will be exposed at `tokenBucket.redis.redisClient`.
130 | * This means you can watch for redis events, or execute redis client functions.
131 | * For example if we want to close the redis connection we can execute `tokenBucket.redis.redisClient.quit()`.
132 | */
133 |
134 | TokenBucket = (function() {
135 | var addTokens, errors;
136 |
137 | errors = {
138 | noRedisOptions: 'Redis options missing.',
139 | notEnoughSize: function(tokensToRemove, size) {
140 | return 'Requested tokens (' + tokensToRemove + ') exceed bucket size (' + size + ')';
141 | },
142 | noInfinityRemoval: 'Not possible to remove infinite tokens.',
143 | exceedsMaxWait: 'It will exceed maximum waiting time'
144 | };
145 |
146 | addTokens = function() {
147 | var now, timeSinceLastFill, tokensSinceLastFill;
148 | now = +new Date();
149 | timeSinceLastFill = Math.max(now - this.lastFill, 0);
150 | if (timeSinceLastFill) {
151 | tokensSinceLastFill = timeSinceLastFill * (this.tokensToAddPerInterval / this.interval);
152 | } else {
153 | tokensSinceLastFill = 0;
154 | }
155 | if (this.spread || (timeSinceLastFill >= this.interval)) {
156 | this.lastFill = now;
157 | return this.tokensLeft = Math.min(this.tokensLeft + tokensSinceLastFill, this.size);
158 | }
159 | };
160 |
161 | function TokenBucket(config) {
162 | this.loadSaved = bind(this.loadSaved, this);
163 | this.save = bind(this.save, this);
164 | this.removeTokensSync = bind(this.removeTokensSync, this);
165 | this.removeTokens = bind(this.removeTokens, this);
166 | var base, base1, base2, base3;
167 | if (config) {
168 | this.size = config.size, this.tokensToAddPerInterval = config.tokensToAddPerInterval, this.interval = config.interval, this.tokensLeft = config.tokensLeft, this.lastFill = config.lastFill, this.spread = config.spread, this.redis = config.redis, this.parentBucket = config.parentBucket, this.maxWait = config.maxWait;
169 | }
170 | if ((this.redis != null) && (this.redis.bucketName != null)) {
171 | if (this.redis.redisClient != null) {
172 | delete this.redis.redisClientConfig;
173 | } else {
174 | if ((base = this.redis).redisClientConfig == null) {
175 | base.redisClientConfig = {};
176 | }
177 | if (this.redis.redisClientConfig.unixSocket != null) {
178 | this.redis.redisClient = redis.createClient(this.redis.redisClientConfig.unixSocket, this.redis.redisClientConfig.options);
179 | } else {
180 | if ((base1 = this.redis.redisClientConfig).port == null) {
181 | base1.port = 6379;
182 | }
183 | if ((base2 = this.redis.redisClientConfig).host == null) {
184 | base2.host = '127.0.0.1';
185 | }
186 | if ((base3 = this.redis.redisClientConfig).options == null) {
187 | base3.options = {};
188 | }
189 | this.redis.redisClient = redis.createClient(this.redis.redisClientConfig.port, this.redis.redisClientConfig.host, this.redis.redisClientConfig.options);
190 | }
191 | }
192 | } else {
193 | delete this.redis;
194 | }
195 | if (this.size !== Number.POSITIVE_INFINITY) {
196 | if (this.size == null) {
197 | this.size = 1;
198 | }
199 | }
200 | if (this.tokensLeft == null) {
201 | this.tokensLeft = this.size;
202 | }
203 | if (this.tokensToAddPerInterval == null) {
204 | this.tokensToAddPerInterval = 1;
205 | }
206 | if (this.interval == null) {
207 | this.interval = 1000;
208 | } else if (typeof this.interval === 'string') {
209 | switch (this.interval) {
210 | case 'second':
211 | this.interval = 1000;
212 | break;
213 | case 'minute':
214 | this.interval = 1000 * 60;
215 | break;
216 | case 'hour':
217 | this.interval = 1000 * 60 * 60;
218 | break;
219 | case 'day':
220 | this.interval = 1000 * 60 * 60 * 24;
221 | }
222 | }
223 | if (typeof this.maxWait === 'string') {
224 | switch (this.maxWait) {
225 | case 'second':
226 | this.maxWait = 1000;
227 | break;
228 | case 'minute':
229 | this.maxWait = 1000 * 60;
230 | break;
231 | case 'hour':
232 | this.maxWait = 1000 * 60 * 60;
233 | break;
234 | case 'day':
235 | this.maxWait = 1000 * 60 * 60 * 24;
236 | }
237 | }
238 | if (this.lastFill == null) {
239 | this.lastFill = +new Date();
240 | }
241 | }
242 |
243 |
244 | /**
245 | * @desc Remove the requested number of tokens. If the bucket (and any parent buckets) contains enough tokens this will happen immediately. Otherwise, it will wait to get enough tokens.
246 | * @param {Number} tokensToRemove - The number of tokens to remove.
247 | * @returns {external:Promise}
248 | * @fulfil {Number} - The remaining tokens number, taking into account the parent if it has it.
249 | * @reject {Error} - Operational errors will be returned with the following `name` property, so they can be handled accordingly:
250 | * * `'NotEnoughSize'` - The requested tokens are greater than the bucket size.
251 | * * `'NoInfinityRemoval'` - It is not possible to remove infinite tokens, because even if the bucket has infinite size, the `tokensLeft` would be indeterminant.
252 | * * `'ExceedsMaxWait'` - The time we need to wait to be able to remove the tokens requested exceed the time set in `maxWait` configuration (parent or child).
253 | *
254 | * .
255 | * @example
256 | * We have some code that uses 3 API requests, so we would need to remove 3 tokens from our rate limiter bucket.
257 | * If we had to wait more than the specified `maxWait` to get enough tokens, we would handle that in certain way.
258 | * ```javascript
259 | * tokenBucket.removeTokens(3).then(function(remainingTokens) {
260 | * console.log('10 tokens removed, ' + remainingTokens + 'tokens left');
261 | * // make triple API call
262 | * }).catch(function (err) {
263 | * console.log(err)
264 | * if (err.name === 'ExceedsMaxWait') {
265 | * // do something to handle this specific error
266 | * }
267 | * });
268 | * ```
269 | */
270 |
271 | TokenBucket.prototype.removeTokens = function(tokensToRemove) {
272 | var bucketWaitInterval, calculateWaitInterval, error, hierarchyMaxWait, hierarchyWaitInterval, parentBucket, parentLastFill, resolver, wait;
273 | resolver = Promise.pending();
274 | tokensToRemove || (tokensToRemove = 1);
275 | if (tokensToRemove > this.size) {
276 | error = new Error(errors.notEnoughSize(tokensToRemove, this.size));
277 | Object.defineProperty(error, 'name', {
278 | value: 'NotEnoughSize'
279 | });
280 | resolver.reject(error);
281 | return resolver.promise;
282 | }
283 | if (tokensToRemove === Number.POSITIVE_INFINITY) {
284 | error = new Error(errors.noInfinityRemoval);
285 | Object.defineProperty(error, 'name', {
286 | value: 'NoInfinityRemoval'
287 | });
288 | resolver.reject(error);
289 | return resolver.promise;
290 | }
291 | addTokens.call(this);
292 | calculateWaitInterval = function(bucket) {
293 | var intervalsNeeded, timePerToken, timeSinceLastFill, tokensNeeded, waitInterval;
294 | tokensNeeded = tokensToRemove - bucket.tokensLeft;
295 | timeSinceLastFill = Math.max(+new Date() - bucket.lastFill, 0);
296 | if (bucket.spread) {
297 | timePerToken = bucket.interval / bucket.tokensToAddPerInterval;
298 | waitInterval = Math.ceil(tokensNeeded * timePerToken - timeSinceLastFill);
299 | } else {
300 | intervalsNeeded = tokensNeeded / bucket.tokensToAddPerInterval;
301 | waitInterval = Math.ceil(intervalsNeeded * bucket.interval - timeSinceLastFill);
302 | }
303 | return Math.max(waitInterval, 0);
304 | };
305 | bucketWaitInterval = calculateWaitInterval(this);
306 | hierarchyWaitInterval = bucketWaitInterval;
307 | if (this.maxWait != null) {
308 | hierarchyMaxWait = this.maxWait;
309 | }
310 | parentBucket = this.parentBucket;
311 | while (parentBucket != null) {
312 | hierarchyWaitInterval += calculateWaitInterval(parentBucket);
313 | if (parentBucket.maxWait != null) {
314 | if (hierarchyMaxWait != null) {
315 | hierarchyMaxWait = Math.min(parentBucket.maxWait, hierarchyMaxWait);
316 | } else {
317 | hierarchyMaxWait = parentBucket.maxWait;
318 | }
319 | }
320 | parentBucket = parentBucket.parentBucket;
321 | }
322 | if ((hierarchyMaxWait != null) && (hierarchyWaitInterval > hierarchyMaxWait)) {
323 | error = new Error(errors.exceedsMaxWait);
324 | Object.defineProperty(error, 'name', {
325 | value: 'ExceedsMaxWait'
326 | });
327 | resolver.reject(error);
328 | return resolver.promise;
329 | }
330 | wait = (function(_this) {
331 | return function() {
332 | var waitResolver;
333 | waitResolver = Promise.pending();
334 | setTimeout(function() {
335 | return waitResolver.resolve(true);
336 | }, bucketWaitInterval);
337 | return waitResolver.promise;
338 | };
339 | })(this);
340 | if (tokensToRemove > this.tokensLeft) {
341 | return wait().then((function(_this) {
342 | return function() {
343 | return _this.removeTokens(tokensToRemove);
344 | };
345 | })(this));
346 | } else {
347 | if (this.parentBucket) {
348 | parentLastFill = this.parentBucket.lastFill;
349 | return this.parentBucket.removeTokens(tokensToRemove).then((function(_this) {
350 | return function() {
351 | addTokens.call(_this);
352 | if (tokensToRemove > _this.tokensLeft) {
353 | _this.parentBucket.tokensLeft += tokensToRemove;
354 | _this.parentBucket.lastFill = parentLastFill;
355 | return wait().then(function() {
356 | return _this.removeTokens(tokensToRemove);
357 | });
358 | } else {
359 | _this.tokensLeft -= tokensToRemove;
360 | return Math.min(_this.tokensLeft, _this.parentBucket.tokensLeft);
361 | }
362 | };
363 | })(this));
364 | } else {
365 | this.tokensLeft -= tokensToRemove;
366 | resolver.resolve(this.tokensLeft);
367 | }
368 | }
369 | return resolver.promise;
370 | };
371 |
372 |
373 | /**
374 | * @desc Attempt to remove the requested number of tokens and return inmediately.
375 | * @param {Number} tokensToRemove - The number of tokens to remove.
376 | * @returns {Boolean} If it could remove the tokens inmediately it will return `true`, if not possible or needs to wait, it will return `false`.
377 | *
378 | * @example
379 | * ```javascript
380 | * if (tokenBucket.removeTokensSync(50)) {
381 | * // the tokens were removed
382 | * } else {
383 | * // the tokens were not removed
384 | * }
385 | * ```
386 | */
387 |
388 | TokenBucket.prototype.removeTokensSync = function(tokensToRemove) {
389 | tokensToRemove || (tokensToRemove = 1);
390 | addTokens.call(this);
391 | if (tokensToRemove > this.size) {
392 | return false;
393 | }
394 | if (tokensToRemove > this.tokensLeft) {
395 | return false;
396 | }
397 | if (this.parentBucket && !this.parentBucket.removeTokensSync(tokensToRemove)) {
398 | return false;
399 | }
400 | this.tokensLeft -= tokensToRemove;
401 | return true;
402 | };
403 |
404 |
405 | /**
406 | * @desc Saves the bucket lastFill and tokensLeft to Redis. If it has any parents with `redis` options, they will get saved too.
407 | *
408 | * @returns {external:Promise}
409 | * @fulfil {true}
410 | * @reject {Error} - If we call this function and we didn't set the redis options, the error will have `'NoRedisOptions'` as the `name` property, so it can be handled specifically.
411 | * If there is an error with Redis it will be rejected with the error returned by Redis.
412 | * @example
413 | * We have a worker process that uses 1 API requests, so we would need to remove 1 token (default) from our rate limiter bucket.
414 | * If we had to wait more than the specified `maxWait` to get enough tokens, we would end the worker process.
415 | * We are saving the bucket state in Redis, so we first load from Redis, and before exiting we save the updated bucket state.
416 | * Note that if it had parent buckets with Redis options set, they would get saved too.
417 | * ```javascript
418 | * tokenBucket.loadSaved().then(function () {
419 | * // now the bucket has the state it had last time we saved it
420 | * return tokenBucket.removeTokens().then(function() {
421 | * // make API call
422 | * });
423 | * }).catch(function (err) {
424 | * if (err.name === 'ExceedsMaxWait') {
425 | * tokenBucket.save().then(function () {
426 | * process.kill(process.pid, 'SIGKILL');
427 | * }).catch(function (err) {
428 | * if (err.name == 'NoRedisOptions') {
429 | * // do something to handle this specific error
430 | * }
431 | * });
432 | * }
433 | * });
434 | * ```
435 | */
436 |
437 | TokenBucket.prototype.save = function() {
438 | var error, resolver, set;
439 | resolver = Promise.pending();
440 | if (!this.redis) {
441 | error = new Error(errors.noRedisOptions);
442 | Object.defineProperty(error, 'name', {
443 | value: 'NoRedisOptions'
444 | });
445 | resolver.reject(error);
446 | } else {
447 | set = (function(_this) {
448 | return function() {
449 | return _this.redis.redisClient.mset('tokenbucket:' + _this.redis.bucketName + ':lastFill', _this.lastFill, 'tokenbucket:' + _this.redis.bucketName + ':tokensLeft', _this.tokensLeft, function(err, reply) {
450 | if (err) {
451 | return resolver.reject(new Error(err));
452 | } else {
453 | return resolver.resolve(true);
454 | }
455 | });
456 | };
457 | })(this);
458 | if (this.parentBucket && (this.parentBucket.redis != null)) {
459 | return this.parentBucket.save().then(set);
460 | } else {
461 | set();
462 | }
463 | }
464 | return resolver.promise;
465 | };
466 |
467 |
468 | /**
469 | * @desc Loads the bucket lastFill and tokensLeft as it was saved in Redis. If it has any parents with `redis` options, they will get loaded too.
470 | * @returns {external:Promise}
471 | * @fulfil {true}
472 | * @reject {Error} - If we call this function and we didn't set the redis options, the error will have `'NoRedisOptions'` as the `name` property, so it can be handled specifically.
473 | * If there is an error with Redis it will be rejected with the error returned by Redis.
474 | * @example @lang off
475 | * See {@link module:tokenbucket#save}
476 | */
477 |
478 | TokenBucket.prototype.loadSaved = function() {
479 | var error, get, resolver;
480 | resolver = Promise.pending();
481 | if (!this.redis) {
482 | error = new Error(errors.noRedisOptions);
483 | Object.defineProperty(error, 'name', {
484 | value: 'NoRedisOptions'
485 | });
486 | resolver.reject(error);
487 | } else {
488 | get = (function(_this) {
489 | return function() {
490 | return _this.redis.redisClient.mget('tokenbucket:' + _this.redis.bucketName + ':lastFill', 'tokenbucket:' + _this.redis.bucketName + ':tokensLeft', function(err, reply) {
491 | if (err) {
492 | return resolver.reject(new Error(err));
493 | } else {
494 | if (reply[0]) {
495 | _this.lastFill = +reply[0];
496 | }
497 | if (reply[1]) {
498 | _this.tokensLeft = +reply[1];
499 | }
500 | return resolver.resolve(true);
501 | }
502 | });
503 | };
504 | })(this);
505 | if (this.parentBucket && (this.parentBucket.redis != null)) {
506 | return this.parentBucket.loadSaved().then(get);
507 | } else {
508 | get();
509 | }
510 | }
511 | return resolver.promise;
512 | };
513 |
514 | return TokenBucket;
515 |
516 | })();
517 |
518 | module.exports = TokenBucket;
519 |
520 |
521 | /**
522 | * @external Promise
523 | * @see https://github.com/petkaantonov/bluebird
524 | */
525 |
526 |
527 | /**
528 | * @external redisClient
529 | * @see https://github.com/mranney/node_redis#rediscreateclient
530 | */
531 |
532 |
533 | /**
534 | * @external redisClientCofig
535 | * @see https://github.com/mranney/node_redis#rediscreateclient
536 | */
537 |
--------------------------------------------------------------------------------
/test/tokenbucket.spec.coffee:
--------------------------------------------------------------------------------
1 | 'use strict'
2 | chai = require 'chai'
3 | sinon = require 'sinon'
4 | chai.use require 'sinon-chai'
5 | chai.use require 'chai-as-promised'
6 | expect = chai.expect
7 |
8 | Promise = require 'bluebird'
9 |
10 | # Using the compiled JavaScript file here to be sure that the module works
11 | TokenBucket = require '../lib/tokenbucket'
12 |
13 | bucket = null
14 | clock = null
15 |
16 | # Helper function that checks that the removal happened inmediately or after the supposed time, and that it leaves the right amount of tokens
17 | checkRemoval = ({tokensRemove, time, tokensLeft, done, clock, parentTokensLeft, nextTickScheduler}) ->
18 | if nextTickScheduler
19 | Promise.setScheduler (fn) ->
20 | process.nextTick fn
21 | bucket.removeTokens(tokensRemove)
22 | .then (remainingTokens) ->
23 | expect(bucket.tokensLeft, 'bucket.tokensLeft').eql tokensLeft if tokensLeft?
24 | if parentTokensLeft
25 | expect(bucket.parentBucket.tokensLeft, 'bucket.parentBucket.tokensLeft').eql parentTokensLeft
26 | if bucket.parentBucket
27 | message = 'remaining with parent: ' + bucket.tokensLeft + ', ' + bucket.parentBucket.tokensLeft + ', ' + remainingTokens
28 | expect(Math.min(bucket.tokensLeft, bucket.parentBucket.tokensLeft) == remainingTokens, message).true
29 | else
30 | message = 'remaining ' + remainingTokens + ', ' + bucket.tokensLeft
31 | expect(bucket.tokensLeft == remainingTokens, message).true
32 | done()
33 | .catch (err) ->
34 | done(err)
35 | # Promises with enough tokens get resolved without any clock tick
36 | if time
37 | done = sinon.spy(done)
38 | clock.tick(time - 1)
39 | expect(done).not.called
40 | clock.tick(1)
41 |
42 | describe 'a default tokenbucket', ->
43 | beforeEach ->
44 | clock = sinon.useFakeTimers()
45 | bucket = new TokenBucket()
46 | afterEach ->
47 | clock.restore()
48 | it 'is initialized with the right values', ->
49 | expect(bucket.size).eql 1
50 | expect(bucket.tokensToAddPerInterval).eql 1
51 | expect(bucket.interval).eql 1000
52 | expect(bucket.tokensLeft).eql 1
53 | expect(bucket.lastFill).eql 0 # Fake timer without any tick
54 | expect(bucket.spread).undefined
55 | expect(bucket.redis).undefined
56 | expect(bucket.parentBucket).undefined
57 | describe 'when configuring the instance', ->
58 | parentBucket = new TokenBucket
59 | size: 10
60 | beforeEach (done) ->
61 | bucket.size = 5
62 | bucket.tokensToAddPerInterval = 2
63 | bucket.interval = 500
64 | bucket.tokensLeft = 3
65 | bucket.lastFill = +new Date() - 250 # Fake timer at 0ms minus 250ms
66 | bucket.spread = true
67 | bucket.redis =
68 | bucketName: 'bucket1'
69 | redisClient: 'fakeRedisClient'
70 | bucket.parentBucket = parentBucket
71 | done()
72 | it 'has the right values', ->
73 | expect(bucket.size).eql 5
74 | expect(bucket.tokensToAddPerInterval).eql 2
75 | expect(bucket.interval).eql 500
76 | expect(bucket.tokensLeft).eql 3
77 | expect(bucket.lastFill).eql -250
78 | expect(bucket.spread).true
79 | expect(bucket.redis).eql
80 | bucketName: 'bucket1'
81 | redisClient: 'fakeRedisClient'
82 | expect(bucket.parentBucket).eql parentBucket
83 | it 'works as expected with the configured instance', (done) ->
84 | expect(bucket.tokensLeft).eql 3
85 | checkRemoval
86 | tokensLeft: 3 # had 3 tokens left, plus 1 token (2 tokens per interval / half interval passed added evenly = 1 token), makes 4 tokens, minus 1 token removed = 3
87 | done: done
88 |
89 | describe 'removeTokens called without parameter', ->
90 | it 'removes 1 token instantly and leaves 0 tokens', (done) ->
91 | checkRemoval
92 | tokensLeft: 0
93 | done: done
94 | describe 'trying to remove more tokens than the bucket size', ->
95 | it 'rejects the promise with the right error', (done) ->
96 | bucket.removeTokens(2).catch (err) ->
97 | expect(err instanceof Error).true
98 | expect(err.name).eql 'NotEnoughSize'
99 | expect(err.message).eql 'Requested tokens (2) exceed bucket size (1)'
100 | done()
101 |
102 | describe 'a tokenbucket with redis options', ->
103 | it 'removes redis if bucketName is not set', ->
104 | bucket = new TokenBucket
105 | redis:
106 | redisClient: 'fakeRedisClient'
107 | redisClientConfig:
108 | port: 1000
109 | expect(bucket.redis).undefined
110 | it 'removes redisClientConfig if redisClient is set', ->
111 | bucket = new TokenBucket
112 | redis:
113 | bucketName: 'bucket1'
114 | redisClient: 'fakeRedisClient'
115 | redisClientConfig:
116 | port: 1000
117 | expect(bucket.redis.redisClientConfig).undefined
118 | it 'sets redisClientConfig defaults', ->
119 | bucket = new TokenBucket
120 | redis:
121 | bucketName: 'bucket1'
122 | expect(bucket.redis.redisClientConfig.port).eql 6379
123 | expect(bucket.redis.redisClientConfig.host).eql '127.0.0.1'
124 | expect(bucket.redis.redisClientConfig.unixSocket).undefined
125 | expect(bucket.redis.redisClientConfig.options).exists
126 | bucket.redis.redisClient.end()
127 | it 'sets redisClientConfig as defined', ->
128 | bucket = new TokenBucket
129 | redis:
130 | bucketName: 'bucket1'
131 | redisClientConfig:
132 | port: 6379
133 | host: 'localhost'
134 | options:
135 | max_attempts: 10
136 | expect(bucket.redis.redisClientConfig.port).eql 6379
137 | expect(bucket.redis.redisClientConfig.host).eql 'localhost'
138 | expect(bucket.redis.redisClientConfig.unixSocket).undefined
139 | expect(bucket.redis.redisClientConfig.options.max_attempts).eql 10
140 | bucket.redis.redisClient.end()
141 | it 'sets unixSocket if defined, and throws and error for the non existing socket', (done) ->
142 | bucket = new TokenBucket
143 | redis:
144 | bucketName: 'bucket1'
145 | redisClientConfig:
146 | unixSocket: '/tmp/fakeredis.sock'
147 | bucket.redis.redisClient.on 'error', (err) ->
148 | expect(err instanceof Error).true
149 | bucket.redis.redisClient.end()
150 | done()
151 |
152 | describe 'a tokenbucket initialized with interval string', ->
153 | describe 'when string is second', ->
154 | beforeEach ->
155 | bucket = new TokenBucket
156 | interval: 'second'
157 | it 'is initialized with the right interval', ->
158 | expect(bucket.interval).eql 1000
159 | describe 'when string is minute', ->
160 | beforeEach ->
161 | bucket = new TokenBucket
162 | interval: 'minute'
163 | it 'is initialized with the right interval', ->
164 | expect(bucket.interval).eql 1000 * 60
165 | describe 'when string is hour', ->
166 | beforeEach ->
167 | bucket = new TokenBucket
168 | interval: 'hour'
169 | it 'is initialized with the right interval', ->
170 | expect(bucket.interval).eql 1000 * 60 * 60
171 | describe 'when string is day', ->
172 | beforeEach ->
173 | bucket = new TokenBucket
174 | interval: 'day'
175 | it 'is initialized with the right interval', ->
176 | expect(bucket.interval).eql 1000 * 60 * 60 * 24
177 |
178 | describe 'a tokenbucket initialized with maxWait string', ->
179 | describe 'when string is second', ->
180 | beforeEach ->
181 | bucket = new TokenBucket
182 | maxWait: 'second'
183 | it 'is initialized with the right interval', ->
184 | expect(bucket.maxWait).eql 1000
185 | describe 'when string is minute', ->
186 | beforeEach ->
187 | bucket = new TokenBucket
188 | maxWait: 'minute'
189 | it 'is initialized with the right interval', ->
190 | expect(bucket.maxWait).eql 1000 * 60
191 | describe 'when string is hour', ->
192 | beforeEach ->
193 | bucket = new TokenBucket
194 | maxWait: 'hour'
195 | it 'is initialized with the right interval', ->
196 | expect(bucket.maxWait).eql 1000 * 60 * 60
197 | describe 'when string is day', ->
198 | beforeEach ->
199 | bucket = new TokenBucket
200 | maxWait: 'day'
201 | it 'is initialized with the right interval', ->
202 | expect(bucket.maxWait).eql 1000 * 60 * 60 * 24
203 |
204 | describe 'a tokenbucket with maxWait', ->
205 | beforeEach ->
206 | clock = sinon.useFakeTimers()
207 | bucket = new TokenBucket
208 | size: 10
209 | tokensLeft: 1
210 | maxWait: 2000
211 | afterEach ->
212 | clock.restore()
213 | it 'will remove tokens when maxWait is not exceeded', (done) ->
214 | checkRemoval
215 | done: done
216 | it 'will not remove tokens when maxWait is exceeded and reject with the right error', (done) ->
217 | bucket.removeTokens(10).catch (err) ->
218 | expect(err instanceof Error).true
219 | expect(err.name).eql 'ExceedsMaxWait'
220 | expect(err.message).eql 'It will exceed maximum waiting time'
221 | done()
222 |
223 | describe 'a tokenbucket with maxWait and parent', ->
224 | beforeEach ->
225 | clock = sinon.useFakeTimers()
226 | parentBucket = new TokenBucket
227 | size: 20
228 | tokensLeft: 1
229 | bucket = new TokenBucket
230 | size: 10
231 | maxWait: 2000
232 | parentBucket: parentBucket
233 | afterEach ->
234 | clock.restore()
235 | it 'will remove tokens when maxWait is not exceeded', (done) ->
236 | checkRemoval
237 | done: done
238 | it 'will not remove tokens when maxWait is exceeded because of the parent and reject with the right error', (done) ->
239 | bucket.removeTokens(10).catch (err) ->
240 | expect(err instanceof Error).true
241 | expect(err.name).eql 'ExceedsMaxWait'
242 | expect(err.message).eql 'It will exceed maximum waiting time'
243 | done()
244 |
245 | describe 'a tokenbucket with maxWait and parent with smaller maxWait', ->
246 | beforeEach ->
247 | clock = sinon.useFakeTimers()
248 | parentBucket = new TokenBucket
249 | size: 20
250 | tokensLeft: 1
251 | maxWait: 2000
252 | bucket = new TokenBucket
253 | size: 10
254 | maxWait: 100000
255 | parentBucket: parentBucket
256 | afterEach ->
257 | clock.restore()
258 | it 'will remove tokens when maxWait is not exceeded', (done) ->
259 | checkRemoval
260 | done: done
261 | it 'will not remove tokens when maxWait is exceeded because of the parent and reject with the right error', (done) ->
262 | bucket.removeTokens(10).catch (err) ->
263 | expect(err instanceof Error).true
264 | expect(err.name).eql 'ExceedsMaxWait'
265 | expect(err.message).eql 'It will exceed maximum waiting time'
266 | done()
267 |
268 | describe 'a tokenbucket with maxWait and parent with bigger maxWait', ->
269 | beforeEach ->
270 | clock = sinon.useFakeTimers()
271 | parentBucket = new TokenBucket
272 | size: 20
273 | tokensLeft: 1
274 | maxWait: 100000
275 | bucket = new TokenBucket
276 | size: 10
277 | maxWait: 2000
278 | parentBucket: parentBucket
279 | afterEach ->
280 | clock.restore()
281 | it 'will remove tokens when maxWait is not exceeded', (done) ->
282 | checkRemoval
283 | done: done
284 | it 'will not remove tokens when maxWait is exceeded because of the child and reject with the right error', (done) ->
285 | bucket.removeTokens(10).catch (err) ->
286 | expect(err instanceof Error).true
287 | expect(err.name).eql 'ExceedsMaxWait'
288 | expect(err.message).eql 'It will exceed maximum waiting time'
289 | done()
290 |
291 | describe 'a tokenbucket with a parent with maxWait', ->
292 | beforeEach ->
293 | clock = sinon.useFakeTimers()
294 | parentBucket = new TokenBucket
295 | size: 20
296 | maxWait: 2000
297 | bucket = new TokenBucket
298 | size: 10
299 | tokensLeft: 1
300 | parentBucket: parentBucket
301 | afterEach ->
302 | clock.restore()
303 | it 'will remove tokens when the parent maxWait is not exceeded', (done) ->
304 | checkRemoval
305 | done: done
306 | it 'will not remove tokens when the parent maxWait is exceeded and reject with the right error', (done) ->
307 | bucket.removeTokens(10).catch (err) ->
308 | expect(err instanceof Error).true
309 | expect(err.name).eql 'ExceedsMaxWait'
310 | expect(err.message).eql 'It will exceed maximum waiting time'
311 | done()
312 |
313 | describe 'a tokenbucket with a grandparent with maxWait', ->
314 | beforeEach ->
315 | clock = sinon.useFakeTimers()
316 | grandParentBucket = new TokenBucket
317 | size: 50
318 | maxWait: 2000
319 | parentBucket = new TokenBucket
320 | size: 20
321 | parentBucket: grandParentBucket
322 | bucket = new TokenBucket
323 | size: 10
324 | tokensLeft: 1
325 | maxWait: 100000
326 | parentBucket: parentBucket
327 | afterEach ->
328 | clock.restore()
329 | it 'will remove tokens when the parent maxWait is not exceeded', (done) ->
330 | checkRemoval
331 | done: done
332 | it 'will not remove tokens when the grandparent maxWait is exceeded and reject with the right error', (done) ->
333 | bucket.removeTokens(10).catch (err) ->
334 | expect(err instanceof Error).true
335 | expect(err.name).eql 'ExceedsMaxWait'
336 | expect(err.message).eql 'It will exceed maximum waiting time'
337 | done()
338 |
339 | describe 'an empty tokenbucket size 2 filled evenly and last filled 1s ago when requesting tokens sync', ->
340 | beforeEach ->
341 | clock = sinon.useFakeTimers()
342 | bucket = new TokenBucket
343 | size: 2
344 | spread: true
345 | tokensLeft: 0
346 | lastFill: +new Date() - 1000
347 | afterEach ->
348 | clock.restore()
349 | describe 'when requesting 2 tokens sync', ->
350 | it 'doesn\'t remove them but add 1 token', ->
351 | result = bucket.removeTokensSync 2
352 | expect(result).to.be.false
353 | expect(bucket.tokensLeft).eql 1
354 | describe 'when requesting 1 token sync', ->
355 | it 'removes it and has 0 tokens', ->
356 | result = bucket.removeTokensSync 1
357 | expect(result).to.be.true
358 | expect(bucket.tokensLeft).eql 0
359 | describe 'removeTokensSync called without parameter', ->
360 | it 'removes 1 token and has 0 tokens', ->
361 | result = bucket.removeTokensSync()
362 | expect(result).to.be.true
363 | expect(bucket.tokensLeft).eql 0
364 | describe 'when it has a parent without enough tokens', ->
365 | it 'doesn\'t remove tokens', ->
366 | parentBucket = new TokenBucket
367 | tokensLeft: 0
368 | bucket.parentBucket = parentBucket
369 | result = bucket.removeTokensSync()
370 | expect(result).to.be.false
371 | expect(bucket.tokensLeft).eql 1
372 | describe 'when trying to remove more tokens that its size', ->
373 | it 'doesn\'t remove tokens but adds 1 token', ->
374 | result = bucket.removeTokensSync(3)
375 | expect(result).to.be.false
376 | expect(bucket.tokensLeft).eql 1
377 | describe 'when waiting 500ms and removing 1 token', ->
378 | it 'removes the token and leaves 0.5 tokens', ->
379 | clock.tick 500
380 | result = bucket.removeTokensSync()
381 | expect(result).to.be.true
382 | expect(bucket.tokensLeft).eql 0.5
383 |
384 | describe 'a tokenbucket with parent bucket', ->
385 | parentBucket = null
386 | before ->
387 | clock = sinon.useFakeTimers()
388 | parentBucket = new TokenBucket()
389 | bucket = new TokenBucket
390 | size: 2
391 | parentBucket: parentBucket
392 | after ->
393 | clock.restore()
394 | describe 'when removing a token', ->
395 | it 'removes it from the bucket and leaves 1 token', (done) ->
396 | expect(bucket.tokensLeft).eql 2
397 | expect(bucket.parentBucket.tokensLeft).eql 1
398 | checkRemoval
399 | tokensLeft: 1
400 | done: done
401 | it 'removes it from the parent bucket and leaves 0 tokens', ->
402 | expect(parentBucket.tokensLeft).eql 0
403 | describe 'when removing a token and there are no tokens in the parent', ->
404 | it 'waits for the parent to have enough tokens and then removes it from bucket and parent bucket', (done) ->
405 | done = sinon.spy(done)
406 | expect(bucket.tokensLeft).eql 1
407 | expect(bucket.parentBucket.tokensLeft).eql 0
408 | checkRemoval
409 | tokensLeft: 1 # same interval as the parent, after the wait got 1 more token (2 left), after removal there is 1 left
410 | parentTokensLeft: 0 # 1 token after interval, 0 tokens after removal
411 | time: 1000
412 | clock: clock
413 | done: done
414 | nextTickScheduler: true
415 |
416 | describe 'when after waiting for the parent doesn\t have enough tokens any more', ->
417 | it 'waits to get enough tokens', (done) ->
418 | done = sinon.spy(done)
419 | bucket.interval = 1500 # greater interval than the parent, so we check that it waits longer than just the parent interval
420 | bucket.removeTokens().then ->
421 | expect(bucket.tokensLeft).eql 0
422 | expect(bucket.parentBucket.tokensLeft).eql 0
423 | done()
424 | clock.tick 500 # some time passed whilst waiting for parent
425 | expect(bucket.tokensLeft).eql 1 # still one token left
426 | expect(bucket.parentBucket.tokensLeft).eql 0 # parent still empty
427 | # empty bucket whilst waiting for parent
428 | bucket.tokensLeft = 0
429 | expect(bucket.tokensLeft).eql 0
430 | expect(bucket.parentBucket.tokensLeft).eql 0
431 | # the parent gets 1 token (500 + 500 = parent interval)
432 | clock.tick 500
433 | # We need nextTick so the previous clock tick gets executed, and that part of the code gets covered
434 | process.nextTick ->
435 | expect(bucket.tokensLeft).eql 0
436 | expect(bucket.parentBucket.tokensLeft).eql 1
437 | clock.tick 499
438 | expect(done).not.called
439 | clock.tick 1 # 1500 total = 1000 parent + 500 itself
440 |
441 | describe 'a filled infinite tokenbucket', ->
442 | beforeEach ->
443 | clock = sinon.useFakeTimers()
444 | bucket = new TokenBucket
445 | size: Number.POSITIVE_INFINITY
446 | afterEach ->
447 | clock.restore()
448 | it 'removes tokens inmediately and still has infinite tokens', (done) ->
449 | checkRemoval
450 | tokensRemove: 9999
451 | tokensLeft: Number.POSITIVE_INFINITY
452 | done: done
453 | it 'can\'t remove infinite tokens and rejects with the right error', (done) ->
454 | bucket.removeTokens(Number.POSITIVE_INFINITY).catch (err) ->
455 | expect(err instanceof Error).true
456 | expect(err.name).eql 'NoInfinityRemoval'
457 | expect(err.message).eql 'Not possible to remove infinite tokens.'
458 | done()
459 |
460 | describe 'an empty infinite tokenbucket filled evenly with infinite tokens', ->
461 | before ->
462 | clock = sinon.useFakeTimers()
463 | bucket = new TokenBucket
464 | size: Number.POSITIVE_INFINITY
465 | tokensToAddPerInterval: Number.POSITIVE_INFINITY
466 | tokensLeft: 0
467 | spread: true
468 | after ->
469 | clock.restore()
470 | it 'removes tokens after at least 1ms and then gets infinite tokens', (done) ->
471 | checkRemoval
472 | tokensRemove: 9999
473 | tokensLeft: Number.POSITIVE_INFINITY
474 | time: 1
475 | clock: clock
476 | done: done
477 |
478 | describe 'an empty infinite tokenbucket with 100ms interval', ->
479 | beforeEach ->
480 | clock = sinon.useFakeTimers()
481 | bucket = new TokenBucket
482 | size: Number.POSITIVE_INFINITY
483 | tokensLeft: 0
484 | interval: 100
485 | afterEach ->
486 | clock.restore()
487 | describe 'when removing 1 token', ->
488 | it 'takes 100ms and leaves 0 tokens', (done) ->
489 | checkRemoval
490 | tokensLeft: 0
491 | time: 100
492 | clock: clock
493 | done: done
494 |
495 |
496 | describe 'a tokenbucket with size 10 adding 1 token per 100ms', ->
497 | describe 'when removing 1 token', ->
498 | before ->
499 | clock = sinon.useFakeTimers()
500 | bucket = new TokenBucket
501 | size: 10
502 | interval: 100
503 | after ->
504 | clock.restore()
505 | it 'takes the tokens inmediately and leaves 9 tokens', (done) ->
506 | checkRemoval
507 | tokensLeft: 9
508 | done: done
509 | describe 'when removing 10 tokens', ->
510 | before ->
511 | clock = sinon.useFakeTimers()
512 | bucket = new TokenBucket
513 | size: 10
514 | interval: 100
515 | after ->
516 | clock.restore()
517 | it 'takes the tokens inmediately and leaves 0 tokens', (done) ->
518 | checkRemoval
519 | tokensRemove: 10
520 | tokensLeft: 0
521 | done: done
522 | describe 'when removing another 10 tokens', ->
523 | it 'takes 1 second and leaves 0 tokens again', (done) ->
524 | checkRemoval
525 | tokensRemove: 10
526 | tokensLeft: 0
527 | time: 1000
528 | clock: clock
529 | done: done
530 | describe 'when waiting 2 seconds and removing 10 tokens', ->
531 | it 'removes the tokens inmediately and leaves the bucket empty', (done) ->
532 | clock.tick 2000
533 | checkRemoval
534 | tokensRemove: 10
535 | tokensLeft: 0
536 | done: done
537 |
538 | describe 'a tokenbucket starting empty with size 10 adding 1 token per 100ms', ->
539 | describe 'when removing 1 token', ->
540 | before ->
541 | clock = sinon.useFakeTimers()
542 | bucket = new TokenBucket
543 | size: 10
544 | tokensToAddPerInterval: 1
545 | interval: 100
546 | tokensLeft: 0
547 | after ->
548 | clock.restore()
549 | it 'takes 100ms and leaves 0 tokens', (done) ->
550 | checkRemoval
551 | tokensLeft: 0
552 | time: 100
553 | clock: clock
554 | done: done
555 | describe 'when removing 10 tokens', ->
556 | before ->
557 | clock = sinon.useFakeTimers()
558 | bucket = new TokenBucket
559 | size: 10
560 | tokensToAddPerInterval: 1
561 | interval: 100
562 | tokensLeft: 0
563 | after ->
564 | clock.restore()
565 | it 'takes 1 second and leaves 0 tokens', (done) ->
566 | checkRemoval
567 | tokensRemove: 10
568 | tokensLeft: 0
569 | time: 1000
570 | clock: clock
571 | done: done
572 | describe 'when removing another 10 tokens', ->
573 | it 'takes 1 second and leaves 0 tokens again', (done) ->
574 | checkRemoval
575 | tokensRemove: 10
576 | tokensLeft: 0
577 | time: 1000
578 | clock: clock
579 | done: done
580 | describe 'when waiting 2 seconds and removing 10 tokens', ->
581 | it 'removes the tokens inmediately and leaves the bucket empty', (done) ->
582 | clock.tick 2000
583 | checkRemoval
584 | tokensRemove: 10
585 | tokensLeft: 0
586 | done: done
587 |
588 |
589 | describe 'a tokenbucket with size 10 adding 5 token per 1 second', ->
590 | before ->
591 | clock = sinon.useFakeTimers()
592 | bucket = new TokenBucket
593 | size: 10
594 | tokensToAddPerInterval: 5
595 | interval: 1000
596 | after ->
597 | clock.restore()
598 | describe 'when removing 10 tokens', ->
599 | it 'takes them inmediately and leaves 0 tokens', (done) ->
600 | checkRemoval
601 | tokensRemove: 10
602 | tokensLeft: 0
603 | done: done
604 | describe 'when removing another 10 tokens', ->
605 | it 'takes 2s and is empty again', (done) ->
606 | checkRemoval
607 | tokensRemove: 10
608 | tokensLeft: 0
609 | time: 2000
610 | clock: clock
611 | done: done
612 | describe 'when removing another 1 token', ->
613 | it 'takes 1s and leave 4 tokens left', (done) ->
614 | checkRemoval
615 | tokensLeft: 4
616 | time: 1000
617 | clock: clock
618 | done: done
619 |
620 | describe 'a tokenbucket with size 10 adding evenly 5 tokens per 1 second', ->
621 | before ->
622 | clock = sinon.useFakeTimers()
623 | bucket = new TokenBucket
624 | size: 10
625 | tokensToAddPerInterval: 5
626 | interval: 1000
627 | spread: true
628 | after ->
629 | clock.restore()
630 | describe 'when removing 10 tokens', ->
631 | it 'takes them inmediately and leaves 0 tokens', (done) ->
632 | checkRemoval
633 | tokensRemove: 10
634 | tokensLeft: 0
635 | done: done
636 | describe 'when removing another 10 tokens', ->
637 | it 'takes 2s and is empty again', (done) ->
638 | checkRemoval
639 | tokensRemove: 10
640 | tokensLeft: 0
641 | time: 2000
642 | clock: clock
643 | done: done
644 | describe 'when removing another 1 token', ->
645 | it 'takes 200ms and leaves no tokens', (done) ->
646 | checkRemoval
647 | tokensLeft: 0
648 | time: 200
649 | clock: clock
650 | done: done
651 |
652 | describe 'a tokenbucket with size 10 and 5 tokens left adding 1 token per 100ms', ->
653 | beforeEach ->
654 | clock = sinon.useFakeTimers()
655 | bucket = new TokenBucket
656 | size: 10
657 | tokensToAddPerInterval: 1
658 | interval: 100
659 | tokensLeft: 5
660 | after ->
661 | clock.restore()
662 | describe 'when removing 10 token', ->
663 | it 'takes 500ms and leaves 0 tokens', (done) ->
664 | checkRemoval
665 | tokensRemove: 10
666 | tokensLeft: 0
667 | time: 500
668 | clock: clock
669 | done: done
670 |
671 |
672 | describe 'saving a tokenbucket', ->
673 | stub = null
674 | stubParent = null
675 | parentBucket = null
676 | describe 'when initialized without bucket name', ->
677 | it 'rejects the promise with the right error', (done) ->
678 | redisClient = mset: ->
679 | bucket = new TokenBucket
680 | redis:
681 | redisClient: redisClient
682 | bucket.save().catch (err) ->
683 | expect(err instanceof Error).true
684 | expect(err.name).eql 'NoRedisOptions'
685 | expect(err.message).eql 'Redis options missing.'
686 | done()
687 | describe 'when initialized with the right options', ->
688 | beforeEach ->
689 | redisClient = mset: ->
690 | bucket = new TokenBucket
691 | redis:
692 | bucketName: 'test'
693 | redisClient: redisClient
694 | stub = sinon.stub(bucket.redis.redisClient, 'mset')
695 | it 'redis command is called with the right parameters and resolves the promise', ->
696 | stub.callsArgWith(4, null, 'OK')
697 | promise = bucket.save()
698 | expect(bucket.redis.redisClient.mset).to.have.been.calledWith 'tokenbucket:test:lastFill', bucket.lastFill, 'tokenbucket:test:tokensLeft', bucket.tokensLeft
699 | expect(promise).to.be.resolved
700 | it 'when callback has error rejects the promise with the error', ->
701 | stub.callsArgWith(4, new Error('db err'))
702 | promise = bucket.save()
703 | expect(promise).to.be.rejectedWith Error, 'db err'
704 | describe 'when has parent bucket with redis options', ->
705 | beforeEach ->
706 | parentRedisClient = mset: ->
707 | parentBucket = new TokenBucket
708 | redis:
709 | bucketName: 'testParent'
710 | redisClient: parentRedisClient
711 | redisClient = mset: ->
712 | bucket = new TokenBucket
713 | redis:
714 | bucketName: 'test'
715 | redisClient: redisClient
716 | parentBucket: parentBucket
717 | stubParent = sinon.stub(parentBucket.redis.redisClient, 'mset').yields(Promise.pending().resolve())
718 | stub = sinon.stub(bucket.redis.redisClient, 'mset')
719 | it 'parent bucket gets called with the right parameters, then its save() promise resolves, and then redis command is called in the child bucket with the right parameters and resolves the promise', (done) ->
720 | stub.callsArgWith(4, null, 'OK')
721 | stubParent.callsArgWith(4, null, 'OK')
722 | bucket.save().then ->
723 | expect(parentBucket.redis.redisClient.mset).to.have.been.calledWith 'tokenbucket:testParent:lastFill', parentBucket.lastFill, 'tokenbucket:testParent:tokensLeft', parentBucket.tokensLeft
724 | expect(bucket.redis.redisClient.mset).to.have.been.calledWith 'tokenbucket:test:lastFill', bucket.lastFill, 'tokenbucket:test:tokensLeft', bucket.tokensLeft
725 | done()
726 | it 'when parent callback has error rejects the promise with the error', ->
727 | stubParent.callsArgWith(4, new Error('db err parent'))
728 | expect(bucket.save()).to.be.rejectedWith Error, 'db err parent'
729 |
730 | describe 'load a saved tokenbucket', ->
731 | stub = null
732 | stubParent = null
733 | parentBucket = null
734 | lastFill = +new Date()
735 | lastFillSaved = +new Date() - 5000
736 | tokensLeftSaved = 3
737 | describe 'when initialized without bucket name', ->
738 | it 'rejects the promise with the right error', (done) ->
739 | redisClient = mget: ->
740 | bucket = new TokenBucket
741 | redis:
742 | redisClient: redisClient
743 | bucket.loadSaved().catch (err) ->
744 | expect(err instanceof Error).true
745 | expect(err.name).eql 'NoRedisOptions'
746 | expect(err.message).eql 'Redis options missing.'
747 | done()
748 | describe 'when initialized with the right options', ->
749 | beforeEach ->
750 | redisClient = mget: ->
751 | bucket = new TokenBucket
752 | redis:
753 | bucketName: 'test'
754 | redisClient: redisClient
755 | bucket.lastFill = lastFill
756 | stub = sinon.stub(bucket.redis.redisClient, 'mget')
757 | it 'calls the redis command with the right parameters and loads the bucket with the returned data', ->
758 | stub.callsArgWith 2, null, [lastFillSaved, tokensLeftSaved]
759 | promise = bucket.loadSaved()
760 | expect(bucket.redis.redisClient.mget).to.have.been.calledWith 'tokenbucket:test:lastFill', 'tokenbucket:test:tokensLeft'
761 | expect(promise).to.be.resolved
762 | expect(bucket.lastFill).to.eql lastFillSaved
763 | expect(bucket.tokensLeft).to.eql tokensLeftSaved
764 | it 'leaves the original data if no data is returned', ->
765 | stub.callsArgWith 2, null, [null, null]
766 | promise = bucket.loadSaved()
767 | expect(promise).to.be.resolved
768 | expect(bucket.lastFill).to.eql lastFill
769 | expect(bucket.tokensLeft).to.eql 1
770 | it 'when callback has error rejects the promise with the error', ->
771 | stub.callsArgWith(2, new Error('db err'))
772 | promise = bucket.loadSaved()
773 | expect(promise).to.be.rejectedWith Error, 'db err'
774 | describe 'when has parent bucket with redis options', ->
775 | beforeEach ->
776 | parentRedisClient = mget: ->
777 | parentBucket = new TokenBucket
778 | redis:
779 | bucketName: 'testParent'
780 | redisClient: parentRedisClient
781 | redisClient = mget: ->
782 | bucket = new TokenBucket
783 | redis:
784 | bucketName: 'test'
785 | redisClient: redisClient
786 | parentBucket: parentBucket
787 | stubParent = sinon.stub(parentBucket.redis.redisClient, 'mget').yields(Promise.pending().resolve())
788 | stub = sinon.stub(bucket.redis.redisClient, 'mget')
789 | it 'parent bucket gets called with the right parameters, then its loadSaved() promise resolves, and then redis command is called in the child bucket with the right parameters and resolves the promise', (done) ->
790 | stub.callsArgWith(2, null, [lastFillSaved, tokensLeftSaved])
791 | stubParent.callsArgWith(2, null, [lastFillSaved, tokensLeftSaved])
792 | bucket.loadSaved().then ->
793 | expect(parentBucket.redis.redisClient.mget).to.have.been.calledWith 'tokenbucket:testParent:lastFill', 'tokenbucket:testParent:tokensLeft'
794 | expect(bucket.redis.redisClient.mget).to.have.been.calledWith 'tokenbucket:test:lastFill', 'tokenbucket:test:tokensLeft'
795 | done()
796 | it 'when parent callback has error rejects the promise with the error', ->
797 | stubParent.callsArgWith(2, new Error('db err parent'))
798 | expect(bucket.loadSaved()).to.be.rejectedWith Error, 'db err parent'
799 |
--------------------------------------------------------------------------------