├── .gitignore ├── test ├── _.js ├── EventEmitter.js └── LeakyBucket.js ├── .jshintrc ├── package.json ├── LICENSE ├── src ├── EventEmitter.js └── LeakyBucket.js └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | npm-debug.log 2 | node_modules 3 | -------------------------------------------------------------------------------- /test/_.js: -------------------------------------------------------------------------------- 1 | import section, { SpecReporter } from 'section-tests'; 2 | import logd from 'logd'; 3 | import ConsoleTransport from 'logd-console-transport'; 4 | 5 | section.use(new SpecReporter()); 6 | 7 | logd.transport(new ConsoleTransport()); -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "es5" : true 3 | , "freeze" : true 4 | , "latedef" : true 5 | , "noarg" : true 6 | , "notypeof" : true 7 | , "undef" : true 8 | , "unused" : true 9 | , "esnext" : true 10 | , "laxcomma" : true 11 | , "node" : true 12 | } 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "leaky-bucket", 3 | "description": "A fast and efficient leaky bucket implementation", 4 | "version": "4.1.4", 5 | "homepage": "https://github.com/linaGirl/leaky-bucket", 6 | "author": "Lina van der Weg (http://vanderweg.ch/)", 7 | "license": "MIT", 8 | "repository": { 9 | "url": "https://github.com/linaGirl/leaky-bucket.git", 10 | "type": "git" 11 | }, 12 | "engines": { 13 | "node": ">=v12" 14 | }, 15 | "bugs": { 16 | "url": "https://github.com/linaGirl/leaky-bucket/issues" 17 | }, 18 | "dependencies": {}, 19 | "devDependencies": { 20 | "section-tests": "^3.2.0" 21 | }, 22 | "keywords": [ 23 | "leaky-bucket", 24 | "leaky", 25 | "bucket" 26 | ], 27 | "scripts": { 28 | "test": "node --experimental-modules --no-warnings ./node_modules/.bin/section ./test/*.js --ld" 29 | }, 30 | "main": "./src/LeakyBucket.js", 31 | "type": "module" 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2021 Lina van der Weg 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software 4 | and associated documentation files (the "Software"), to deal in the Software without restriction, 5 | including without limitation the rights to use, copy, modify, merge, publish, distribute, 6 | sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is 7 | furnished to do so, subject to the following conditions: 8 | 9 | The above copyright notice and this permission notice shall be included in all copies or 10 | substantial portions of the Software. 11 | 12 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT 13 | NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 14 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, 15 | DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 16 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /test/EventEmitter.js: -------------------------------------------------------------------------------- 1 | import section from 'section-tests'; 2 | import EventEmitter from '../src/EventEmitter.js'; 3 | import assert from 'assert'; 4 | 5 | 6 | section('EventEmitter', async(section) => { 7 | section.test('Instantiate class', async() => { 8 | new EventEmitter(); 9 | }); 10 | 11 | section.test('EventEmitter.on()', async() => { 12 | const emitter = new EventEmitter(); 13 | let handled = 0; 14 | 15 | emitter.on('test', () => { 16 | handled++; 17 | }); 18 | 19 | emitter.emit('test'); 20 | 21 | assert.equal(handled, 1); 22 | }); 23 | 24 | section.test('EventEmitter.once()', async() => { 25 | const emitter = new EventEmitter(); 26 | let handled = 0; 27 | 28 | emitter.once('test', () => { 29 | handled++; 30 | }); 31 | 32 | emitter.emit('test'); 33 | emitter.emit('test'); 34 | 35 | assert.equal(handled, 1); 36 | }); 37 | 38 | section.test('EventEmitter.off()', async() => { 39 | const emitter = new EventEmitter(); 40 | let handled = 0; 41 | 42 | emitter.once('test', () => { 43 | handled++; 44 | }); 45 | 46 | emitter.off('test'); 47 | 48 | emitter.emit('test'); 49 | 50 | assert.equal(handled, 0); 51 | }); 52 | 53 | section.test('EventEmitter.off(listener)', async() => { 54 | const emitter = new EventEmitter(); 55 | let handled = 0; 56 | const handler = () => { 57 | handled++; 58 | }; 59 | 60 | emitter.once('test', handler); 61 | 62 | emitter.off('test', handler); 63 | 64 | emitter.emit('test'); 65 | 66 | assert.equal(handled, 0); 67 | }); 68 | 69 | section.test('EventEmitter.hasListener(event)', async() => { 70 | const emitter = new EventEmitter(); 71 | const handler = () => { 72 | handled++; 73 | }; 74 | 75 | emitter.once('test', handler); 76 | assert.equal(emitter.hasListener('test'), 1); 77 | 78 | emitter.off('test', handler); 79 | assert.equal(emitter.hasListener('test'), 0); 80 | }); 81 | }); -------------------------------------------------------------------------------- /src/EventEmitter.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | export default class EventEmitter { 5 | 6 | 7 | constructor() { 8 | this.eventHandlers = new Map(); 9 | } 10 | 11 | 12 | /** 13 | * emit an event 14 | * 15 | * @param {string} event The event 16 | * @param {Array} args The arguments 17 | */ 18 | emit(event, ...args) { 19 | let results = []; 20 | 21 | if (this.hasListener(event)) { 22 | const handlerMap = this.getEventHandlers(event); 23 | for (const [ handler, once ] of handlerMap.entries()) { 24 | results.push(handler(...args)); 25 | 26 | if (once) { 27 | handlerMap.delete(handler); 28 | } 29 | } 30 | } 31 | 32 | return results; 33 | } 34 | 35 | 36 | 37 | 38 | /** 39 | * return event handelrs 40 | * 41 | * @return {} The event handlers. 42 | */ 43 | getEventHandlers(event) { 44 | if (event) return this.eventHandlers.get(event); 45 | else return this.eventHandlers; 46 | } 47 | 48 | 49 | 50 | /** 51 | * Determines if listeners are registered for a given event 52 | * 53 | * @param {string} event event name 54 | */ 55 | hasListener(event) { 56 | return this.eventHandlers.has(event) && this.eventHandlers.get(event).size; 57 | } 58 | 59 | 60 | 61 | /** 62 | * register an once event handler 63 | * 64 | * @param {string} event The event 65 | * @param {function} handler The handler 66 | * @return {Model} this 67 | */ 68 | once(event, handler) { 69 | return this.on(event, handler, true); 70 | } 71 | 72 | 73 | 74 | /** 75 | * register an event handler 76 | * 77 | * @param {string} event The event 78 | * @param {function} handler The handler 79 | * @param {boolean} [once=false] if the handler shuld be called just 80 | * once 81 | * @return {Object} this 82 | */ 83 | on(event, handler, once = false) { 84 | if (!this.eventHandlers.has(event)) { 85 | this.eventHandlers.set(event, new Map()); 86 | } 87 | 88 | const handlerMap = this.eventHandlers.get(event); 89 | 90 | if (handlerMap.has(handler)) { 91 | throw new Error(`Cannot register the same event handler for ${event} twice!`); 92 | } 93 | 94 | this.eventHandlers.get(event).set(handler, once); 95 | return this; 96 | } 97 | 98 | 99 | 100 | /** 101 | * deregister event handler 102 | * 103 | * @param {string} event The event 104 | * @param {function} handler The handler, optionsl 105 | * @return {boolean} true if at least one handler was removed 106 | */ 107 | off(event, handler) { 108 | if (!this.eventHandlers.has(event)) return false; 109 | 110 | if (!handler) { 111 | this.eventHandlers.delete(event); 112 | return true; 113 | } 114 | 115 | if (handler) { 116 | if (this.eventHandlers.get(event).has(handler)) { 117 | this.eventHandlers.get(event).delete(handler); 118 | return true; 119 | } 120 | } 121 | 122 | return false; 123 | } 124 | } -------------------------------------------------------------------------------- /test/LeakyBucket.js: -------------------------------------------------------------------------------- 1 | import section from 'section-tests'; 2 | import LeakyBucket from '../src/LeakyBucket.js'; 3 | import assert from 'assert'; 4 | 5 | 6 | 7 | section('Leaky Bucket', (section) => { 8 | 9 | section.test('Compute factors correctly', async() => { 10 | const bucket = new LeakyBucket({ 11 | capacity: 120, 12 | interval: 60, 13 | timeout: 300, 14 | }); 15 | 16 | assert.equal(bucket.capacity, 120); 17 | assert.equal(bucket.interval, 60); 18 | assert.equal(bucket.timeout, 300); 19 | 20 | assert.equal(bucket.maxCapacity, 600); 21 | assert.equal(bucket.refillRate, 2) 22 | }); 23 | 24 | 25 | section.test('Excute items that are burstable and wait for the ones that cannot burst', async() => { 26 | const bucket = new LeakyBucket({ 27 | capacity: 100, 28 | interval: 60, 29 | timeout: 300, 30 | }); 31 | 32 | const start = Date.now(); 33 | 34 | for (let i = 0; i < 101; i++) { 35 | await bucket.throttle(); } 36 | 37 | const duration = Date.now() - start; 38 | assert(duration > 600); 39 | assert(duration < 700); 40 | }); 41 | 42 | section.test('Overflow when an excess item is added', async() => { 43 | const bucket = new LeakyBucket({ 44 | capacity: 100, 45 | interval: 60, 46 | timeout: 300, 47 | }); 48 | 49 | bucket.throttle(500); 50 | await bucket.throttle(1).catch(async (err) => { 51 | assert(err); 52 | 53 | // since the throttle with a cost of 500 was 400 cost over the 54 | // cost that can be processed immediatelly, the bucket needs to be ended 55 | bucket.end(); 56 | }); 57 | }); 58 | 59 | 60 | section.test('Overlow already added items when pausing the bucket', async() => { 61 | const bucket = new LeakyBucket({ 62 | capacity: 60, 63 | interval: 60, 64 | timeout: 70, 65 | }); 66 | 67 | bucket.throttle(60); 68 | bucket.throttle(5); 69 | bucket.throttle(5).catch(async (err) => { 70 | assert(err); 71 | 72 | // since the throttle with a cost of 500 was 400 cost over the 73 | // cost that can be processed immediatelly, the bucket needs to be ended 74 | bucket.end(); 75 | }); 76 | 77 | bucket.pause(); 78 | }); 79 | 80 | 81 | section.test('Empty bucket promise', async() => { 82 | const bucket = new LeakyBucket({ 83 | capacity: 60, 84 | interval: 60, 85 | timeout: 70, 86 | }); 87 | 88 | const start = Date.now(); 89 | bucket.throttle(60); 90 | bucket.throttle(1); 91 | 92 | await bucket.isEmpty(); 93 | 94 | const duration = Date.now() - start; 95 | assert(duration >= 1000); 96 | assert(duration < 1010); 97 | }); 98 | 99 | 100 | section.test('pausing the bucket', async() => { 101 | const bucket = new LeakyBucket({ 102 | capacity: 60, 103 | interval: 60, 104 | timeout: 120, 105 | }); 106 | 107 | const start = Date.now(); 108 | 109 | await bucket.throttle(10); 110 | await bucket.throttle(10); 111 | await bucket.pause(.5); 112 | await bucket.throttle(.5); 113 | 114 | const duration = Date.now() - start; 115 | assert(duration >= 1000); 116 | assert(duration < 1010); 117 | }); 118 | 119 | 120 | 121 | 122 | section.test('initial flooding', async() => { 123 | section.setTimeout(3500); 124 | 125 | const bucket = new LeakyBucket({ 126 | capacity: 1000, 127 | interval: 60, 128 | timeout: 300, 129 | }); 130 | 131 | let executedRequests = 0; 132 | let startTime = Date.now(); 133 | 134 | for (let i = 0; i < 21; ++i) { 135 | await bucket.throttle(50); 136 | executedRequests++; 137 | } 138 | 139 | assert.equal(executedRequests, 21); 140 | assert(Date.now() - startTime >= 3000); 141 | }); 142 | 143 | 144 | 145 | 146 | section.test('idleTimeout: no items', async() => { 147 | const bucket = new LeakyBucket({ 148 | capacity: 1000, 149 | interval: 1, 150 | timeout: 300, 151 | idleTimeout: 50 152 | }); 153 | 154 | let timedOut = false; 155 | 156 | bucket.on('idleTimeout', () => { 157 | timedOut = true; 158 | }); 159 | 160 | assert.equal(timedOut, false); 161 | await section.wait(1100); 162 | assert.equal(timedOut, true); 163 | }); 164 | 165 | 166 | 167 | 168 | section.test('idleTimeout: with items', async() => { 169 | section.setTimeout(4000); 170 | 171 | const bucket = new LeakyBucket({ 172 | capacity: 1000, 173 | interval: 1, 174 | timeout: 300, 175 | idleTimeout: 50 176 | }); 177 | 178 | let timedOut = false; 179 | 180 | bucket.on('idleTimeout', () => { 181 | timedOut = true; 182 | }); 183 | 184 | 185 | let startTime = Date.now(); 186 | let executedRequests = 0; 187 | 188 | for (let i = 0; i < 6; ++i) { 189 | await bucket.throttle(200); 190 | executedRequests++; 191 | } 192 | 193 | assert.equal(executedRequests, 6); 194 | assert(Date.now() - startTime >= 200); 195 | 196 | assert.equal(timedOut, false); 197 | await section.wait(1100); 198 | assert.equal(timedOut, true); 199 | }); 200 | 201 | 202 | 203 | 204 | section.test('initialCapacity: 0', async() => { 205 | section.setTimeout(4000); 206 | 207 | const bucket = new LeakyBucket({ 208 | capacity: 1000, 209 | interval: 1, 210 | timeout: 300, 211 | initialCapacity: 0, 212 | }); 213 | 214 | let startTime = Date.now(); 215 | let executedRequests = 0; 216 | 217 | for (let i = 0; i < 4; ++i) { 218 | await bucket.throttle(200); 219 | executedRequests++; 220 | } 221 | 222 | assert.equal(executedRequests, 4); 223 | assert(Date.now() - startTime >= 800); 224 | assert(Date.now() - startTime < 900); 225 | }); 226 | 227 | 228 | section.test('initialCapacity: 400', async() => { 229 | section.setTimeout(4000); 230 | 231 | const bucket = new LeakyBucket({ 232 | capacity: 1000, 233 | interval: 1, 234 | timeout: 300, 235 | initialCapacity: 400, 236 | }); 237 | 238 | let startTime = Date.now(); 239 | let executedRequests = 0; 240 | 241 | for (let i = 0; i < 4; ++i) { 242 | await bucket.throttle(200); 243 | executedRequests++; 244 | } 245 | 246 | assert.equal(executedRequests, 4); 247 | assert(Date.now() - startTime >= 400); 248 | assert(Date.now() - startTime < 500); 249 | }); 250 | }); -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # leaky-bucket 2 | 3 | A fast and efficient leaky bucket for node.js and the Browser 4 | 5 | Leaky buckets are often used to rate limits calls to APIs. They can be 6 | used on the server, to make sure the client does not send too many 7 | requests in a short time or on the client, to make sure to not to send 8 | too many requests to a server, that is rate limiting using a leaky 9 | bucket. 10 | 11 | Leaky buckets are burstable: if a server lets a client send 10 requests 12 | per minute, it normally lets the user burst those 10 requests in a short 13 | time. After that only one request every 6 seconds may be sent (60 seconds 14 | / 10 requests). If the user stops sending requests, the bucket is filled 15 | up again so that the user may send a burst of requests again. 16 | 17 | 18 | New in Version 4: 19 | - dropped node.js support for node <12 (es modules) 20 | - works now in modern browsers too (removed node.js dependencies) 21 | - added a debug flag to the constructor 22 | - added idleTimeout event and constructor flag 23 | - added the initalCapacity option to the constructor 24 | - added the getCapacity() method 25 | - added the getCurrentCapacity() method 26 | 27 | 28 | ## installation 29 | 30 | npm i leaky-bucket 31 | 32 | 33 | ## API 34 | 35 | ### Constructor 36 | 37 | ```javascript 38 | import LeakyBucket from 'leaky-bucket'; 39 | 40 | // a leaky bucket, that will burst 60 items, then will throttle the items to one per seond 41 | const bucket = new Bucket({ 42 | capacity: 60, 43 | interval: 60, 44 | }); 45 | ``` 46 | 47 | #### option: capacity 48 | 49 | The capacity defines how many requests may be sent oer interval. If the 50 | capacity is 100 and the interval is 60 seconds and a request has a cost 51 | of 1, every 60 seconds 100 request may be processed. If the request 52 | cost is 4, just 25 requests may be processed every 60 seconds (100/4 = 25). 53 | 54 | The complete capacity can be used in a burst. This means for the example 55 | above, 100 requests can be processed immediately. Thereafter every request 56 | has to weit for 0.6 seconds (60 seconds / 100 capacity) if the request 57 | cost is 1. 58 | 59 | #### option: interval 60 | 61 | The interval defines, how much seconds it takes to refill the bucket to 62 | its full capacity. The bucket is not filled every interval, but continously. 63 | 64 | #### option: timeout 65 | 66 | Normally, the bucket will throw errors when the throttle() method is called 67 | when the bucket is empty. When a timeout is defined, the bucket will queue 68 | items as long they can be executed within the timeout. Defaults to 0, which 69 | will not queue any items if the bucket is empty. 70 | 71 | #### option: initialCapacity 72 | 73 | Some rate limited services will start out with an empty bucket, or refill 74 | the bucket not continusly but in an interval. This option can be used to 75 | set a starting capacity beween 0 and the configured capacity. If set to 0 76 | and a request shall be processed immediately and the timeout is 0, the 77 | bucket will reject the request. 78 | 79 | #### option: idleTimeout 80 | 81 | If this option is set, the bucket will emti a idleTimeout event after the 82 | bucket is filled completely and no requests are waiting. Configured in 83 | milliseconds. 84 | 85 | #### option: debug 86 | 87 | If set to true, the bucket will print debug logs using console.log() 88 | 89 | 90 | 91 | ### async bucket.throttle(cost = 1) 92 | 93 | This is the main method used for procsessing requests. If this method is 94 | called and the bucket has more capacity left that the request costs, it 95 | will continue. If the capacity is less than the cost, it will throw an 96 | error. If the timeout option is configured, the method will sleep until 97 | there is enough capacity to process it. 98 | 99 | This method accepts two optional parameters: 100 | 101 | - cost: the cost of the item, defaults to 1. 102 | - append: if set to false, the item is added at the beginning of the queue and will thus executed before all other queued items. Defaults to true; 103 | 104 | ```javascript 105 | // throttle an individual item 106 | await bucket.throttle(); 107 | doThings(); 108 | 109 | 110 | // Throttle a set of items, waiting for each one to complete before the next one is executed 111 | for (const item of set.values()) { 112 | await bucket.throttle(); 113 | doThings(); 114 | } 115 | 116 | 117 | // throttle multiple items and wait untiul all are finished 118 | await Promise.all(Array.from(set).map(async(item) => { 119 | await bucket.throttle(); 120 | doThings(); 121 | })); 122 | ```` 123 | 124 | 125 | ### bucket.pause(seconds) 126 | 127 | The pause method can be use to pause the bucket for n seconds. Same as the throttle call but does not throw errors when the bucket is over its capacity. 128 | 129 | 130 | 131 | ```javascript 132 | bucket.pause(2); 133 | 134 | ```` 135 | 136 | 137 | ### bucket.pauseByCost(cost) 138 | 139 | The pause method can be use to pause the bucket for a specific cost. Same as the throttle call but does not throw errors when the bucket is over its capacity. 140 | 141 | 142 | ```javascript 143 | bucket.pauseByCost(300); 144 | 145 | ```` 146 | 147 | 148 | ### bucket.pay(cost) 149 | 150 | Removes the defined cost from the bucket without taking any action. Reduces the current capacity. 151 | 152 | 153 | ```javascript 154 | bucket.pay(cost); 155 | 156 | ```` 157 | 158 | 159 | ### bucket.end() 160 | 161 | Shuts down the bucket, clears all timers. Removes all pending items wihtout executing them. The bucket cannot be reused thereafter! 162 | 163 | 164 | ```javascript 165 | bucket.end(); 166 | ```` 167 | 168 | 169 | ### bucket.getCapacity() 170 | 171 | Returns the total capacity of the bucket. 172 | 173 | 174 | ```javascript 175 | const capacity = bucket.getCapacity(); 176 | ```` 177 | 178 | 179 | ### bucket.getCurrentCapacity() 180 | 181 | Returns the current capacity of the bucket. 182 | 183 | 184 | ```javascript 185 | const currentCapacity = bucket.getCurrentCapacity(); 186 | ```` 187 | 188 | ### bucket.setTimeout(seconds) 189 | 190 | Sets the amount of seconds the bucket queue items before it starts to reject them. Same as the timeout option in the constructor 191 | 192 | 193 | ```javascript 194 | bucket.setTimeout(300); 195 | ```` 196 | 197 | ### bucket.setInterval(seconds) 198 | 199 | Sets the interval it takes to refill the bucket completely. Same as the interval option in the constructor 200 | 201 | 202 | ```javascript 203 | bucket.setInterval(60); 204 | ```` 205 | 206 | ### bucket.setCapacity(capacity) 207 | 208 | Sets the capacity of the bucket. Same as the capacity option in the constructor 209 | 210 | 211 | ```javascript 212 | bucket.setTimeout(1000); 213 | ```` 214 | 215 | 216 | 217 | ### Event bucket.on('idleTimeout') 218 | 219 | This event is emitted, if the bucket is at full capacity and idle for N milliseconds 220 | 221 | ```javascript 222 | const bucket = new Bucket({ 223 | capacity: 60, 224 | interval: 60, 225 | }); 226 | 227 | bucket.on('idleTimeout', (bucketInstance) => { 228 | bucket.end(); 229 | }); 230 | 231 | // you may remove the listener if you want 232 | bucket.off('idleTimeout'); 233 | ```` 234 | 235 | 236 | 237 | ### Event bucket.on('idle') 238 | 239 | This event is emitted, when the bucket is idle, thus no items are waiting to be executed. 240 | 241 | ```javascript 242 | const bucket = new Bucket({ 243 | capacity: 60, 244 | interval: 60, 245 | 246 | bucket.on('idle', (bucketInstance) => { 247 | console.log('bucket is idling'); 248 | }); 249 | 250 | // you may remove the listener if you want 251 | bucket.off('idle'); 252 | ```` 253 | 254 | ### bucket.off(eventName, optional handler) 255 | 256 | Removes all or one listeners for an event 257 | 258 | ```javascript 259 | const bucket = new Bucket({ 260 | capacity: 60, 261 | interval: 60, 262 | }); 263 | 264 | // remove all listeners for the idle event 265 | bucket.off('idle') 266 | 267 | const listener = (bucketInstance) => { 268 | console.log(bucketInstance.getCurrentCapacity()); 269 | } 270 | 271 | bucket.on('idle', listener); 272 | 273 | // remove one specific listener 274 | bucket.off('idle', listener); 275 | ```` 276 | 277 | 278 | ## Browser 279 | 280 | The bucket can used in the Browser. Import `src/LeakyBucket.js` for that usecase. 281 | 282 | 283 | ## Debugging 284 | 285 | In order to debug the internals of the bucket you may enable debugging by passing the debug flag to the constructor. 286 | 287 | ```javascript 288 | const bucket = new Bucket({ 289 | capacity: 60, 290 | interval: 60, 291 | debug: true, 292 | }); 293 | ```` 294 | 295 | ## express.js 296 | 297 | If you'd like to throttle incoming requests using the leaky bucket with express, you may register it as middleware. The example below shows a bucket per user, identified by a cookie identifying the user. The bucket gets deleted after a user has not sent requests for 2 minutes. 298 | 299 | 300 | ```javascript 301 | import LeakyBucket from 'leaky-bucket'; 302 | import express from 'express'; 303 | 304 | 305 | const app = express() 306 | const buckets = new Map(); 307 | const costOfOperation = 50; 308 | 309 | app.use((req, res, next) => { 310 | // your cookie should be secure and not guessable by the user 311 | const userUid = req.cookies.userUid; 312 | 313 | // set up a bucket for the user 314 | if (!users.has(userUid)) { 315 | const bucket = new Bucket({ 316 | capacity: 1000, 317 | interval: 60, 318 | idleTimeout: 120 * 1000 // 120 seconds 319 | }); 320 | 321 | // end the bucket, remove it from memory when it becomes idle 322 | bucket.on('idleTimeout', () => { 323 | bucket.end(); 324 | buckets.delete(userUid); 325 | }); 326 | 327 | // store for later access 328 | buckets.set(userUid, bucket); 329 | } 330 | 331 | // get the users bucket 332 | const usersBucket = buckets.get(userUid); 333 | 334 | 335 | try { 336 | // try to execute the request, if the bucket is empty, it will throw an error 337 | usersBucket.throttle(costOfOperation); 338 | } catch (e) { 339 | // bucket is over capacity 340 | res.status(420).send(`Enhance your calm!`); 341 | return; 342 | } 343 | 344 | 345 | // all set and fine, continue to process the request 346 | res.set('x-rate-limit-cost', costOfOperation); 347 | res.set('x-rate-limit-bucket-size', bucket.getCapacity()); 348 | res.set('x-rate-limit-remaining-size', bucket.getCurrentCapacity()); 349 | next(); 350 | }); 351 | 352 | 353 | app.listen(8080); 354 | ```` 355 | -------------------------------------------------------------------------------- /src/LeakyBucket.js: -------------------------------------------------------------------------------- 1 | import EventEmitter from './EventEmitter.js'; 2 | 3 | 4 | export default class LeakyBucket extends EventEmitter { 5 | 6 | 7 | /** 8 | * Sets up the leaky bucket. The bucket is designed so that it can 9 | * burst by the capacity it is given. after that items can be queued 10 | * until a timeout of n seonds is reached. 11 | * 12 | * example: throttle 10 actions per minute that have each a cost of 1, reject 13 | * everything theat is overflowing. there will no more than 10 items queued 14 | * at any time 15 | * capacity: 10 16 | * interval: 60 17 | * timeout: 60 18 | * 19 | * example: throttle 100 actions per minute that have a cost of 1, reject 20 | * items that have to wait more thatn 2 minutes. there will be no more thatn 21 | * 200 items queued at any time. of those 200 items 100 will be bursted within 22 | * a minute, the rest will be executed evenly spread over a mintue. 23 | * capacity: 100 24 | * interval: 60 25 | * timeout: 120 26 | * 27 | * @param {number} capacity the capacity the bucket has per interval 28 | * @param {number} timeout the total time items are allowed to wait for execution 29 | * @param {number} interval the interval for the capacity in seconds 30 | */ 31 | constructor({ 32 | capacity = 60, 33 | timeout, 34 | interval = 60000, 35 | debug = false, 36 | idleTimeout = null, 37 | initialCapacity = null, 38 | } = {}) { 39 | super(); 40 | 41 | // if true, logs are printed 42 | this.debug = !!debug; 43 | 44 | // set the timeout to the interval if not set, so that the bucket overflows as soon 45 | // the capacity is reached 46 | if (isNaN(timeout)) timeout = interval; 47 | 48 | // queue containing all items to execute 49 | this.queue = []; 50 | 51 | // the value of all items currently enqueued 52 | this.totalCost = 0; 53 | 54 | // the capacity, which can be used at this moment 55 | // to execute items 56 | this.currentCapacity = capacity; 57 | 58 | // time when the last refill occured 59 | this.lastRefill = null; 60 | 61 | // correct for the inital capacity 62 | if (initialCapacity !== null) { 63 | this.pay(capacity - initialCapacity); 64 | } 65 | 66 | // if the bucket is full and the idle timeout is reached, 67 | // it will emit the idleTimeout event 68 | this.idleTimeout = idleTimeout; 69 | 70 | this.setCapacity(capacity); 71 | this.setTimeout(timeout); 72 | this.setInterval(interval); 73 | 74 | this.refill(); 75 | } 76 | 77 | 78 | 79 | 80 | /** 81 | * the throttle method is used to throttle things. it is async and will resolve either 82 | * immediatelly, if there is space in the bucket, that can be bursted, or it will wait 83 | * until there is enough capacity left to execute the item with the given cost. if the 84 | * bucket is overflowing, and the item cannot be executed within the timeout of the bucket, 85 | * the call will be rejected with an error. 86 | * 87 | * @param {number} cost=1 the cost of the item to be throttled. is the cost is unknown, 88 | * the cost can be payed after execution using the pay method. 89 | * defaults to 1. 90 | * @param {boolean} append = true set to false if the item needs ot be added to the 91 | * beginning of the queue 92 | * @param {boolean} isPause = false defines if the element is a pause elemtn, if yes, it 93 | * will not be cleaned off of the queue when checking 94 | * for overflowing elements 95 | * @returns {promise} resolves when the item can be executed, rejects if the item cannot 96 | * be executed in time 97 | */ 98 | async throttle(cost = 1, append = true, isPause = false) { 99 | const maxCurrentCapacity = this.getCurrentMaxCapacity(); 100 | 101 | // if items are added at the beginning, the excess items will be removed 102 | // later on 103 | if (append && this.totalCost + cost > maxCurrentCapacity) { 104 | if (this.debug) console.log(`Rejecting item because the bucket is over capacity! Current max capacity: ${maxCurrentCapacity}, Total cost of all queued items: ${this.totalCost}, item cost: ${cost}`); 105 | throw new Error(`Cannot throttle item, bucket is overflowing: the maximum capacity is ${maxCurrentCapacity}, the current total capacity is ${this.totalCost}!`); 106 | } 107 | 108 | return new Promise((resolve, reject) => { 109 | const item = { 110 | resolve, 111 | reject, 112 | cost, 113 | isPause, 114 | }; 115 | 116 | this.totalCost += cost; 117 | 118 | if (append) { 119 | this.queue.push(item); 120 | if (this.debug) console.log(`Appended an item with the cost of ${cost} to the queue`); 121 | } else { 122 | this.queue.unshift(item); 123 | if (this.debug) console.log(`Added an item to the start of the queue with the cost of ${cost} to the queue`); 124 | this.cleanQueue(); 125 | } 126 | 127 | 128 | this.startTimer(); 129 | }); 130 | } 131 | 132 | 133 | /** 134 | * returns the capacity 135 | * 136 | * @return {number} The capacity. 137 | */ 138 | getCapacity() { 139 | return this.capacity; 140 | } 141 | 142 | 143 | /** 144 | * returns the current capacity 145 | * 146 | * @return {number} The current capacity. 147 | */ 148 | getCurrentCapacity() { 149 | return this.currentCapacity; 150 | } 151 | 152 | 153 | /** 154 | * either executes directly when enough capacity is present or delays the 155 | * execution until enough capacity is available. 156 | * 157 | * @private 158 | */ 159 | startTimer() { 160 | if (!this.timer) { 161 | if (this.queue.length > 0) { 162 | const item = this.getFirstItem(); 163 | if (this.debug) console.log(`Processing an item with the cost of ${item.cost}`); 164 | 165 | this.stopIdleTimer(); 166 | this.refill(); 167 | 168 | if (this.currentCapacity >= item.cost) { 169 | item.resolve(); 170 | if (this.debug) console.log(`Resolved an item with the cost ${item.cost}`) 171 | 172 | // remove the item from the queue 173 | this.shiftQueue(); 174 | 175 | // pay it's cost 176 | this.pay(item.cost); 177 | 178 | // go to the next item 179 | this.startTimer(); 180 | } else { 181 | const requiredDelta = item.cost + (this.currentCapacity * -1); 182 | const timeToDelta = requiredDelta / this.refillRate * 1000; 183 | 184 | if (this.debug) console.log(`Waiting ${timeToDelta} for topping up ${requiredDelta} capacity until the next item can be processed ...`); 185 | // wait until the next item can be handled 186 | this.timer = setTimeout(() => { 187 | this.timer = 0; 188 | this.startTimer(); 189 | }, timeToDelta); 190 | } 191 | } else { 192 | // refill the bucket, will start the idle timeout eventually 193 | this.refill(); 194 | } 195 | } 196 | } 197 | 198 | 199 | /** 200 | * removes the first item in the queue, resolves the promise that indicated 201 | * that the bucket is empty and no more items are waiting 202 | * 203 | * @private 204 | */ 205 | shiftQueue() { 206 | this.queue.shift(); 207 | 208 | if (this.queue.length === 0) { 209 | if (this.emptyPromiseResolver) { 210 | this.emptyPromiseResolver(); 211 | } 212 | 213 | this.emit('idle', this); 214 | } 215 | } 216 | 217 | 218 | 219 | 220 | 221 | 222 | /** 223 | * is resolved as soon as the bucket is empty. is basically an event 224 | * that is emitted 225 | * 226 | * @private 227 | */ 228 | async isEmpty() { 229 | if (!this.emptyPromiseResolver) { 230 | this.emptyPromise = new Promise((resolve) => { 231 | this.emptyPromiseResolver = () => { 232 | this.emptyPromiseResolver = null; 233 | this.emptyPromise = null; 234 | resolve(); 235 | }; 236 | }); 237 | } 238 | 239 | return this.emptyPromise; 240 | } 241 | 242 | 243 | 244 | 245 | /** 246 | * ends the bucket. The bucket may be recycled after this call 247 | */ 248 | end() { 249 | if (this.debug) console.log(`Ending bucket!`); 250 | this.stopTimer(); 251 | this.stopIdleTimer(); 252 | this.stopRefillTimer(); 253 | this.clear(); 254 | } 255 | 256 | 257 | 258 | /** 259 | * removes all items from the queue, does not stop the timer though 260 | * 261 | * @privae 262 | */ 263 | clear() { 264 | if (this.debug) console.log(`Resetting queue`); 265 | this.queue = []; 266 | } 267 | 268 | 269 | 270 | /** 271 | * can be used to pay costs for items where the cost is clear after exection 272 | * this will devcrease the current capacity availabe on the bucket. 273 | * 274 | * @param {number} cost the ost to pay 275 | */ 276 | pay(cost) { 277 | if (this.debug) console.log(`Paying ${cost}`); 278 | 279 | // reduce the current capacity, so that bursts 280 | // as calculated correctly 281 | this.currentCapacity -= cost; 282 | if (this.debug) console.log(`The current capacity is now ${this.currentCapacity}`); 283 | 284 | // keep track of the total cost for the bucket 285 | // so that we know when we're overflowing 286 | this.totalCost -= cost; 287 | 288 | // store the date the leaky bucket was starting to leak 289 | // so that it can be refilled correctly 290 | if (this.lastRefill === null) { 291 | this.lastRefill = Date.now(); 292 | } 293 | } 294 | 295 | 296 | 297 | /** 298 | * stops the running times 299 | * 300 | * @private 301 | */ 302 | stopTimer() { 303 | if (this.timer) { 304 | if (this.debug) console.log(`Stopping timer`); 305 | clearTimeout(this.timer); 306 | this.timer = null; 307 | } 308 | } 309 | 310 | 311 | 312 | /** 313 | * refills the bucket with capacity which has become available since the 314 | * last refill. starts to refill after a call has started using capacity 315 | * 316 | * @private 317 | */ 318 | refill() { 319 | 320 | // don't do refills, if we're already full 321 | if (this.currentCapacity < this.capacity) { 322 | 323 | // refill the currently avilable capacity 324 | const refillAmount = ((Date.now() - this.lastRefill) / 1000) * this.refillRate; 325 | this.currentCapacity += refillAmount; 326 | if (this.debug) console.log(`Refilled the bucket with ${refillAmount}, last refill was ${this.lastRefill}, current Date is ${Date.now()}, diff is ${(Date.now() - this.lastRefill)} msec`); 327 | if (this.debug) console.log(`The current capacity is now ${this.currentCapacity}`); 328 | 329 | // make sure, that no more capacity is added than is the maximum 330 | if (this.currentCapacity >= this.capacity) { 331 | this.currentCapacity = this.capacity; 332 | if (this.debug) console.log(`The current capacity is now ${this.currentCapacity}`); 333 | 334 | this.lastRefill = null; 335 | if (this.debug) console.log(`Buckets capacity is fully recharged`); 336 | } else { 337 | // date of last refill, ued for the next refill 338 | this.lastRefill = Date.now(); 339 | } 340 | 341 | 342 | // start the refill timer 343 | this.startRefillTimer(); 344 | } else { 345 | this.startIdleTimer(); 346 | } 347 | } 348 | 349 | 350 | /** 351 | * this timer is schduled to refill the bucket on the moment 352 | * it shoudl reach ful capacity. used for the idle event 353 | * 354 | * @private 355 | */ 356 | startRefillTimer() { 357 | if (!this.idleTimeout || this.refillTimer) return; 358 | 359 | const requiredDelta = this.capacity - this.currentCapacity 360 | const timeToDelta = requiredDelta / this.refillRate * 1000; 361 | 362 | 363 | this.refillTimer = setTimeout(() => { 364 | this.refillTimer = null; 365 | this.refill(); 366 | }, timeToDelta + 10); 367 | } 368 | 369 | 370 | /** 371 | * Stops the refill timer. 372 | * 373 | * @private 374 | */ 375 | stopRefillTimer() { 376 | if (this.refillTimer) { 377 | clearTimeout(this.refillTimer); 378 | this.refillTimer = null; 379 | } 380 | } 381 | 382 | 383 | /** 384 | * Stops the idle timer. 385 | * 386 | * @private 387 | */ 388 | stopIdleTimer() { 389 | if (this.idleTimer) { 390 | clearTimeout(this.idleTimer); 391 | this.idleTimer = null; 392 | } 393 | } 394 | 395 | /** 396 | * the idle timer is started as soon the bucket is idle and 397 | * at full capacity 398 | * 399 | * @private 400 | */ 401 | startIdleTimer() { 402 | if (!this.idleTimeout) return; 403 | this.stopIdleTimer(); 404 | 405 | if (this.currentCapacity >= this.capacity && 406 | this.queue.length === 0 && 407 | !this.timer) { 408 | 409 | this.idleTimer = setTimeout(() => { 410 | this.emit('idleTimeout', this); 411 | }, this.idleTimeout); 412 | } 413 | } 414 | 415 | 416 | 417 | /** 418 | * gets the currenlty avilable max capacity, respecintg 419 | * the capacity that is already used in the moment 420 | * 421 | * @private 422 | */ 423 | getCurrentMaxCapacity() { 424 | this.refill(); 425 | return this.maxCapacity - (this.capacity - this.currentCapacity); 426 | } 427 | 428 | 429 | 430 | /** 431 | * removes all items that cannot be executed in time due to items 432 | * that were added in front of them in the queue (mostly pause items) 433 | * 434 | * @private 435 | */ 436 | cleanQueue() { 437 | const maxCapacity = this.getCurrentMaxCapacity(); 438 | let currentCapacity = 0; 439 | 440 | // find the first item, that goes over the thoretical maximal 441 | // capacity that is available 442 | const index = this.queue.findIndex((item) => { 443 | currentCapacity += item.cost; 444 | return currentCapacity > maxCapacity; 445 | }); 446 | 447 | 448 | // reject all items that cannot be enqueued 449 | if (index >= 0) { 450 | this.queue.splice(index).forEach((item) => { 451 | if (!item.isPause) { 452 | if (this.debug) console.log(`Rejecting item with a cost of ${item.cost} because an item was added in front of it!`); 453 | item.reject(new Error(`Cannot throttle item because an item was added in front of it which caused the queue to overflow!`)); 454 | this.totalCost -= item.cost; 455 | } 456 | }); 457 | } 458 | } 459 | 460 | 461 | 462 | /** 463 | * returns the first item from the queue 464 | * 465 | * @private 466 | */ 467 | getFirstItem() { 468 | if (this.queue.length > 0) { 469 | return this.queue[0]; 470 | } else { 471 | return null; 472 | } 473 | } 474 | 475 | 476 | 477 | /** 478 | * pause the bucket for the given cost. means that an item is added in the 479 | * front of the queue with the cost passed to this method 480 | * 481 | * @param {number} cost the cost to pasue by 482 | */ 483 | pauseByCost(cost) { 484 | this.stopTimer(); 485 | if (this.debug) console.log(`Pausing bucket for ${cost} cost`); 486 | this.throttle(cost, false, true); 487 | } 488 | 489 | 490 | /** 491 | * pause the bucket for n seconds. means that an item with the cost for one 492 | * second is added at the beginning of the queue 493 | * 494 | * @param {number} seconds the number of seconds to pause the bucket by 495 | */ 496 | pause(seconds = 1) { 497 | this.drain(); 498 | this.stopTimer(); 499 | const cost = this.refillRate * seconds; 500 | if (this.debug) console.log(`Pausing bucket for ${seconds} seonds`); 501 | this.pauseByCost(cost); 502 | } 503 | 504 | 505 | 506 | /** 507 | * drains the bucket, so that nothing can be exuted at the moment 508 | * 509 | * @private 510 | */ 511 | drain() { 512 | if (this.debug) console.log(`Draining the bucket, removing ${this.currentCapacity} from it, so that the current capacity is 0`); 513 | this.currentCapacity = 0; 514 | if (this.debug) console.log(`The current capacity is now ${this.currentCapacity}`); 515 | 516 | this.lastRefill = Date.now(); 517 | } 518 | 519 | 520 | 521 | /** 522 | * set the timeout value for the bucket. this is the amount of time no item 523 | * may longer wait for. 524 | * 525 | * @param {number} timeout in seonds 526 | */ 527 | setTimeout(timeout) { 528 | if (this.debug) console.log(`the buckets timeout is now ${timeout}`); 529 | this.timeout = timeout; 530 | this.updateVariables(); 531 | return this; 532 | } 533 | 534 | 535 | /** 536 | * set the interval within whch the capacity can be used 537 | * 538 | * @param {number} interval in seonds 539 | */ 540 | setInterval(interval) { 541 | if (this.debug) console.log(`the buckets interval is now ${interval}`); 542 | this.interval = interval; 543 | this.updateVariables(); 544 | return this; 545 | } 546 | 547 | 548 | /** 549 | * set the capacity of the bucket. this si the capacity that can be used per interval 550 | * 551 | * @param {number} capacity 552 | */ 553 | setCapacity(capacity) { 554 | if (this.debug) console.log(`the buckets capacity is now ${capacity}`); 555 | this.capacity = capacity; 556 | this.updateVariables(); 557 | return this; 558 | } 559 | 560 | 561 | 562 | /** 563 | * claculates the values of some frequently used variables on the bucket 564 | * 565 | * @private 566 | */ 567 | updateVariables() { 568 | // take one as default for each variable since this method may be called 569 | // before every variable was set 570 | this.maxCapacity = ((this.timeout || 1) / (this.interval || 1)) * (this.capacity || 1); 571 | 572 | // the rate, at which the leaky bucket is filled per second 573 | this.refillRate = (this.capacity || 1) / (this.interval || 1); 574 | 575 | if (this.debug) console.log(`the buckets max capacity is now ${this.maxCapacity}`); 576 | if (this.debug) console.log(`the buckets refill rate is now ${this.refillRate}`); 577 | } 578 | } --------------------------------------------------------------------------------