├── .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 | [![Dependency status](https://img.shields.io/david/jesucarr/tokenbucket.svg?style=flat)](https://david-dm.org/jesucarr/tokenbucket) 2 | [![devDependency Status](https://img.shields.io/david/dev/jesucarr/tokenbucket.svg?style=flat)](https://david-dm.org/jesucarr/tokenbucket#info=devDependencies) 3 | [![Build Status](https://img.shields.io/travis/jesucarr/tokenbucket.svg?style=flat&branch=master)](https://travis-ci.org/jesucarr/tokenbucket) 4 | [![Test Coverage](https://img.shields.io/coveralls/jesucarr/tokenbucket.svg?style=flat&branch=master)](https://coveralls.io/r/jesucarr/tokenbucket) 5 | [![NPM](https://nodei.co/npm/tokenbucket.svg?style=flat)](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 | [![Dependency status](https://img.shields.io/david/jesucarr/tokenbucket.svg?style=flat)](https://david-dm.org/jesucarr/tokenbucket) 2 | [![devDependency Status](https://img.shields.io/david/dev/jesucarr/tokenbucket.svg?style=flat)](https://david-dm.org/jesucarr/tokenbucket#info=devDependencies) 3 | [![Build Status](https://img.shields.io/travis/jesucarr/tokenbucket.svg?style=flat&branch=master)](https://travis-ci.org/jesucarr/tokenbucket) 4 | [![Test Coverage](https://img.shields.io/coveralls/jesucarr/tokenbucket.svg?style=flat&branch=master)](https://coveralls.io/r/jesucarr/tokenbucket) 5 | [![NPM](https://nodei.co/npm/tokenbucket.svg?style=flat)](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 | --------------------------------------------------------------------------------