├── .github └── workflows │ └── crystal_spec.yml ├── .gitignore ├── .vscode ├── settings.json └── tasks.json ├── LICENSE ├── README.md ├── docs ├── RateLimiter.html ├── RateLimiter │ ├── Limiter.html │ ├── LimiterLike.html │ ├── MultiLimiter.html │ ├── Timeout.html │ └── Token.html ├── css │ └── style.css ├── index.html ├── index.json ├── js │ └── doc.js └── search-index.js ├── media └── diagram_1.png ├── shard.yml ├── spec ├── rate_limiter_spec.cr ├── spec_helper.cr └── token_spec.cr └── src └── rate_limiter.cr /.github/workflows/crystal_spec.yml: -------------------------------------------------------------------------------- 1 | name: Crystal spec 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | container: 14 | image: crystallang/crystal 15 | 16 | steps: 17 | - uses: actions/checkout@v2 18 | - name: Run tests 19 | run: crystal spec 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /lib/ 2 | /bin/ 3 | /.shards/ 4 | *.dwarf 5 | 6 | # Libraries don't need dependency lock 7 | # Dependencies will be locked in applications that use them 8 | /shard.lock 9 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "spellright.language": [ 3 | "engb" 4 | ], 5 | "spellright.documentTypes": [ 6 | "markdown", 7 | "latex" 8 | ] 9 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "shell", 6 | "command": "crystal run ${file}", 7 | "problemMatcher": [], 8 | "label": "Crystal run", 9 | "group": { 10 | "kind": "build", 11 | "isDefault": true 12 | } 13 | } 14 | ] 15 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Lorenzo Barasti 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![GitHub release](https://img.shields.io/github/release/lbarasti/rate_limiter.svg) 2 | ![Build Status](https://github.com/lbarasti/rate_limiter/workflows/Crystal%20spec/badge.svg) 3 | [![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://opensource.org/licenses/MIT) 4 | [![Docs](https://img.shields.io/badge/docs-available-brightgreen.svg)](https://lbarasti.github.io/rate_limiter) 5 | 6 | # rate_limiter 7 | 8 | This shard provides a Crystal implementation of the [token bucket](https://en.wikipedia.org/wiki/Token_bucket) algorithm for rate limiting. You can check out the API documentation [here](https://lbarasti.com/rate_limiter/). 9 | 10 | ## Installation 11 | 12 | 1 - Add the dependency to your `shard.yml`: 13 | 14 | ```yaml 15 | dependencies: 16 | rate_limiter: 17 | github: lbarasti/rate_limiter 18 | ``` 19 | 20 | 2 - Run `shards install` 21 | 22 | ## Usage 23 | 24 | Remember to import the shard at the top of your file. 25 | ```crystal 26 | require "rate_limiter" 27 | ``` 28 | 29 | Now you can initialise a rate limiter that produces 1 token every few seconds 30 | ```crystal 31 | rl = RateLimiter.new(interval: 3.seconds) 32 | ``` 33 | 34 | Mind that the first call to `#get` will return immediately, as buckets are initialised with 1 token, by default. 35 | ```crystal 36 | rl.get # => RateLimiter::Token(2020-11-29 20:36:56 UTC) 37 | ``` 38 | 39 | The next call to `#get` will block for approx. 3 seconds 40 | ```crystal 41 | start_time = Time.utc 42 | rl.get 43 | Time.utc - start_time # => 00:00:03.000426843 44 | ``` 45 | 46 | We can also provide a `max_wait` parameter to `#get`. 47 | ```crystal 48 | rl.get(0.5.seconds) 49 | ``` 50 | This call will block for at most 0.5 seconds. If a token is not returned within that interval, then a `RateLimiter::Timeout` is returned. 51 | 52 | Rate limiters also expose non-blocking methods. 53 | ```crystal 54 | rl.get? # returns `nil` if no token is available 55 | 56 | rl.get! # raises a RateLimiter::Timeout exception if no token is available 57 | ``` 58 | 59 | You can pass `#get!` a `max_wait` parameter. 60 | ```crystal 61 | rl.get!(1.second) 62 | ``` 63 | This will raise a `RateLimiter::Timeout` exception if no token is returned within a 1 second interval. 64 | 65 | ### Burst size 66 | You can define a rate limiter that accumulates unused tokens up to the specified value by providing a `max_burst` parameter to `RateLimiter.new` - the default is 1. 67 | ```crystal 68 | RateLimiter.new(rate: 0.5, max_burst: 10) 69 | ``` 70 | This will generate 1 token every 2 seconds and store up to 10 unused tokens for later use. See Wikipedia's [Burst size](https://en.wikipedia.org/wiki/Token_bucket#Burst_size) for more details. 71 | 72 | ### Multi-limiters 73 | In the scenario where a part of your code needs to abide to two or more rate limits, you can combine multiple rate limiters into a `MultiLimiter`. 74 | 75 | ```crystal 76 | api_limiter = RateLimiter.new(rate: 10, max_burst: 60) 77 | db_limiter = RateLimiter.new(rate: 100) 78 | multi = RateLimiter::MultiLimiter.new(api_limiter, db_limiter) 79 | ``` 80 | 81 | You can also use the convenience constructor on the `RateLimiter` module. 82 | 83 | ```crystal 84 | multi = RateLimiter.new(api_limiter, db_limiter) 85 | ``` 86 | 87 | A `MultiLimiter` exposes the same API as a regular `Limiter` - they both include the `LimiterLike` module - so you can call the same flavours of `#get` methods on it. 88 | 89 | When calling `get` on a `MultiLimiter`, it will try to acquire tokens from each one of the underlying rate limiters, and only return a token then. 90 | 91 | ## Under the hood 92 | ![A rate limiter produces one token in each interval. If the bucket has no more room available, then no token will be added for the interval.](./media/diagram_1.png) 93 | 94 | ## Why do I need a rate limiter? 95 | * We're calling an API that throttles us when we 96 | call it too frequently, and we'd rather avoid that. 97 | * We are exposing an API to customers and want to 98 | ensure we don't get flooded with requests. For example, we might want to rate limit calls by client id, so that one misbehaving client will not affect the others. 99 | * One of our ETL stages talks to a datastore that limits the number of requests per second we can send. 100 | * We have to run a database migration in production and we don't 101 | want to affect the responsiveness of the service. 102 | 103 | ## Development 104 | 105 | Run the following to run the tests. 106 | ``` 107 | crystal spec 108 | ``` 109 | 110 | ## Contributing 111 | 112 | 1. Fork it () 113 | 2. Create your feature branch (`git checkout -b my-new-feature`) 114 | 3. Commit your changes (`git commit -am 'Add some feature'`) 115 | 4. Push to the branch (`git push origin my-new-feature`) 116 | 5. Create a new Pull Request 117 | 118 | ## Contributors 119 | 120 | - [lbarasti](https://github.com/lbarasti) - creator and maintainer 121 | -------------------------------------------------------------------------------- /docs/RateLimiter.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | RateLimiter - rate_limiter main-dev 17 | 20 | 21 | 22 | 23 | 28 | 93 | 94 | 95 |
96 |

97 | 98 | module RateLimiter 99 | 100 |

101 | 102 | 103 | 104 | 105 | 106 |

107 | 108 | 111 | 112 | Overview 113 |

114 | 115 |

Rate limiting functionality.

116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 |

131 | 132 | 135 | 136 | Defined in: 137 |

138 | 139 | 140 | rate_limiter.cr 141 | 142 |
143 | 144 | 145 | 146 | 147 | 148 |

149 | 150 | 153 | 154 | Constant Summary 155 |

156 | 157 |
158 | 159 |
160 | VERSION = "1.0.1" 161 |
162 | 163 | 164 |
165 | 166 | 167 | 168 |

169 | 170 | 173 | 174 | Constructors 175 |

176 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 |
210 | 211 |
212 | 213 | 214 |

215 | 216 | 219 | 220 | Constructor Detail 221 |

222 | 223 |
224 |
225 | 226 | def self.new(rate : Float64, max_burst : Int32 = 1) 227 | 228 | # 229 |
230 | 231 |
232 | 233 |

Creates a new Limiter. 234 | rate: the rate of tokens being produced in tokens/second. 235 | max_burst: maximum number of tokens that can be stored in the bucket.

236 |
237 | 238 |
239 |
240 | 241 |
242 |
243 | 244 |
245 |
246 | 247 | def self.new(interval : Time::Span, max_burst : Int32 = 1) 248 | 249 | # 250 |
251 | 252 |
253 | 254 |

Creates a new Limiter. 255 | interval: the interval at which new tokens are generated. 256 | max_burst: maximum number of tokens that can be stored in the bucket.

257 |
258 | 259 |
260 |
261 | 262 |
263 |
264 | 265 |
266 |
267 | 268 | def self.new(*limiters : Limiter) 269 | 270 | # 271 |
272 | 273 |
274 | 275 |

Creates a MultiLimiter. 276 | limiters: a set of rate limiters.

277 |
278 | 279 |
280 |
281 | 282 |
283 |
284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 | 293 |
294 | 295 | 296 | 297 | -------------------------------------------------------------------------------- /docs/RateLimiter/Limiter.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | RateLimiter::Limiter - rate_limiter main-dev 17 | 20 | 21 | 22 | 23 | 28 | 93 | 94 | 95 |
96 |

97 | 98 | class RateLimiter::Limiter 99 | 100 |

101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 |

109 | 110 | 113 | 114 | Overview 115 |

116 | 117 |

A rate limiter erogating tokens at the specified rate.

118 | 119 |

This is powered by the token bucket algorithm.

120 | 121 | 122 | 123 | 124 | 125 |

126 | 127 | 130 | 131 | Included Modules 132 |

133 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 |

149 | 150 | 153 | 154 | Defined in: 155 |

156 | 157 | 158 | rate_limiter.cr 159 | 160 |
161 | 162 | 163 | 164 | 165 | 166 | 167 |

168 | 169 | 172 | 173 | Constructors 174 |

175 | 183 | 184 | 185 | 186 | 187 | 188 |

189 | 190 | 193 | 194 | Instance Method Summary 195 |

196 | 223 | 224 | 225 | 226 | 227 | 228 |
229 | 230 | 231 | 232 |

Instance methods inherited from module RateLimiter::LimiterLike

233 | 234 | 235 | 236 | get(max_wait : Time::Span) : Token | Timeout
get : Token
237 | get
, 238 | 239 | 240 | 241 | get!(max_wait : Time::Span) : Token
get! : Token
242 | get!
, 243 | 244 | 245 | 246 | get? : Token? 247 | get? 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 |
280 | 281 | 282 |

283 | 284 | 287 | 288 | Constructor Detail 289 |

290 | 291 |
292 |
293 | 294 | def self.new(rate : Float64, max_burst : Int32 = 1) 295 | 296 | # 297 |
298 | 299 |
300 |
301 | 302 |
303 |
304 | 305 | 306 | 307 | 308 | 309 | 310 |

311 | 312 | 315 | 316 | Instance Method Detail 317 |

318 | 319 |
320 |
321 | 322 | def bucket : Channel(Nil) 323 | 324 | # 325 |
326 | 327 |
328 |
329 | 330 |
331 |
332 | 333 |
334 |
335 | 336 | def get(max_wait : Time::Span) : Token | Timeout 337 | 338 | # 339 |
340 | 341 |
342 | 343 |
344 | Description copied from module RateLimiter::LimiterLike 345 |
346 | 347 |

Returns a Token if one is available within max_wait time, 348 | otherwise it returns a Timeout. Blocking.

349 |
350 | 351 |
352 |
353 | 354 |
355 |
356 | 357 |
358 |
359 | 360 | def get : Token 361 | 362 | # 363 |
364 | 365 |
366 | 367 |
368 | Description copied from module RateLimiter::LimiterLike 369 |
370 | 371 |

Returns a Token as soon as available. Blocking.

372 |
373 | 374 |
375 |
376 | 377 |
378 |
379 | 380 |
381 |
382 | 383 | def rate : Float64 384 | 385 | # 386 |
387 | 388 |
389 |
390 | 391 |
392 |
393 | 394 | 395 | 396 | 397 | 398 |
399 | 400 | 401 | 402 | -------------------------------------------------------------------------------- /docs/RateLimiter/LimiterLike.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | RateLimiter::LimiterLike - rate_limiter main-dev 17 | 20 | 21 | 22 | 23 | 28 | 93 | 94 | 95 |
96 |

97 | 98 | module RateLimiter::LimiterLike 99 | 100 |

101 | 102 | 103 | 104 | 105 | 106 |

107 | 108 | 111 | 112 | Overview 113 |

114 | 115 |

Defines the API for a rate-limiter-like instance.

116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 |

128 | 129 | 132 | 133 | Direct including types 134 |

135 | 142 | 143 | 144 | 145 | 146 |

147 | 148 | 151 | 152 | Defined in: 153 |

154 | 155 | 156 | rate_limiter.cr 157 | 158 |
159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 |

170 | 171 | 174 | 175 | Instance Method Summary 176 |

177 | 215 | 216 | 217 | 218 | 219 | 220 |
221 | 222 |
223 | 224 | 225 | 226 | 227 | 228 | 229 |

230 | 231 | 234 | 235 | Instance Method Detail 236 |

237 | 238 |
239 |
240 | abstract 241 | def get(max_wait : Time::Span) : Token | Timeout 242 | 243 | # 244 |
245 | 246 |
247 | 248 |

Returns a Token if one is available within max_wait time, 249 | otherwise it returns a Timeout. Blocking.

250 |
251 | 252 |
253 |
254 | 255 |
256 |
257 | 258 |
259 |
260 | abstract 261 | def get : Token 262 | 263 | # 264 |
265 | 266 |
267 | 268 |

Returns a Token as soon as available. Blocking.

269 |
270 | 271 |
272 |
273 | 274 |
275 |
276 | 277 |
278 |
279 | 280 | def get!(max_wait : Time::Span) : Token 281 | 282 | # 283 |
284 | 285 |
286 | 287 |

Raises RateLimiter::Timeout if no token is available after the given 288 | time span. Blocking for at most a max_wait duration.

289 |
290 | 291 |
292 |
293 | 294 |
295 |
296 | 297 |
298 |
299 | 300 | def get! : Token 301 | 302 | # 303 |
304 | 305 |
306 | 307 |

Raises RateLimiter::Timeout if no token is available at call time. Non-blocking.

308 |
309 | 310 |
311 |
312 | 313 |
314 |
315 | 316 |
317 |
318 | 319 | def get? : Token? 320 | 321 | # 322 |
323 | 324 |
325 | 326 |

Returns nil if no token is available at call time. Non-blocking.

327 |
328 | 329 |
330 |
331 | 332 |
333 |
334 | 335 | 336 | 337 | 338 | 339 |
340 | 341 | 342 | 343 | -------------------------------------------------------------------------------- /docs/RateLimiter/MultiLimiter.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | RateLimiter::MultiLimiter - rate_limiter main-dev 17 | 20 | 21 | 22 | 23 | 28 | 93 | 94 | 95 |
96 |

97 | 98 | class RateLimiter::MultiLimiter 99 | 100 |

101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 |

109 | 110 | 113 | 114 | Overview 115 |

116 | 117 |

A rate limiter combining multiple Limiters.

118 | 119 |

A MultiLimter tries to acquire tokens from limiters producing at the lowest rate first. 120 | This mitigates the scenario where tokens are acquired and then wasted due to a single rate limiter timing out.

121 | 122 | 123 | 124 | 125 | 126 |

127 | 128 | 131 | 132 | Included Modules 133 |

134 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 |

150 | 151 | 154 | 155 | Defined in: 156 |

157 | 158 | 159 | rate_limiter.cr 160 | 161 |
162 | 163 | 164 | 165 | 166 | 167 | 168 |

169 | 170 | 173 | 174 | Constructors 175 |

176 | 184 | 185 | 186 | 187 | 188 | 189 |

190 | 191 | 194 | 195 | Instance Method Summary 196 |

197 | 214 | 215 | 216 | 217 | 218 | 219 |
220 | 221 | 222 | 223 |

Instance methods inherited from module RateLimiter::LimiterLike

224 | 225 | 226 | 227 | get(max_wait : Time::Span) : Token | Timeout
get : Token
228 | get
, 229 | 230 | 231 | 232 | get!(max_wait : Time::Span) : Token
get! : Token
233 | get!
, 234 | 235 | 236 | 237 | get? : Token? 238 | get? 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 |
271 | 272 | 273 |

274 | 275 | 278 | 279 | Constructor Detail 280 |

281 | 282 |
283 |
284 | 285 | def self.new(*rate_limiters : Limiter) 286 | 287 | # 288 |
289 | 290 |
291 |
292 | 293 |
294 |
295 | 296 | 297 | 298 | 299 | 300 | 301 |

302 | 303 | 306 | 307 | Instance Method Detail 308 |

309 | 310 |
311 |
312 | 313 | def get(max_wait : Time::Span) : Token | Timeout 314 | 315 | # 316 |
317 | 318 |
319 | 320 |
321 | Description copied from module RateLimiter::LimiterLike 322 |
323 | 324 |

Returns a Token if one is available within max_wait time, 325 | otherwise it returns a Timeout. Blocking.

326 |
327 | 328 |
329 |
330 | 331 |
332 |
333 | 334 |
335 |
336 | 337 | def get : Token 338 | 339 | # 340 |
341 | 342 |
343 | 344 |
345 | Description copied from module RateLimiter::LimiterLike 346 |
347 | 348 |

Returns a Token as soon as available. Blocking.

349 |
350 | 351 |
352 |
353 | 354 |
355 |
356 | 357 | 358 | 359 | 360 | 361 |
362 | 363 | 364 | 365 | -------------------------------------------------------------------------------- /docs/RateLimiter/Timeout.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | RateLimiter::Timeout - rate_limiter main-dev 17 | 20 | 21 | 22 | 23 | 28 | 93 | 94 | 95 |
96 |

97 | 98 | class RateLimiter::Timeout 99 | 100 |

101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 |

109 | 110 | 113 | 114 | Overview 115 |

116 | 117 |

Returned or raised whenever a Token is not available within a given time constraint.

118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 |

133 | 134 | 137 | 138 | Defined in: 139 |

140 | 141 | 142 | rate_limiter.cr 143 | 144 |
145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 |
160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 |
192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 |
202 | 203 | 204 | 205 | -------------------------------------------------------------------------------- /docs/RateLimiter/Token.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | RateLimiter::Token - rate_limiter main-dev 17 | 20 | 21 | 22 | 23 | 28 | 93 | 94 | 95 |
96 |

97 | 98 | class RateLimiter::Token 99 | 100 |

101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 |

109 | 110 | 113 | 114 | Overview 115 |

116 | 117 |

Represents the availability of capacity to perform operations in the current time bucket.

118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 |

133 | 134 | 137 | 138 | Defined in: 139 |

140 | 141 | 142 | rate_limiter.cr 143 | 144 |
145 | 146 | 147 | 148 | 149 | 150 | 151 |

152 | 153 | 156 | 157 | Constructors 158 |

159 | 167 | 168 | 169 | 170 | 171 | 172 |

173 | 174 | 177 | 178 | Instance Method Summary 179 |

180 | 193 | 194 | 195 | 196 | 197 | 198 |
199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 |
221 | 222 | 223 |

224 | 225 | 228 | 229 | Constructor Detail 230 |

231 | 232 |
233 |
234 | 235 | def self.new(created_at = Time.utc) 236 | 237 | # 238 |
239 | 240 |
241 |
242 | 243 |
244 |
245 | 246 | 247 | 248 | 249 | 250 | 251 |

252 | 253 | 256 | 257 | Instance Method Detail 258 |

259 | 260 |
261 |
262 | 263 | def created_at : Time 264 | 265 | # 266 |
267 | 268 |
269 |
270 | 271 |
272 |
273 | 274 |
275 |
276 | 277 | def to_s(io) 278 | 279 | # 280 |
281 | 282 |
283 |
284 | 285 |
286 |
287 | 288 | 289 | 290 | 291 | 292 |
293 | 294 | 295 | 296 | -------------------------------------------------------------------------------- /docs/css/style.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | background: #FFFFFF; 3 | position: relative; 4 | margin: 0; 5 | padding: 0; 6 | width: 100%; 7 | height: 100%; 8 | overflow: hidden; 9 | } 10 | 11 | body { 12 | font-family: "Avenir", "Tahoma", "Lucida Sans", "Lucida Grande", Verdana, Arial, sans-serif; 13 | color: #333; 14 | line-height: 1.5; 15 | } 16 | 17 | a { 18 | color: #263F6C; 19 | } 20 | 21 | a:visited { 22 | color: #112750; 23 | } 24 | 25 | h1, h2, h3, h4, h5, h6 { 26 | margin: 35px 0 25px; 27 | color: #444444; 28 | } 29 | 30 | h1.type-name { 31 | color: #47266E; 32 | margin: 20px 0 30px; 33 | background-color: #F8F8F8; 34 | padding: 10px 12px; 35 | border: 1px solid #EBEBEB; 36 | border-radius: 2px; 37 | } 38 | 39 | h2 { 40 | border-bottom: 1px solid #E6E6E6; 41 | padding-bottom: 5px; 42 | } 43 | 44 | body { 45 | display: flex; 46 | } 47 | 48 | .sidebar, .main-content { 49 | overflow: auto; 50 | } 51 | 52 | .sidebar { 53 | width: 30em; 54 | color: #F8F4FD; 55 | background-color: #2E1052; 56 | padding: 0 0 30px; 57 | box-shadow: inset -3px 0 4px rgba(0,0,0,.35); 58 | line-height: 1.2; 59 | z-index: 0; 60 | } 61 | 62 | .sidebar .search-box { 63 | padding: 13px 9px; 64 | } 65 | 66 | .sidebar input { 67 | display: block; 68 | box-sizing: border-box; 69 | margin: 0; 70 | padding: 5px; 71 | font: inherit; 72 | font-family: inherit; 73 | line-height: 1.2; 74 | width: 100%; 75 | border: 0; 76 | outline: 0; 77 | border-radius: 2px; 78 | box-shadow: 0px 3px 5px rgba(0,0,0,.25); 79 | transition: box-shadow .12s; 80 | } 81 | 82 | .sidebar input:focus { 83 | box-shadow: 0px 5px 6px rgba(0,0,0,.5); 84 | } 85 | 86 | .sidebar input::-webkit-input-placeholder { /* Chrome/Opera/Safari */ 87 | color: #C8C8C8; 88 | font-size: 14px; 89 | text-indent: 2px; 90 | } 91 | 92 | .sidebar input::-moz-placeholder { /* Firefox 19+ */ 93 | color: #C8C8C8; 94 | font-size: 14px; 95 | text-indent: 2px; 96 | } 97 | 98 | .sidebar input:-ms-input-placeholder { /* IE 10+ */ 99 | color: #C8C8C8; 100 | font-size: 14px; 101 | text-indent: 2px; 102 | } 103 | 104 | .sidebar input:-moz-placeholder { /* Firefox 18- */ 105 | color: #C8C8C8; 106 | font-size: 14px; 107 | text-indent: 2px; 108 | } 109 | 110 | .project-summary { 111 | padding: 9px 15px 30px 30px; 112 | } 113 | 114 | .project-name { 115 | font-size: 1.4rem; 116 | margin: 0; 117 | color: #f4f4f4; 118 | font-weight: 600; 119 | } 120 | 121 | .project-version { 122 | margin-top: 5px; 123 | display: inline-block; 124 | position: relative; 125 | } 126 | 127 | .project-version > form::after { 128 | position: absolute; 129 | right: 0; 130 | top: 0; 131 | content: "\25BC"; 132 | font-size: .6em; 133 | line-height: 1.2rem; 134 | z-index: -1; 135 | } 136 | 137 | .project-versions-nav { 138 | cursor: pointer; 139 | margin: 0; 140 | padding: 0 .9em 0 0; 141 | border: none; 142 | -moz-appearance: none; 143 | -webkit-appearance: none; 144 | appearance: none; 145 | background-color: transparent; 146 | color: inherit; 147 | font-family: inherit; 148 | font-size: inherit; 149 | line-height: inherit; 150 | } 151 | .project-versions-nav:focus { 152 | outline: none; 153 | } 154 | 155 | .project-versions-nav > option { 156 | color: initial; 157 | } 158 | 159 | .sidebar ul { 160 | margin: 0; 161 | padding: 0; 162 | list-style: none outside; 163 | } 164 | 165 | .sidebar li { 166 | display: block; 167 | position: relative; 168 | } 169 | 170 | .types-list li.hide { 171 | display: none; 172 | } 173 | 174 | .sidebar a { 175 | text-decoration: none; 176 | color: inherit; 177 | transition: color .14s; 178 | } 179 | .types-list a { 180 | display: block; 181 | padding: 5px 15px 5px 30px; 182 | } 183 | 184 | .types-list { 185 | display: block; 186 | } 187 | 188 | .sidebar a:focus { 189 | outline: 1px solid #D1B7F1; 190 | } 191 | 192 | .types-list a { 193 | padding: 5px 15px 5px 30px; 194 | } 195 | 196 | .sidebar .current > a, 197 | .sidebar a:hover { 198 | color: #866BA6; 199 | } 200 | 201 | .types-list li ul { 202 | overflow: hidden; 203 | height: 0; 204 | max-height: 0; 205 | transition: 1s ease-in-out; 206 | } 207 | 208 | .types-list li.parent { 209 | padding-left: 30px; 210 | } 211 | 212 | .types-list li.parent::before { 213 | box-sizing: border-box; 214 | content: "▼"; 215 | display: block; 216 | width: 30px; 217 | height: 30px; 218 | position: absolute; 219 | top: 0; 220 | left: 0; 221 | text-align: center; 222 | color: white; 223 | font-size: 8px; 224 | line-height: 30px; 225 | transform: rotateZ(-90deg); 226 | cursor: pointer; 227 | transition: .2s linear; 228 | } 229 | 230 | 231 | .types-list li.parent > a { 232 | padding-left: 0; 233 | } 234 | 235 | .types-list li.parent.open::before { 236 | transform: rotateZ(0); 237 | } 238 | 239 | .types-list li.open > ul { 240 | height: auto; 241 | max-height: 1000em; 242 | } 243 | 244 | .main-content { 245 | padding: 0 30px 30px 30px; 246 | width: 100%; 247 | } 248 | 249 | .kind { 250 | font-size: 60%; 251 | color: #866BA6; 252 | } 253 | 254 | .superclass-hierarchy { 255 | margin: -15px 0 30px 0; 256 | padding: 0; 257 | list-style: none outside; 258 | font-size: 80%; 259 | } 260 | 261 | .superclass-hierarchy .superclass { 262 | display: inline-block; 263 | margin: 0 7px 0 0; 264 | padding: 0; 265 | } 266 | 267 | .superclass-hierarchy .superclass + .superclass::before { 268 | content: "<"; 269 | margin-right: 7px; 270 | } 271 | 272 | .other-types-list li { 273 | display: inline-block; 274 | } 275 | 276 | .other-types-list, 277 | .list-summary { 278 | margin: 0 0 30px 0; 279 | padding: 0; 280 | list-style: none outside; 281 | } 282 | 283 | .entry-const { 284 | font-family: Menlo, Monaco, Consolas, 'Courier New', Courier, monospace; 285 | } 286 | 287 | .entry-const code { 288 | white-space: pre-wrap; 289 | } 290 | 291 | .entry-summary { 292 | padding-bottom: 4px; 293 | } 294 | 295 | .superclass-hierarchy .superclass a, 296 | .other-type a, 297 | .entry-summary .signature { 298 | padding: 4px 8px; 299 | margin-bottom: 4px; 300 | display: inline-block; 301 | background-color: #f8f8f8; 302 | color: #47266E; 303 | border: 1px solid #f0f0f0; 304 | text-decoration: none; 305 | border-radius: 3px; 306 | font-family: Menlo, Monaco, Consolas, 'Courier New', Courier, monospace; 307 | transition: background .15s, border-color .15s; 308 | } 309 | 310 | .superclass-hierarchy .superclass a:hover, 311 | .other-type a:hover, 312 | .entry-summary .signature:hover { 313 | background: #D5CAE3; 314 | border-color: #624288; 315 | } 316 | 317 | .entry-summary .summary { 318 | padding-left: 32px; 319 | } 320 | 321 | .entry-summary .summary p { 322 | margin: 12px 0 16px; 323 | } 324 | 325 | .entry-summary a { 326 | text-decoration: none; 327 | } 328 | 329 | .entry-detail { 330 | padding: 30px 0; 331 | } 332 | 333 | .entry-detail .signature { 334 | position: relative; 335 | padding: 5px 15px; 336 | margin-bottom: 10px; 337 | display: block; 338 | border-radius: 5px; 339 | background-color: #f8f8f8; 340 | color: #47266E; 341 | border: 1px solid #f0f0f0; 342 | font-family: Menlo, Monaco, Consolas, 'Courier New', Courier, monospace; 343 | transition: .2s ease-in-out; 344 | } 345 | 346 | .entry-detail:target .signature { 347 | background-color: #D5CAE3; 348 | border: 1px solid #624288; 349 | } 350 | 351 | .entry-detail .signature .method-permalink { 352 | position: absolute; 353 | top: 0; 354 | left: -35px; 355 | padding: 5px 15px; 356 | text-decoration: none; 357 | font-weight: bold; 358 | color: #624288; 359 | opacity: .4; 360 | transition: opacity .2s; 361 | } 362 | 363 | .entry-detail .signature .method-permalink:hover { 364 | opacity: 1; 365 | } 366 | 367 | .entry-detail:target .signature .method-permalink { 368 | opacity: 1; 369 | } 370 | 371 | .methods-inherited { 372 | padding-right: 10%; 373 | line-height: 1.5em; 374 | } 375 | 376 | .methods-inherited h3 { 377 | margin-bottom: 4px; 378 | } 379 | 380 | .methods-inherited a { 381 | display: inline-block; 382 | text-decoration: none; 383 | color: #47266E; 384 | } 385 | 386 | .methods-inherited a:hover { 387 | text-decoration: underline; 388 | color: #6C518B; 389 | } 390 | 391 | .methods-inherited .tooltip>span { 392 | background: #D5CAE3; 393 | padding: 4px 8px; 394 | border-radius: 3px; 395 | margin: -4px -8px; 396 | } 397 | 398 | .methods-inherited .tooltip * { 399 | color: #47266E; 400 | } 401 | 402 | pre { 403 | padding: 10px 20px; 404 | margin-top: 4px; 405 | border-radius: 3px; 406 | line-height: 1.45; 407 | overflow: auto; 408 | color: #333; 409 | background: #fdfdfd; 410 | font-size: 14px; 411 | border: 1px solid #eee; 412 | } 413 | 414 | code { 415 | font-family: Menlo, Monaco, Consolas, 'Courier New', Courier, monospace; 416 | } 417 | 418 | :not(pre) > code { 419 | background-color: rgba(40,35,30,0.05); 420 | padding: 0.2em 0.4em; 421 | font-size: 85%; 422 | border-radius: 3px; 423 | } 424 | 425 | span.flag { 426 | padding: 2px 4px 1px; 427 | border-radius: 3px; 428 | margin-right: 3px; 429 | font-size: 11px; 430 | border: 1px solid transparent; 431 | } 432 | 433 | span.flag.orange { 434 | background-color: #EE8737; 435 | color: #FCEBDD; 436 | border-color: #EB7317; 437 | } 438 | 439 | span.flag.yellow { 440 | background-color: #E4B91C; 441 | color: #FCF8E8; 442 | border-color: #B69115; 443 | } 444 | 445 | span.flag.green { 446 | background-color: #469C14; 447 | color: #E2F9D3; 448 | border-color: #34700E; 449 | } 450 | 451 | span.flag.red { 452 | background-color: #BF1919; 453 | color: #F9ECEC; 454 | border-color: #822C2C; 455 | } 456 | 457 | span.flag.purple { 458 | background-color: #2E1052; 459 | color: #ECE1F9; 460 | border-color: #1F0B37; 461 | } 462 | 463 | span.flag.lime { 464 | background-color: #a3ff00; 465 | color: #222222; 466 | border-color: #00ff1e; 467 | } 468 | 469 | .tooltip>span { 470 | position: absolute; 471 | opacity: 0; 472 | display: none; 473 | pointer-events: none; 474 | } 475 | 476 | .tooltip:hover>span { 477 | display: inline-block; 478 | opacity: 1; 479 | } 480 | 481 | .c { 482 | color: #969896; 483 | } 484 | 485 | .n { 486 | color: #0086b3; 487 | } 488 | 489 | .t { 490 | color: #0086b3; 491 | } 492 | 493 | .s { 494 | color: #183691; 495 | } 496 | 497 | .i { 498 | color: #7f5030; 499 | } 500 | 501 | .k { 502 | color: #a71d5d; 503 | } 504 | 505 | .o { 506 | color: #a71d5d; 507 | } 508 | 509 | .m { 510 | color: #795da3; 511 | } 512 | 513 | .hidden { 514 | display: none; 515 | } 516 | .search-results { 517 | font-size: 90%; 518 | line-height: 1.3; 519 | } 520 | 521 | .search-results mark { 522 | color: inherit; 523 | background: transparent; 524 | font-weight: bold; 525 | } 526 | .search-result { 527 | padding: 5px 8px 5px 5px; 528 | cursor: pointer; 529 | border-left: 5px solid transparent; 530 | transform: translateX(-3px); 531 | transition: all .2s, background-color 0s, border .02s; 532 | min-height: 3.2em; 533 | } 534 | .search-result.current { 535 | border-left-color: #ddd; 536 | background-color: rgba(200,200,200,0.4); 537 | transform: translateX(0); 538 | transition: all .2s, background-color .5s, border 0s; 539 | } 540 | .search-result.current:hover, 541 | .search-result.current:focus { 542 | border-left-color: #866BA6; 543 | } 544 | .search-result:not(.current):nth-child(2n) { 545 | background-color: rgba(255,255,255,.06); 546 | } 547 | .search-result__title { 548 | font-size: 105%; 549 | word-break: break-all; 550 | line-height: 1.1; 551 | padding: 3px 0; 552 | } 553 | .search-result__title strong { 554 | font-weight: normal; 555 | } 556 | .search-results .search-result__title > a { 557 | padding: 0; 558 | display: block; 559 | } 560 | .search-result__title > a > .args { 561 | color: #dddddd; 562 | font-weight: 300; 563 | transition: inherit; 564 | font-size: 88%; 565 | line-height: 1.2; 566 | letter-spacing: -.02em; 567 | } 568 | .search-result__title > a > .args * { 569 | color: inherit; 570 | } 571 | 572 | .search-result a, 573 | .search-result a:hover { 574 | color: inherit; 575 | } 576 | .search-result:not(.current):hover .search-result__title > a, 577 | .search-result:not(.current):focus .search-result__title > a, 578 | .search-result__title > a:focus { 579 | color: #866BA6; 580 | } 581 | .search-result:not(.current):hover .args, 582 | .search-result:not(.current):focus .args { 583 | color: #6a5a7d; 584 | } 585 | 586 | .search-result__type { 587 | color: #e8e8e8; 588 | font-weight: 300; 589 | } 590 | .search-result__doc { 591 | color: #bbbbbb; 592 | font-size: 90%; 593 | } 594 | .search-result__doc p { 595 | margin: 0; 596 | text-overflow: ellipsis; 597 | display: -webkit-box; 598 | -webkit-box-orient: vertical; 599 | -webkit-line-clamp: 2; 600 | overflow: hidden; 601 | line-height: 1.2em; 602 | max-height: 2.4em; 603 | } 604 | 605 | .js-modal-visible .modal-background { 606 | display: flex; 607 | } 608 | .main-content { 609 | position: relative; 610 | } 611 | .modal-background { 612 | position: absolute; 613 | display: none; 614 | height: 100%; 615 | width: 100%; 616 | background: rgba(120,120,120,.4); 617 | z-index: 100; 618 | align-items: center; 619 | justify-content: center; 620 | } 621 | .usage-modal { 622 | max-width: 90%; 623 | background: #fff; 624 | border: 2px solid #ccc; 625 | border-radius: 9px; 626 | padding: 5px 15px 20px; 627 | min-width: 50%; 628 | color: #555; 629 | position: relative; 630 | transform: scale(.5); 631 | transition: transform 200ms; 632 | } 633 | .js-modal-visible .usage-modal { 634 | transform: scale(1); 635 | } 636 | .usage-modal > .close-button { 637 | position: absolute; 638 | right: 15px; 639 | top: 8px; 640 | color: #aaa; 641 | font-size: 27px; 642 | cursor: pointer; 643 | } 644 | .usage-modal > .close-button:hover { 645 | text-shadow: 2px 2px 2px #ccc; 646 | color: #999; 647 | } 648 | .modal-title { 649 | margin: 0; 650 | text-align: center; 651 | font-weight: normal; 652 | color: #666; 653 | border-bottom: 2px solid #ddd; 654 | padding: 10px; 655 | } 656 | .usage-list { 657 | padding: 0; 658 | margin: 13px; 659 | } 660 | .usage-list > li { 661 | padding: 5px 2px; 662 | overflow: auto; 663 | padding-left: 100px; 664 | min-width: 12em; 665 | } 666 | .usage-modal kbd { 667 | background: #eee; 668 | border: 1px solid #ccc; 669 | border-bottom-width: 2px; 670 | border-radius: 3px; 671 | padding: 3px 8px; 672 | font-family: monospace; 673 | margin-right: 2px; 674 | display: inline-block; 675 | } 676 | .usage-key { 677 | float: left; 678 | clear: left; 679 | margin-left: -100px; 680 | margin-right: 12px; 681 | } 682 | .doc-inherited { 683 | font-weight: bold; 684 | } 685 | 686 | .anchor { 687 | float: left; 688 | padding-right: 4px; 689 | margin-left: -20px; 690 | } 691 | 692 | .main-content .anchor .octicon-link { 693 | width: 16px; 694 | height: 16px; 695 | } 696 | 697 | .main-content .anchor:focus { 698 | outline: none 699 | } 700 | 701 | .main-content h1:hover .anchor, 702 | .main-content h2:hover .anchor, 703 | .main-content h3:hover .anchor, 704 | .main-content h4:hover .anchor, 705 | .main-content h5:hover .anchor, 706 | .main-content h6:hover .anchor { 707 | text-decoration: none 708 | } 709 | 710 | .main-content h1 .octicon-link, 711 | .main-content h2 .octicon-link, 712 | .main-content h3 .octicon-link, 713 | .main-content h4 .octicon-link, 714 | .main-content h5 .octicon-link, 715 | .main-content h6 .octicon-link { 716 | visibility: hidden 717 | } 718 | 719 | .main-content h1:hover .anchor .octicon-link, 720 | .main-content h2:hover .anchor .octicon-link, 721 | .main-content h3:hover .anchor .octicon-link, 722 | .main-content h4:hover .anchor .octicon-link, 723 | .main-content h5:hover .anchor .octicon-link, 724 | .main-content h6:hover .anchor .octicon-link { 725 | visibility: visible 726 | } 727 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | rate_limiter main-dev 17 | 20 | 21 | 22 | 23 | 28 | 93 | 94 | 95 |
96 |

GitHub release 97 | Build Status 98 | License 99 | Docs

100 | 101 |

102 | 105 | rate_limiter

106 | 107 |

This shard provides a Crystal implementation of the token bucket algorithm for rate limiting. You can check out the API documentation here.

108 | 109 |

110 | 113 | Installation

114 | 115 |

1 - Add the dependency to your shard.yml:

116 | 117 |
dependencies:
118 |   rate_limiter:
119 |     github: lbarasti/rate_limiter
120 | 121 |

2 - Run shards install

122 | 123 |

124 | 127 | Usage

128 | 129 |

Remember to import the shard at the top of your file.

130 | 131 |
require "rate_limiter"
132 | 133 |

Now you can initialise a rate limiter that produces 1 token every few seconds

134 | 135 |
rl = RateLimiter.new(interval: 3.seconds)
136 | 137 |

Mind that the first call to #get will return immediately, as buckets are initialised with 1 token, by default.

138 | 139 |
rl.get # => RateLimiter::Token(2020-11-29 20:36:56 UTC)
140 | 141 |

The next call to #get will block for approx. 3 seconds

142 | 143 |
start_time = Time.utc
144 | rl.get
145 | Time.utc - start_time # => 00:00:03.000426843
146 | 147 |

We can also provide a max_wait parameter to #get.

148 | 149 |
rl.get(0.5.seconds)
150 | 151 |

This call will block for at most 0.5 seconds. If a token is not returned within that interval, then a RateLimiter::Timeout is returned.

152 | 153 |

Rate limiters also expose non-blocking methods.

154 | 155 |
rl.get? # returns `nil` if no token is available
156 | 
157 | rl.get! # raises a RateLimiter::Timeout exception if no token is available
158 | 159 |

You can pass #get! a max_wait parameter.

160 | 161 |
rl.get!(1.second)
162 | 163 |

This will raise a RateLimiter::Timeout exception if no token is returned within a 1 second interval.

164 | 165 |

166 | 169 | Burst size

170 | 171 |

You can define a rate limiter that accumulates unused tokens up to the specified value by providing a max_burst parameter to RateLimiter.new - the default is 1.

172 | 173 |
RateLimiter.new(rate: 0.5, max_burst: 10)
174 | 175 |

This will generate 1 token every 2 seconds and store up to 10 unused tokens for later use. See Wikipedia's Burst size for more details.

176 | 177 |

178 | 181 | Multi-limiters

182 | 183 |

In the scenario where a part of your code needs to abide to two or more rate limits, you can combine multiple rate limiters into a MultiLimiter.

184 | 185 |
api_limiter = RateLimiter.new(rate: 10, max_burst: 60)
186 | db_limiter = RateLimiter.new(rate: 100)
187 | multi = RateLimiter::MultiLimiter.new(api_limiter, db_limiter)
188 | 189 |

You can also use the convenience constructor on the RateLimiter module.

190 | 191 |
multi = RateLimiter.new(api_limiter, db_limiter)
192 | 193 |

A MultiLimiter exposes the same API as a regular Limiter - they both include the LimiterLike module - so you can call the same flavours of #get methods on it.

194 | 195 |

When calling get on a MultiLimiter, it will try to acquire tokens from each one of the underlying rate limiters, and only return a token then.

196 | 197 |

198 | 201 | Under the hood

202 | 203 |

A rate limiter produces one token in each interval. If the bucket has no more room available, then no token will be added for the interval.

204 | 205 |

206 | 209 | Why do I need a rate limiter?

210 | 211 | 215 | 216 |

217 | 220 | Development

221 | 222 |

Run the following to run the tests.

223 | 224 |
crystal spec
225 | 226 |

227 | 230 | Contributing

231 | 232 |
  1. Fork it (<https://github.com/lbarasti/rate_limiter/fork>)
  2. Create your feature branch (git checkout -b my-new-feature)
  3. Commit your changes (git commit -am 'Add some feature')
  4. Push to the branch (git push origin my-new-feature)
  5. Create a new Pull Request
233 | 234 |

235 | 238 | Contributors

239 | 240 | 241 |
242 | 243 | 244 | -------------------------------------------------------------------------------- /docs/index.json: -------------------------------------------------------------------------------- 1 | {"repository_name":"rate_limiter","body":"![GitHub release](https://img.shields.io/github/release/lbarasti/rate_limiter.svg)\n![Build Status](https://github.com/lbarasti/rate_limiter/workflows/Crystal%20spec/badge.svg)\n[![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://opensource.org/licenses/MIT)\n[![Docs](https://img.shields.io/badge/docs-available-brightgreen.svg)](https://lbarasti.github.io/rate_limiter/docs)\n\n# rate_limiter\n\nThis shard provides a Crystal implementation of the [token bucket](https://en.wikipedia.org/wiki/Token_bucket) algorithm for rate limiting. You can check out the API documentation [here](https://lbarasti.com/rate_limiter/).\n\n## Installation\n\n1 - Add the dependency to your `shard.yml`:\n\n```yaml\ndependencies:\n rate_limiter:\n github: lbarasti/rate_limiter\n```\n\n2 - Run `shards install`\n\n## Usage\n\nRemember to import the shard at the top of your file.\n```crystal\nrequire \"rate_limiter\"\n```\n\nNow you can initialise a rate limiter that produces 1 token every few seconds\n```crystal\nrl = RateLimiter.new(interval: 3.seconds)\n```\n\nMind that the first call to `#get` will return immediately, as buckets are initialised with 1 token, by default.\n```crystal\nrl.get # => RateLimiter::Token(2020-11-29 20:36:56 UTC)\n```\n\nThe next call to `#get` will block for approx. 3 seconds\n```crystal\nstart_time = Time.utc\nrl.get\nTime.utc - start_time # => 00:00:03.000426843\n```\n\nWe can also provide a `max_wait` parameter to `#get`.\n```crystal\nrl.get(0.5.seconds)\n```\nThis call will block for at most 0.5 seconds. If a token is not returned within that interval, then a `RateLimiter::Timeout` is returned.\n\nRate limiters also expose non-blocking methods.\n```crystal\nrl.get? # returns `nil` if no token is available\n\nrl.get! # raises a RateLimiter::Timeout exception if no token is available\n```\n\nYou can pass `#get!` a `max_wait` parameter.\n```crystal\nrl.get!(1.second)\n```\nThis will raise a `RateLimiter::Timeout` exception if no token is returned within a 1 second interval.\n\n### Burst size\nYou can define a rate limiter that accumulates unused tokens up to the specified value by providing a `max_burst` parameter to `RateLimiter.new` - the default is 1.\n```crystal\nRateLimiter.new(rate: 0.5, max_burst: 10)\n```\nThis will generate 1 token every 2 seconds and store up to 10 unused tokens for later use. See Wikipedia's [Burst size](https://en.wikipedia.org/wiki/Token_bucket#Burst_size) for more details.\n\n### Multi-limiters\nIn the scenario where a part of your code needs to abide to two or more rate limits, you can combine multiple rate limiters into a `MultiLimiter`.\n\n```crystal\napi_limiter = RateLimiter.new(rate: 10, max_burst: 60)\ndb_limiter = RateLimiter.new(rate: 100)\nmulti = RateLimiter::MultiLimiter.new(api_limiter, db_limiter)\n```\n\nYou can also use the convenience constructor on the `RateLimiter` module.\n\n```crystal\nmulti = RateLimiter.new(api_limiter, db_limiter)\n```\n\nA `MultiLimiter` exposes the same API as a regular `Limiter` - they both include the `LimiterLike` module - so you can call the same flavours of `#get` methods on it.\n\nWhen calling `get` on a `MultiLimiter`, it will try to acquire tokens from each one of the underlying rate limiters, and only return a token then.\n\n## Under the hood\n![A rate limiter produces one token in each interval. If the bucket has no more room available, then no token will be added for the interval.](./media/diagram_1.png)\n\n## Why do I need a rate limiter?\n* We're calling an API that throttles us when we\n call it too frequently, and we'd rather avoid that.\n* We are exposing an API to customers and want to\n ensure we don't get flooded with requests. For example, we might want to rate limit calls by client id, so that one misbehaving client will not affect the others.\n* One of our ETL stages talks to a datastore that limits the number of requests per second we can send.\n* We have to run a database migration in production and we don't\n want to affect the responsiveness of the service.\n\n## Development\n\nRun the following to run the tests.\n```\ncrystal spec\n```\n\n## Contributing\n\n1. Fork it ()\n2. Create your feature branch (`git checkout -b my-new-feature`)\n3. Commit your changes (`git commit -am 'Add some feature'`)\n4. Push to the branch (`git push origin my-new-feature`)\n5. Create a new Pull Request\n\n## Contributors\n\n- [lbarasti](https://github.com/lbarasti) - creator and maintainer\n","program":{"html_id":"rate_limiter/toplevel","path":"toplevel.html","kind":"module","full_name":"Top Level Namespace","name":"Top Level Namespace","abstract":false,"superclass":null,"ancestors":[],"locations":[],"repository_name":"rate_limiter","program":true,"enum":false,"alias":false,"aliased":null,"aliased_html":null,"const":false,"constants":[],"included_modules":[],"extended_modules":[],"subclasses":[],"including_types":[],"namespace":null,"doc":null,"summary":null,"class_methods":[],"constructors":[],"instance_methods":[],"macros":[],"types":[{"html_id":"rate_limiter/RateLimiter","path":"RateLimiter.html","kind":"module","full_name":"RateLimiter","name":"RateLimiter","abstract":false,"superclass":null,"ancestors":[],"locations":[{"filename":"src/rate_limiter.cr","line_number":2,"url":null}],"repository_name":"rate_limiter","program":false,"enum":false,"alias":false,"aliased":null,"aliased_html":null,"const":false,"constants":[{"id":"VERSION","name":"VERSION","value":"\"1.0.1\"","doc":null,"summary":null}],"included_modules":[],"extended_modules":[],"subclasses":[],"including_types":[],"namespace":null,"doc":"Rate limiting functionality.","summary":"

Rate limiting functionality.

","class_methods":[],"constructors":[{"id":"new(rate:Float64,max_burst:Int32=1)-class-method","html_id":"new(rate:Float64,max_burst:Int32=1)-class-method","name":"new","doc":"Creates a new `Limiter`.\n`rate`: the rate of tokens being produced in tokens/second.\n`max_burst`: maximum number of tokens that can be stored in the bucket.","summary":"

Creates a new Limiter.

","abstract":false,"args":[{"name":"rate","doc":null,"default_value":"","external_name":"rate","restriction":"Float64"},{"name":"max_burst","doc":null,"default_value":"1","external_name":"max_burst","restriction":"Int32"}],"args_string":"(rate : Float64, max_burst : Int32 = 1)","args_html":"(rate : Float64, max_burst : Int32 = 1)","location":{"filename":"src/rate_limiter.cr","line_number":7,"url":null},"def":{"name":"new","args":[{"name":"rate","doc":null,"default_value":"","external_name":"rate","restriction":"Float64"},{"name":"max_burst","doc":null,"default_value":"1","external_name":"max_burst","restriction":"Int32"}],"double_splat":null,"splat_index":null,"yields":null,"block_arg":null,"return_type":"","visibility":"Public","body":"Limiter.new(rate, max_burst)"}},{"id":"new(interval:Time::Span,max_burst:Int32=1)-class-method","html_id":"new(interval:Time::Span,max_burst:Int32=1)-class-method","name":"new","doc":"Creates a new `Limiter`.\n`interval`: the interval at which new tokens are generated.\n`max_burst`: maximum number of tokens that can be stored in the bucket.","summary":"

Creates a new Limiter.

","abstract":false,"args":[{"name":"interval","doc":null,"default_value":"","external_name":"interval","restriction":"Time::Span"},{"name":"max_burst","doc":null,"default_value":"1","external_name":"max_burst","restriction":"Int32"}],"args_string":"(interval : Time::Span, max_burst : Int32 = 1)","args_html":"(interval : Time::Span, max_burst : Int32 = 1)","location":{"filename":"src/rate_limiter.cr","line_number":14,"url":null},"def":{"name":"new","args":[{"name":"interval","doc":null,"default_value":"","external_name":"interval","restriction":"Time::Span"},{"name":"max_burst","doc":null,"default_value":"1","external_name":"max_burst","restriction":"Int32"}],"double_splat":null,"splat_index":null,"yields":null,"block_arg":null,"return_type":"","visibility":"Public","body":"rate = 1 / interval.total_seconds\nLimiter.new(rate, max_burst)\n"}},{"id":"new(*limiters:Limiter)-class-method","html_id":"new(*limiters:Limiter)-class-method","name":"new","doc":"Creates a `MultiLimiter`.\n`limiters`: a set of rate limiters.","summary":"

Creates a MultiLimiter.

","abstract":false,"args":[{"name":"limiters","doc":null,"default_value":"","external_name":"limiters","restriction":"Limiter"}],"args_string":"(*limiters : Limiter)","args_html":"(*limiters : Limiter)","location":{"filename":"src/rate_limiter.cr","line_number":21,"url":null},"def":{"name":"new","args":[{"name":"limiters","doc":null,"default_value":"","external_name":"limiters","restriction":"Limiter"}],"double_splat":null,"splat_index":0,"yields":null,"block_arg":null,"return_type":"","visibility":"Public","body":"MultiLimiter.new(*limiters)"}}],"instance_methods":[],"macros":[],"types":[{"html_id":"rate_limiter/RateLimiter/Limiter","path":"RateLimiter/Limiter.html","kind":"class","full_name":"RateLimiter::Limiter","name":"Limiter","abstract":false,"superclass":{"html_id":"rate_limiter/Reference","kind":"class","full_name":"Reference","name":"Reference"},"ancestors":[{"html_id":"rate_limiter/RateLimiter/LimiterLike","kind":"module","full_name":"RateLimiter::LimiterLike","name":"LimiterLike"},{"html_id":"rate_limiter/Reference","kind":"class","full_name":"Reference","name":"Reference"},{"html_id":"rate_limiter/Object","kind":"class","full_name":"Object","name":"Object"}],"locations":[{"filename":"src/rate_limiter.cr","line_number":83,"url":null}],"repository_name":"rate_limiter","program":false,"enum":false,"alias":false,"aliased":null,"aliased_html":null,"const":false,"constants":[],"included_modules":[{"html_id":"rate_limiter/RateLimiter/LimiterLike","kind":"module","full_name":"RateLimiter::LimiterLike","name":"LimiterLike"}],"extended_modules":[],"subclasses":[],"including_types":[],"namespace":{"html_id":"rate_limiter/RateLimiter","kind":"module","full_name":"RateLimiter","name":"RateLimiter"},"doc":"A rate limiter erogating tokens at the specified rate.\n\nThis is powered by the token bucket algorithm.","summary":"

A rate limiter erogating tokens at the specified rate.

","class_methods":[],"constructors":[{"id":"new(rate:Float64,max_burst:Int32=1)-class-method","html_id":"new(rate:Float64,max_burst:Int32=1)-class-method","name":"new","doc":null,"summary":null,"abstract":false,"args":[{"name":"rate","doc":null,"default_value":"","external_name":"rate","restriction":"Float64"},{"name":"max_burst","doc":null,"default_value":"1","external_name":"max_burst","restriction":"Int32"}],"args_string":"(rate : Float64, max_burst : Int32 = 1)","args_html":"(rate : Float64, max_burst : Int32 = 1)","location":{"filename":"src/rate_limiter.cr","line_number":87,"url":null},"def":{"name":"new","args":[{"name":"rate","doc":null,"default_value":"","external_name":"rate","restriction":"Float64"},{"name":"max_burst","doc":null,"default_value":"1","external_name":"max_burst","restriction":"Int32"}],"double_splat":null,"splat_index":null,"yields":null,"block_arg":null,"return_type":"","visibility":"Public","body":"_ = allocate\n_.initialize(rate, max_burst)\nif _.responds_to?(:finalize)\n ::GC.add_finalizer(_)\nend\n_\n"}}],"instance_methods":[{"id":"bucket:Channel(Nil)-instance-method","html_id":"bucket:Channel(Nil)-instance-method","name":"bucket","doc":null,"summary":null,"abstract":false,"args":[],"args_string":" : Channel(Nil)","args_html":" : Channel(Nil)","location":{"filename":"src/rate_limiter.cr","line_number":85,"url":null},"def":{"name":"bucket","args":[],"double_splat":null,"splat_index":null,"yields":null,"block_arg":null,"return_type":"","visibility":"Public","body":"@bucket"}},{"id":"get(max_wait:Time::Span):Token|Timeout-instance-method","html_id":"get(max_wait:Time::Span):Token|Timeout-instance-method","name":"get","doc":"Returns a `Token` if one is available within `max_wait` time,\notherwise it returns a `Timeout`. Blocking.","summary":"

Returns a Token if one is available within max_wait time, otherwise it returns a Timeout.

","abstract":false,"args":[{"name":"max_wait","doc":null,"default_value":"","external_name":"max_wait","restriction":"Time::Span"}],"args_string":"(max_wait : Time::Span) : Token | Timeout","args_html":"(max_wait : Time::Span) : Token | Timeout","location":{"filename":"src/rate_limiter.cr","line_number":109,"url":null},"def":{"name":"get","args":[{"name":"max_wait","doc":null,"default_value":"","external_name":"max_wait","restriction":"Time::Span"}],"double_splat":null,"splat_index":null,"yields":null,"block_arg":null,"return_type":"Token | Timeout","visibility":"Public","body":"select\nwhen @bucket.receive\n Token.new\nwhen timeout(max_wait)\n Timeout.new\nend\n"}},{"id":"get:Token-instance-method","html_id":"get:Token-instance-method","name":"get","doc":"Returns a `Token` as soon as available. Blocking.","summary":"

Returns a Token as soon as available.

","abstract":false,"args":[],"args_string":" : Token","args_html":" : Token","location":{"filename":"src/rate_limiter.cr","line_number":104,"url":null},"def":{"name":"get","args":[],"double_splat":null,"splat_index":null,"yields":null,"block_arg":null,"return_type":"Token","visibility":"Public","body":"@bucket.receive\nToken.new\n"}},{"id":"rate:Float64-instance-method","html_id":"rate:Float64-instance-method","name":"rate","doc":null,"summary":null,"abstract":false,"args":[],"args_string":" : Float64","args_html":" : Float64","location":{"filename":"src/rate_limiter.cr","line_number":85,"url":null},"def":{"name":"rate","args":[],"double_splat":null,"splat_index":null,"yields":null,"block_arg":null,"return_type":"","visibility":"Public","body":"@rate"}}],"macros":[],"types":[]},{"html_id":"rate_limiter/RateLimiter/LimiterLike","path":"RateLimiter/LimiterLike.html","kind":"module","full_name":"RateLimiter::LimiterLike","name":"LimiterLike","abstract":false,"superclass":null,"ancestors":[],"locations":[{"filename":"src/rate_limiter.cr","line_number":26,"url":null}],"repository_name":"rate_limiter","program":false,"enum":false,"alias":false,"aliased":null,"aliased_html":null,"const":false,"constants":[],"included_modules":[],"extended_modules":[],"subclasses":[],"including_types":[{"html_id":"rate_limiter/RateLimiter/Limiter","kind":"class","full_name":"RateLimiter::Limiter","name":"Limiter"},{"html_id":"rate_limiter/RateLimiter/MultiLimiter","kind":"class","full_name":"RateLimiter::MultiLimiter","name":"MultiLimiter"}],"namespace":{"html_id":"rate_limiter/RateLimiter","kind":"module","full_name":"RateLimiter","name":"RateLimiter"},"doc":"Defines the API for a rate-limiter-like instance.","summary":"

Defines the API for a rate-limiter-like instance.

","class_methods":[],"constructors":[],"instance_methods":[{"id":"get(max_wait:Time::Span):Token|Timeout-instance-method","html_id":"get(max_wait:Time::Span):Token|Timeout-instance-method","name":"get","doc":"Returns a `Token` if one is available within `max_wait` time,\notherwise it returns a `Timeout`. Blocking.","summary":"

Returns a Token if one is available within max_wait time, otherwise it returns a Timeout.

","abstract":true,"args":[{"name":"max_wait","doc":null,"default_value":"","external_name":"max_wait","restriction":"Time::Span"}],"args_string":"(max_wait : Time::Span) : Token | Timeout","args_html":"(max_wait : Time::Span) : Token | Timeout","location":{"filename":"src/rate_limiter.cr","line_number":32,"url":null},"def":{"name":"get","args":[{"name":"max_wait","doc":null,"default_value":"","external_name":"max_wait","restriction":"Time::Span"}],"double_splat":null,"splat_index":null,"yields":null,"block_arg":null,"return_type":"Token | Timeout","visibility":"Public","body":""}},{"id":"get:Token-instance-method","html_id":"get:Token-instance-method","name":"get","doc":"Returns a `Token` as soon as available. Blocking.","summary":"

Returns a Token as soon as available.

","abstract":true,"args":[],"args_string":" : Token","args_html":" : Token","location":{"filename":"src/rate_limiter.cr","line_number":28,"url":null},"def":{"name":"get","args":[],"double_splat":null,"splat_index":null,"yields":null,"block_arg":null,"return_type":"Token","visibility":"Public","body":""}},{"id":"get!(max_wait:Time::Span):Token-instance-method","html_id":"get!(max_wait:Time::Span):Token-instance-method","name":"get!","doc":"Raises `RateLimiter::Timeout` if no token is available after the given\ntime span. Blocking for at most a `max_wait` duration.","summary":"

Raises RateLimiter::Timeout if no token is available after the given time span.

","abstract":false,"args":[{"name":"max_wait","doc":null,"default_value":"","external_name":"max_wait","restriction":"Time::Span"}],"args_string":"(max_wait : Time::Span) : Token","args_html":"(max_wait : Time::Span) : Token","location":{"filename":"src/rate_limiter.cr","line_number":46,"url":null},"def":{"name":"get!","args":[{"name":"max_wait","doc":null,"default_value":"","external_name":"max_wait","restriction":"Time::Span"}],"double_splat":null,"splat_index":null,"yields":null,"block_arg":null,"return_type":"Token","visibility":"Public","body":"case res = get(max_wait: max_wait)\nin Token\n res\nin Timeout\n raise(res)\nend"}},{"id":"get!:Token-instance-method","html_id":"get!:Token-instance-method","name":"get!","doc":"Raises `RateLimiter::Timeout` if no token is available at call time. Non-blocking.","summary":"

Raises RateLimiter::Timeout if no token is available at call time.

","abstract":false,"args":[],"args_string":" : Token","args_html":" : Token","location":{"filename":"src/rate_limiter.cr","line_number":56,"url":null},"def":{"name":"get!","args":[],"double_splat":null,"splat_index":null,"yields":null,"block_arg":null,"return_type":"Token","visibility":"Public","body":"get!(max_wait: 0.seconds)"}},{"id":"get?:Token?-instance-method","html_id":"get?:Token?-instance-method","name":"get?","doc":"Returns `nil` if no token is available at call time. Non-blocking.","summary":"

Returns nil if no token is available at call time.

","abstract":false,"args":[],"args_string":" : Token?","args_html":" : Token?","location":{"filename":"src/rate_limiter.cr","line_number":35,"url":null},"def":{"name":"get?","args":[],"double_splat":null,"splat_index":null,"yields":null,"block_arg":null,"return_type":"Token | Nil","visibility":"Public","body":"case t = get(max_wait: 0.seconds)\nin Token\n t\nin Timeout\n nil\nend"}}],"macros":[],"types":[]},{"html_id":"rate_limiter/RateLimiter/MultiLimiter","path":"RateLimiter/MultiLimiter.html","kind":"class","full_name":"RateLimiter::MultiLimiter","name":"MultiLimiter","abstract":false,"superclass":{"html_id":"rate_limiter/Reference","kind":"class","full_name":"Reference","name":"Reference"},"ancestors":[{"html_id":"rate_limiter/RateLimiter/LimiterLike","kind":"module","full_name":"RateLimiter::LimiterLike","name":"LimiterLike"},{"html_id":"rate_limiter/Reference","kind":"class","full_name":"Reference","name":"Reference"},{"html_id":"rate_limiter/Object","kind":"class","full_name":"Object","name":"Object"}],"locations":[{"filename":"src/rate_limiter.cr","line_number":123,"url":null}],"repository_name":"rate_limiter","program":false,"enum":false,"alias":false,"aliased":null,"aliased_html":null,"const":false,"constants":[],"included_modules":[{"html_id":"rate_limiter/RateLimiter/LimiterLike","kind":"module","full_name":"RateLimiter::LimiterLike","name":"LimiterLike"}],"extended_modules":[],"subclasses":[],"including_types":[],"namespace":{"html_id":"rate_limiter/RateLimiter","kind":"module","full_name":"RateLimiter","name":"RateLimiter"},"doc":"A rate limiter combining multiple `Limiter`s.\n\nA MultiLimter tries to acquire tokens from limiters producing at the lowest rate first.\nThis mitigates the scenario where tokens are acquired and then wasted due to a single rate limiter timing out. ","summary":"

A rate limiter combining multiple Limiters.

","class_methods":[],"constructors":[{"id":"new(*rate_limiters:Limiter)-class-method","html_id":"new(*rate_limiters:Limiter)-class-method","name":"new","doc":null,"summary":null,"abstract":false,"args":[{"name":"rate_limiters","doc":null,"default_value":"","external_name":"rate_limiters","restriction":"Limiter"}],"args_string":"(*rate_limiters : Limiter)","args_html":"(*rate_limiters : Limiter)","location":{"filename":"src/rate_limiter.cr","line_number":128,"url":null},"def":{"name":"new","args":[{"name":"rate_limiters","doc":null,"default_value":"","external_name":"rate_limiters","restriction":"Limiter"}],"double_splat":null,"splat_index":0,"yields":null,"block_arg":null,"return_type":"","visibility":"Public","body":"_ = allocate\n_.initialize(*rate_limiters)\nif _.responds_to?(:finalize)\n ::GC.add_finalizer(_)\nend\n_\n"}}],"instance_methods":[{"id":"get(max_wait:Time::Span):Token|Timeout-instance-method","html_id":"get(max_wait:Time::Span):Token|Timeout-instance-method","name":"get","doc":"Returns a `Token` if one is available within `max_wait` time,\notherwise it returns a `Timeout`. Blocking.","summary":"

Returns a Token if one is available within max_wait time, otherwise it returns a Timeout.

","abstract":false,"args":[{"name":"max_wait","doc":null,"default_value":"","external_name":"max_wait","restriction":"Time::Span"}],"args_string":"(max_wait : Time::Span) : Token | Timeout","args_html":"(max_wait : Time::Span) : Token | Timeout","location":{"filename":"src/rate_limiter.cr","line_number":137,"url":null},"def":{"name":"get","args":[{"name":"max_wait","doc":null,"default_value":"","external_name":"max_wait","restriction":"Time::Span"}],"double_splat":null,"splat_index":null,"yields":null,"block_arg":null,"return_type":"Token | Timeout","visibility":"Public","body":"_, remainder = @rate_limiters.map(&.bucket).reduce({Time.utc, max_wait}) do |__arg3, bucket|\n started_at = __arg3[0]\n time_left = __arg3[1]\n select\nwhen bucket.receive\n new_started_at = Time.utc\n elapsed = new_started_at - started_at\n {new_started_at, time_left - elapsed}\nwhen timeout(time_left)\n break {nil, nil}\nend\n\nend\nremainder.nil? ? Timeout.new : Token.new\n"}},{"id":"get:Token-instance-method","html_id":"get:Token-instance-method","name":"get","doc":"Returns a `Token` as soon as available. Blocking.","summary":"

Returns a Token as soon as available.

","abstract":false,"args":[],"args_string":" : Token","args_html":" : Token","location":{"filename":"src/rate_limiter.cr","line_number":132,"url":null},"def":{"name":"get","args":[],"double_splat":null,"splat_index":null,"yields":null,"block_arg":null,"return_type":"Token","visibility":"Public","body":"@rate_limiters.each(&.get)\nToken.new\n"}}],"macros":[],"types":[]},{"html_id":"rate_limiter/RateLimiter/Timeout","path":"RateLimiter/Timeout.html","kind":"class","full_name":"RateLimiter::Timeout","name":"Timeout","abstract":false,"superclass":{"html_id":"rate_limiter/Exception","kind":"class","full_name":"Exception","name":"Exception"},"ancestors":[{"html_id":"rate_limiter/Exception","kind":"class","full_name":"Exception","name":"Exception"},{"html_id":"rate_limiter/Reference","kind":"class","full_name":"Reference","name":"Reference"},{"html_id":"rate_limiter/Object","kind":"class","full_name":"Object","name":"Object"}],"locations":[{"filename":"src/rate_limiter.cr","line_number":62,"url":null}],"repository_name":"rate_limiter","program":false,"enum":false,"alias":false,"aliased":null,"aliased_html":null,"const":false,"constants":[],"included_modules":[],"extended_modules":[],"subclasses":[],"including_types":[],"namespace":{"html_id":"rate_limiter/RateLimiter","kind":"module","full_name":"RateLimiter","name":"RateLimiter"},"doc":"Returned or raised whenever a `Token` is not available within a given time constraint.","summary":"

Returned or raised whenever a Token is not available within a given time constraint.

","class_methods":[],"constructors":[],"instance_methods":[],"macros":[],"types":[]},{"html_id":"rate_limiter/RateLimiter/Token","path":"RateLimiter/Token.html","kind":"class","full_name":"RateLimiter::Token","name":"Token","abstract":false,"superclass":{"html_id":"rate_limiter/Reference","kind":"class","full_name":"Reference","name":"Reference"},"ancestors":[{"html_id":"rate_limiter/Reference","kind":"class","full_name":"Reference","name":"Reference"},{"html_id":"rate_limiter/Object","kind":"class","full_name":"Object","name":"Object"}],"locations":[{"filename":"src/rate_limiter.cr","line_number":66,"url":null}],"repository_name":"rate_limiter","program":false,"enum":false,"alias":false,"aliased":null,"aliased_html":null,"const":false,"constants":[],"included_modules":[],"extended_modules":[],"subclasses":[],"including_types":[],"namespace":{"html_id":"rate_limiter/RateLimiter","kind":"module","full_name":"RateLimiter","name":"RateLimiter"},"doc":"Represents the availability of capacity to perform operations in the current time bucket.","summary":"

Represents the availability of capacity to perform operations in the current time bucket.

","class_methods":[],"constructors":[{"id":"new(created_at=Time.utc)-class-method","html_id":"new(created_at=Time.utc)-class-method","name":"new","doc":null,"summary":null,"abstract":false,"args":[{"name":"created_at","doc":null,"default_value":"Time.utc","external_name":"created_at","restriction":""}],"args_string":"(created_at = Time.utc)","args_html":"(created_at = Time.utc)","location":{"filename":"src/rate_limiter.cr","line_number":69,"url":null},"def":{"name":"new","args":[{"name":"created_at","doc":null,"default_value":"Time.utc","external_name":"created_at","restriction":""}],"double_splat":null,"splat_index":null,"yields":null,"block_arg":null,"return_type":"","visibility":"Public","body":"_ = allocate\n_.initialize(created_at)\nif _.responds_to?(:finalize)\n ::GC.add_finalizer(_)\nend\n_\n"}}],"instance_methods":[{"id":"created_at:Time-instance-method","html_id":"created_at:Time-instance-method","name":"created_at","doc":null,"summary":null,"abstract":false,"args":[],"args_string":" : Time","args_html":" : Time","location":{"filename":"src/rate_limiter.cr","line_number":67,"url":null},"def":{"name":"created_at","args":[],"double_splat":null,"splat_index":null,"yields":null,"block_arg":null,"return_type":"Time","visibility":"Public","body":"@created_at"}},{"id":"to_s(io)-instance-method","html_id":"to_s(io)-instance-method","name":"to_s","doc":null,"summary":null,"abstract":false,"args":[{"name":"io","doc":null,"default_value":"","external_name":"io","restriction":""}],"args_string":"(io)","args_html":"(io)","location":{"filename":"src/rate_limiter.cr","line_number":73,"url":null},"def":{"name":"to_s","args":[{"name":"io","doc":null,"default_value":"","external_name":"io","restriction":""}],"double_splat":null,"splat_index":null,"yields":null,"block_arg":null,"return_type":"","visibility":"Public","body":"io << \"#{{{ @type }}}(\"\n@created_at.to_s(io)\nio << \")\"\n"}}],"macros":[],"types":[]}]}]}} -------------------------------------------------------------------------------- /docs/js/doc.js: -------------------------------------------------------------------------------- 1 | window.CrystalDocs = (window.CrystalDocs || {}); 2 | 3 | CrystalDocs.base_path = (CrystalDocs.base_path || ""); 4 | 5 | CrystalDocs.searchIndex = (CrystalDocs.searchIndex || false); 6 | CrystalDocs.MAX_RESULTS_DISPLAY = 140; 7 | 8 | CrystalDocs.runQuery = function(query) { 9 | function searchType(type, query, results) { 10 | var matches = []; 11 | var matchedFields = []; 12 | var name = type.full_name; 13 | var i = name.lastIndexOf("::"); 14 | if (i > 0) { 15 | name = name.substring(i + 2); 16 | } 17 | var nameMatches = query.matches(name); 18 | if (nameMatches){ 19 | matches = matches.concat(nameMatches); 20 | matchedFields.push("name"); 21 | } 22 | 23 | var namespaceMatches = query.matchesNamespace(type.full_name); 24 | if(namespaceMatches){ 25 | matches = matches.concat(namespaceMatches); 26 | matchedFields.push("name"); 27 | } 28 | 29 | var docMatches = query.matches(type.doc); 30 | if(docMatches){ 31 | matches = matches.concat(docMatches); 32 | matchedFields.push("doc"); 33 | } 34 | if (matches.length > 0) { 35 | results.push({ 36 | id: type.id, 37 | result_type: "type", 38 | kind: type.kind, 39 | name: name, 40 | full_name: type.full_name, 41 | href: type.path, 42 | summary: type.summary, 43 | matched_fields: matchedFields, 44 | matched_terms: matches 45 | }); 46 | } 47 | 48 | type.instance_methods.forEach(function(method) { 49 | searchMethod(method, type, "instance_method", query, results); 50 | }) 51 | type.class_methods.forEach(function(method) { 52 | searchMethod(method, type, "class_method", query, results); 53 | }) 54 | type.constructors.forEach(function(constructor) { 55 | searchMethod(constructor, type, "constructor", query, results); 56 | }) 57 | type.macros.forEach(function(macro) { 58 | searchMethod(macro, type, "macro", query, results); 59 | }) 60 | type.constants.forEach(function(constant){ 61 | searchConstant(constant, type, query, results); 62 | }); 63 | 64 | type.types.forEach(function(subtype){ 65 | searchType(subtype, query, results); 66 | }); 67 | }; 68 | 69 | function searchMethod(method, type, kind, query, results) { 70 | var matches = []; 71 | var matchedFields = []; 72 | var nameMatches = query.matchesMethod(method.name, kind, type); 73 | if (nameMatches){ 74 | matches = matches.concat(nameMatches); 75 | matchedFields.push("name"); 76 | } 77 | 78 | method.args.forEach(function(arg){ 79 | var argMatches = query.matches(arg.external_name); 80 | if (argMatches) { 81 | matches = matches.concat(argMatches); 82 | matchedFields.push("args"); 83 | } 84 | }); 85 | 86 | var docMatches = query.matches(type.doc); 87 | if(docMatches){ 88 | matches = matches.concat(docMatches); 89 | matchedFields.push("doc"); 90 | } 91 | 92 | if (matches.length > 0) { 93 | var typeMatches = query.matches(type.full_name); 94 | if (typeMatches) { 95 | matchedFields.push("type"); 96 | matches = matches.concat(typeMatches); 97 | } 98 | results.push({ 99 | id: method.id, 100 | type: type.full_name, 101 | result_type: kind, 102 | name: method.name, 103 | full_name: type.full_name + "#" + method.name, 104 | args_string: method.args_string, 105 | summary: method.summary, 106 | href: type.path + "#" + method.id, 107 | matched_fields: matchedFields, 108 | matched_terms: matches 109 | }); 110 | } 111 | } 112 | 113 | function searchConstant(constant, type, query, results) { 114 | var matches = []; 115 | var matchedFields = []; 116 | var nameMatches = query.matches(constant.name); 117 | if (nameMatches){ 118 | matches = matches.concat(nameMatches); 119 | matchedFields.push("name"); 120 | } 121 | var docMatches = query.matches(constant.doc); 122 | if(docMatches){ 123 | matches = matches.concat(docMatches); 124 | matchedFields.push("doc"); 125 | } 126 | if (matches.length > 0) { 127 | var typeMatches = query.matches(type.full_name); 128 | if (typeMatches) { 129 | matchedFields.push("type"); 130 | matches = matches.concat(typeMatches); 131 | } 132 | results.push({ 133 | id: constant.id, 134 | type: type.full_name, 135 | result_type: "constant", 136 | name: constant.name, 137 | full_name: type.full_name + "#" + constant.name, 138 | value: constant.value, 139 | summary: constant.summary, 140 | href: type.path + "#" + constant.id, 141 | matched_fields: matchedFields, 142 | matched_terms: matches 143 | }); 144 | } 145 | } 146 | 147 | var results = []; 148 | searchType(CrystalDocs.searchIndex.program, query, results); 149 | return results; 150 | }; 151 | 152 | CrystalDocs.rankResults = function(results, query) { 153 | function uniqueArray(ar) { 154 | var j = {}; 155 | 156 | ar.forEach(function(v) { 157 | j[v + "::" + typeof v] = v; 158 | }); 159 | 160 | return Object.keys(j).map(function(v) { 161 | return j[v]; 162 | }); 163 | } 164 | 165 | results = results.sort(function(a, b) { 166 | var matchedTermsDiff = uniqueArray(b.matched_terms).length - uniqueArray(a.matched_terms).length; 167 | var aHasDocs = b.matched_fields.includes("doc"); 168 | var bHasDocs = b.matched_fields.includes("doc"); 169 | 170 | var aOnlyDocs = aHasDocs && a.matched_fields.length == 1; 171 | var bOnlyDocs = bHasDocs && b.matched_fields.length == 1; 172 | 173 | if (a.result_type == "type" && b.result_type != "type" && !aOnlyDocs) { 174 | if(CrystalDocs.DEBUG) { console.log("a is type b not"); } 175 | return -1; 176 | } else if (b.result_type == "type" && a.result_type != "type" && !bOnlyDocs) { 177 | if(CrystalDocs.DEBUG) { console.log("b is type, a not"); } 178 | return 1; 179 | } 180 | if (a.matched_fields.includes("name")) { 181 | if (b.matched_fields.includes("name")) { 182 | var a_name = (CrystalDocs.prefixForType(a.result_type) || "") + ((a.result_type == "type") ? a.full_name : a.name); 183 | var b_name = (CrystalDocs.prefixForType(b.result_type) || "") + ((b.result_type == "type") ? b.full_name : b.name); 184 | a_name = a_name.toLowerCase(); 185 | b_name = b_name.toLowerCase(); 186 | for(var i = 0; i < query.normalizedTerms.length; i++) { 187 | var term = query.terms[i].replace(/^::?|::?$/, ""); 188 | var a_orig_index = a_name.indexOf(term); 189 | var b_orig_index = b_name.indexOf(term); 190 | if(CrystalDocs.DEBUG) { console.log("term: " + term + " a: " + a_name + " b: " + b_name); } 191 | if(CrystalDocs.DEBUG) { console.log(a_orig_index, b_orig_index, a_orig_index - b_orig_index); } 192 | if (a_orig_index >= 0) { 193 | if (b_orig_index >= 0) { 194 | if(CrystalDocs.DEBUG) { console.log("both have exact match", a_orig_index > b_orig_index ? -1 : 1); } 195 | if(a_orig_index != b_orig_index) { 196 | if(CrystalDocs.DEBUG) { console.log("both have exact match at different positions", a_orig_index > b_orig_index ? 1 : -1); } 197 | return a_orig_index > b_orig_index ? 1 : -1; 198 | } 199 | } else { 200 | if(CrystalDocs.DEBUG) { console.log("a has exact match, b not"); } 201 | return -1; 202 | } 203 | } else if (b_orig_index >= 0) { 204 | if(CrystalDocs.DEBUG) { console.log("b has exact match, a not"); } 205 | return 1; 206 | } 207 | } 208 | } else { 209 | if(CrystalDocs.DEBUG) { console.log("a has match in name, b not"); } 210 | return -1; 211 | } 212 | } else if ( 213 | !a.matched_fields.includes("name") && 214 | b.matched_fields.includes("name") 215 | ) { 216 | return 1; 217 | } 218 | 219 | if (matchedTermsDiff != 0 || (aHasDocs != bHasDocs)) { 220 | if(CrystalDocs.DEBUG) { console.log("matchedTermsDiff: " + matchedTermsDiff, aHasDocs, bHasDocs); } 221 | return matchedTermsDiff; 222 | } 223 | 224 | var matchedFieldsDiff = b.matched_fields.length - a.matched_fields.length; 225 | if (matchedFieldsDiff != 0) { 226 | if(CrystalDocs.DEBUG) { console.log("matched to different number of fields: " + matchedFieldsDiff); } 227 | return matchedFieldsDiff > 0 ? 1 : -1; 228 | } 229 | 230 | var nameCompare = a.name.localeCompare(b.name); 231 | if(nameCompare != 0){ 232 | if(CrystalDocs.DEBUG) { console.log("nameCompare resulted in: " + a.name + "<=>" + b.name + ": " + nameCompare); } 233 | return nameCompare > 0 ? 1 : -1; 234 | } 235 | 236 | if(a.matched_fields.includes("args") && b.matched_fields.includes("args")) { 237 | for(var i = 0; i < query.terms.length; i++) { 238 | var term = query.terms[i]; 239 | var aIndex = a.args_string.indexOf(term); 240 | var bIndex = b.args_string.indexOf(term); 241 | if(CrystalDocs.DEBUG) { console.log("index of " + term + " in args_string: " + aIndex + " - " + bIndex); } 242 | if(aIndex >= 0){ 243 | if(bIndex >= 0){ 244 | if(aIndex != bIndex){ 245 | return aIndex > bIndex ? 1 : -1; 246 | } 247 | }else{ 248 | return -1; 249 | } 250 | }else if(bIndex >= 0) { 251 | return 1; 252 | } 253 | } 254 | } 255 | 256 | return 0; 257 | }); 258 | 259 | if (results.length > 1) { 260 | // if we have more than two search terms, only include results with the most matches 261 | var bestMatchedTerms = uniqueArray(results[0].matched_terms).length; 262 | 263 | results = results.filter(function(result) { 264 | return uniqueArray(result.matched_terms).length + 1 >= bestMatchedTerms; 265 | }); 266 | } 267 | return results; 268 | }; 269 | 270 | CrystalDocs.prefixForType = function(type) { 271 | switch (type) { 272 | case "instance_method": 273 | return "#"; 274 | 275 | case "class_method": 276 | case "macro": 277 | case "constructor": 278 | return "."; 279 | 280 | default: 281 | return false; 282 | } 283 | }; 284 | 285 | CrystalDocs.displaySearchResults = function(results, query) { 286 | function sanitize(html){ 287 | return html.replace(/<(?!\/?code)[^>]+>/g, ""); 288 | } 289 | 290 | // limit results 291 | if (results.length > CrystalDocs.MAX_RESULTS_DISPLAY) { 292 | results = results.slice(0, CrystalDocs.MAX_RESULTS_DISPLAY); 293 | } 294 | 295 | var $frag = document.createDocumentFragment(); 296 | var $resultsElem = document.querySelector(".search-list"); 297 | $resultsElem.innerHTML = ""; 298 | 299 | results.forEach(function(result, i) { 300 | var url = CrystalDocs.base_path + result.href; 301 | var type = false; 302 | 303 | var title = query.highlight(result.result_type == "type" ? result.full_name : result.name); 304 | 305 | var prefix = CrystalDocs.prefixForType(result.result_type); 306 | if (prefix) { 307 | title = "" + prefix + "" + title; 308 | } 309 | 310 | title = "" + title + ""; 311 | 312 | if (result.args_string) { 313 | title += 314 | "" + query.highlight(result.args_string) + ""; 315 | } 316 | 317 | $elem = document.createElement("li"); 318 | $elem.className = "search-result search-result--" + result.result_type; 319 | $elem.dataset.href = url; 320 | $elem.setAttribute("title", result.full_name + " docs page"); 321 | 322 | var $title = document.createElement("div"); 323 | $title.setAttribute("class", "search-result__title"); 324 | var $titleLink = document.createElement("a"); 325 | $titleLink.setAttribute("href", url); 326 | 327 | $titleLink.innerHTML = title; 328 | $title.appendChild($titleLink); 329 | $elem.appendChild($title); 330 | $elem.addEventListener("click", function() { 331 | $titleLink.click(); 332 | }); 333 | 334 | if (result.result_type !== "type") { 335 | var $type = document.createElement("div"); 336 | $type.setAttribute("class", "search-result__type"); 337 | $type.innerHTML = query.highlight(result.type); 338 | $elem.appendChild($type); 339 | } 340 | 341 | if(result.summary){ 342 | var $doc = document.createElement("div"); 343 | $doc.setAttribute("class", "search-result__doc"); 344 | $doc.innerHTML = query.highlight(sanitize(result.summary)); 345 | $elem.appendChild($doc); 346 | } 347 | 348 | $elem.appendChild(document.createComment(JSON.stringify(result))); 349 | $frag.appendChild($elem); 350 | }); 351 | 352 | $resultsElem.appendChild($frag); 353 | 354 | CrystalDocs.toggleResultsList(true); 355 | }; 356 | 357 | CrystalDocs.toggleResultsList = function(visible) { 358 | if (visible) { 359 | document.querySelector(".types-list").classList.add("hidden"); 360 | document.querySelector(".search-results").classList.remove("hidden"); 361 | } else { 362 | document.querySelector(".types-list").classList.remove("hidden"); 363 | document.querySelector(".search-results").classList.add("hidden"); 364 | } 365 | }; 366 | 367 | CrystalDocs.Query = function(string) { 368 | this.original = string; 369 | this.terms = string.split(/\s+/).filter(function(word) { 370 | return CrystalDocs.Query.stripModifiers(word).length > 0; 371 | }); 372 | 373 | var normalized = this.terms.map(CrystalDocs.Query.normalizeTerm); 374 | this.normalizedTerms = normalized; 375 | 376 | function runMatcher(field, matcher) { 377 | if (!field) { 378 | return false; 379 | } 380 | var normalizedValue = CrystalDocs.Query.normalizeTerm(field); 381 | 382 | var matches = []; 383 | normalized.forEach(function(term) { 384 | if (matcher(normalizedValue, term)) { 385 | matches.push(term); 386 | } 387 | }); 388 | return matches.length > 0 ? matches : false; 389 | } 390 | 391 | this.matches = function(field) { 392 | return runMatcher(field, function(normalized, term) { 393 | if (term[0] == "#" || term[0] == ".") { 394 | return false; 395 | } 396 | return normalized.indexOf(term) >= 0; 397 | }); 398 | }; 399 | 400 | function namespaceMatcher(normalized, term){ 401 | var i = term.indexOf(":"); 402 | if(i >= 0){ 403 | term = term.replace(/^::?|::?$/, ""); 404 | var index = normalized.indexOf(term); 405 | if((index == 0) || (index > 0 && normalized[index-1] == ":")){ 406 | return true; 407 | } 408 | } 409 | return false; 410 | } 411 | this.matchesMethod = function(name, kind, type) { 412 | return runMatcher(name, function(normalized, term) { 413 | var i = term.indexOf("#"); 414 | if(i >= 0){ 415 | if (kind != "instance_method") { 416 | return false; 417 | } 418 | }else{ 419 | i = term.indexOf("."); 420 | if(i >= 0){ 421 | if (kind != "class_method" && kind != "macro" && kind != "constructor") { 422 | return false; 423 | } 424 | }else{ 425 | //neither # nor . 426 | if(term.indexOf(":") && namespaceMatcher(normalized, term)){ 427 | return true; 428 | } 429 | } 430 | } 431 | 432 | var methodName = term; 433 | if(i >= 0){ 434 | var termType = term.substring(0, i); 435 | methodName = term.substring(i+1); 436 | 437 | if(termType != "") { 438 | if(CrystalDocs.Query.normalizeTerm(type.full_name).indexOf(termType) < 0){ 439 | return false; 440 | } 441 | } 442 | } 443 | return normalized.indexOf(methodName) >= 0; 444 | }); 445 | }; 446 | 447 | this.matchesNamespace = function(namespace){ 448 | return runMatcher(namespace, namespaceMatcher); 449 | }; 450 | 451 | this.highlight = function(string) { 452 | if (typeof string == "undefined") { 453 | return ""; 454 | } 455 | function escapeRegExp(s) { 456 | return s.replace(/[.*+?\^${}()|\[\]\\]/g, "\\$&").replace(/^[#\.:]+/, ""); 457 | } 458 | return string.replace( 459 | new RegExp("(" + this.normalizedTerms.map(escapeRegExp).join("|") + ")", "gi"), 460 | "$1" 461 | ); 462 | }; 463 | }; 464 | CrystalDocs.Query.normalizeTerm = function(term) { 465 | return term.toLowerCase(); 466 | }; 467 | CrystalDocs.Query.stripModifiers = function(term) { 468 | switch (term[0]) { 469 | case "#": 470 | case ".": 471 | case ":": 472 | return term.substr(1); 473 | 474 | default: 475 | return term; 476 | } 477 | } 478 | 479 | CrystalDocs.search = function(string) { 480 | if(!CrystalDocs.searchIndex) { 481 | console.log("CrystalDocs search index not initialized, delaying search"); 482 | 483 | document.addEventListener("CrystalDocs:loaded", function listener(){ 484 | document.removeEventListener("CrystalDocs:loaded", listener); 485 | CrystalDocs.search(string); 486 | }); 487 | return; 488 | } 489 | 490 | document.dispatchEvent(new Event("CrystalDocs:searchStarted")); 491 | 492 | var query = new CrystalDocs.Query(string); 493 | var results = CrystalDocs.runQuery(query); 494 | results = CrystalDocs.rankResults(results, query); 495 | CrystalDocs.displaySearchResults(results, query); 496 | 497 | document.dispatchEvent(new Event("CrystalDocs:searchPerformed")); 498 | }; 499 | 500 | CrystalDocs.initializeIndex = function(data) { 501 | CrystalDocs.searchIndex = data; 502 | 503 | document.dispatchEvent(new Event("CrystalDocs:loaded")); 504 | }; 505 | 506 | CrystalDocs.loadIndex = function() { 507 | function loadJSON(file, callback) { 508 | var xobj = new XMLHttpRequest(); 509 | xobj.overrideMimeType("application/json"); 510 | xobj.open("GET", file, true); 511 | xobj.onreadystatechange = function() { 512 | if (xobj.readyState == 4 && xobj.status == "200") { 513 | callback(xobj.responseText); 514 | } 515 | }; 516 | xobj.send(null); 517 | } 518 | 519 | function loadScript(file) { 520 | script = document.createElement("script"); 521 | script.src = file; 522 | document.body.appendChild(script); 523 | } 524 | 525 | function parseJSON(json) { 526 | CrystalDocs.initializeIndex(JSON.parse(json)); 527 | } 528 | 529 | for(var i = 0; i < document.scripts.length; i++){ 530 | var script = document.scripts[i]; 531 | if (script.src && script.src.indexOf("js/doc.js") >= 0) { 532 | if (script.src.indexOf("file://") == 0) { 533 | // We need to support JSONP files for the search to work on local file system. 534 | var jsonPath = script.src.replace("js/doc.js", "search-index.js"); 535 | loadScript(jsonPath); 536 | return; 537 | } else { 538 | var jsonPath = script.src.replace("js/doc.js", "index.json"); 539 | loadJSON(jsonPath, parseJSON); 540 | return; 541 | } 542 | } 543 | } 544 | console.error("Could not find location of js/doc.js"); 545 | }; 546 | 547 | // Callback for jsonp 548 | function crystal_doc_search_index_callback(data) { 549 | CrystalDocs.initializeIndex(data); 550 | } 551 | 552 | Navigator = function(sidebar, searchInput, list, leaveSearchScope){ 553 | this.list = list; 554 | var self = this; 555 | 556 | var performingSearch = false; 557 | 558 | document.addEventListener('CrystalDocs:searchStarted', function(){ 559 | performingSearch = true; 560 | }); 561 | document.addEventListener('CrystalDocs:searchDebounceStarted', function(){ 562 | performingSearch = true; 563 | }); 564 | document.addEventListener('CrystalDocs:searchPerformed', function(){ 565 | performingSearch = false; 566 | }); 567 | document.addEventListener('CrystalDocs:searchDebounceStopped', function(event){ 568 | performingSearch = false; 569 | }); 570 | 571 | function delayWhileSearching(callback) { 572 | if(performingSearch){ 573 | document.addEventListener('CrystalDocs:searchPerformed', function listener(){ 574 | document.removeEventListener('CrystalDocs:searchPerformed', listener); 575 | 576 | // add some delay to let search results display kick in 577 | setTimeout(callback, 100); 578 | }); 579 | }else{ 580 | callback(); 581 | } 582 | } 583 | 584 | function clearMoveTimeout() { 585 | clearTimeout(self.moveTimeout); 586 | self.moveTimeout = null; 587 | } 588 | 589 | function startMoveTimeout(upwards){ 590 | /*if(self.moveTimeout) { 591 | clearMoveTimeout(); 592 | } 593 | 594 | var go = function() { 595 | if (!self.moveTimeout) return; 596 | self.move(upwards); 597 | self.moveTimout = setTimeout(go, 600); 598 | }; 599 | self.moveTimeout = setTimeout(go, 800);*/ 600 | } 601 | 602 | function scrollCenter(element) { 603 | var rect = element.getBoundingClientRect(); 604 | var middle = sidebar.clientHeight / 2; 605 | sidebar.scrollTop += rect.top + rect.height / 2 - middle; 606 | } 607 | 608 | var move = this.move = function(upwards){ 609 | if(!this.current){ 610 | this.highlightFirst(); 611 | return true; 612 | } 613 | var next = upwards ? this.current.previousElementSibling : this.current.nextElementSibling; 614 | if(next && next.classList) { 615 | this.highlight(next); 616 | scrollCenter(next); 617 | return true; 618 | } 619 | return false; 620 | }; 621 | 622 | this.moveRight = function(){ 623 | }; 624 | this.moveLeft = function(){ 625 | }; 626 | 627 | this.highlight = function(elem) { 628 | if(!elem){ 629 | return; 630 | } 631 | this.removeHighlight(); 632 | 633 | this.current = elem; 634 | this.current.classList.add("current"); 635 | }; 636 | 637 | this.highlightFirst = function(){ 638 | this.highlight(this.list.querySelector('li:first-child')); 639 | }; 640 | 641 | this.removeHighlight = function() { 642 | if(this.current){ 643 | this.current.classList.remove("current"); 644 | } 645 | this.current = null; 646 | } 647 | 648 | this.openSelectedResult = function() { 649 | if(this.current) { 650 | this.current.click(); 651 | } 652 | } 653 | 654 | this.focus = function() { 655 | searchInput.focus(); 656 | searchInput.select(); 657 | this.highlightFirst(); 658 | } 659 | 660 | function handleKeyUp(event) { 661 | switch(event.key) { 662 | case "ArrowUp": 663 | case "ArrowDown": 664 | case "i": 665 | case "j": 666 | case "k": 667 | case "l": 668 | case "c": 669 | case "h": 670 | case "t": 671 | case "n": 672 | event.stopPropagation(); 673 | clearMoveTimeout(); 674 | } 675 | } 676 | 677 | function handleKeyDown(event) { 678 | switch(event.key) { 679 | case "Enter": 680 | event.stopPropagation(); 681 | event.preventDefault(); 682 | leaveSearchScope(); 683 | self.openSelectedResult(); 684 | break; 685 | case "Escape": 686 | event.stopPropagation(); 687 | event.preventDefault(); 688 | leaveSearchScope(); 689 | break; 690 | case "j": 691 | case "c": 692 | case "ArrowUp": 693 | if(event.ctrlKey || event.key == "ArrowUp") { 694 | event.stopPropagation(); 695 | self.move(true); 696 | startMoveTimeout(true); 697 | } 698 | break; 699 | case "k": 700 | case "h": 701 | case "ArrowDown": 702 | if(event.ctrlKey || event.key == "ArrowDown") { 703 | event.stopPropagation(); 704 | self.move(false); 705 | startMoveTimeout(false); 706 | } 707 | break; 708 | case "k": 709 | case "t": 710 | case "ArrowLeft": 711 | if(event.ctrlKey || event.key == "ArrowLeft") { 712 | event.stopPropagation(); 713 | self.moveLeft(); 714 | } 715 | break; 716 | case "l": 717 | case "n": 718 | case "ArrowRight": 719 | if(event.ctrlKey || event.key == "ArrowRight") { 720 | event.stopPropagation(); 721 | self.moveRight(); 722 | } 723 | break; 724 | } 725 | } 726 | 727 | function handleInputKeyUp(event) { 728 | switch(event.key) { 729 | case "ArrowUp": 730 | case "ArrowDown": 731 | event.stopPropagation(); 732 | event.preventDefault(); 733 | clearMoveTimeout(); 734 | } 735 | } 736 | 737 | function handleInputKeyDown(event) { 738 | switch(event.key) { 739 | case "Enter": 740 | event.stopPropagation(); 741 | event.preventDefault(); 742 | delayWhileSearching(function(){ 743 | self.openSelectedResult(); 744 | leaveSearchScope(); 745 | }); 746 | break; 747 | case "Escape": 748 | event.stopPropagation(); 749 | event.preventDefault(); 750 | // remove focus from search input 751 | leaveSearchScope(); 752 | sidebar.focus(); 753 | break; 754 | case "ArrowUp": 755 | event.stopPropagation(); 756 | event.preventDefault(); 757 | self.move(true); 758 | startMoveTimeout(true); 759 | break; 760 | 761 | case "ArrowDown": 762 | event.stopPropagation(); 763 | event.preventDefault(); 764 | self.move(false); 765 | startMoveTimeout(false); 766 | break; 767 | } 768 | } 769 | 770 | sidebar.tabIndex = 100; // set tabIndex to enable keylistener 771 | sidebar.addEventListener('keyup', function(event) { 772 | handleKeyUp(event); 773 | }); 774 | sidebar.addEventListener('keydown', function(event) { 775 | handleKeyDown(event); 776 | }); 777 | searchInput.addEventListener('keydown', function(event) { 778 | handleInputKeyDown(event); 779 | }); 780 | searchInput.addEventListener('keyup', function(event) { 781 | handleInputKeyUp(event); 782 | }); 783 | this.move(); 784 | }; 785 | 786 | CrystalDocs.initializeVersions = function () { 787 | function loadJSON(file, callback) { 788 | var xobj = new XMLHttpRequest(); 789 | xobj.overrideMimeType("application/json"); 790 | xobj.open("GET", file, true); 791 | xobj.onreadystatechange = function() { 792 | if (xobj.readyState == 4 && xobj.status == "200") { 793 | callback(xobj.responseText); 794 | } 795 | }; 796 | xobj.send(null); 797 | } 798 | 799 | function parseJSON(json) { 800 | CrystalDocs.loadConfig(JSON.parse(json)); 801 | } 802 | 803 | $elem = document.querySelector("html > head > meta[name=\"crystal_docs.json_config_url\"]") 804 | if ($elem == undefined) { 805 | return 806 | } 807 | jsonURL = $elem.getAttribute("content") 808 | if (jsonURL && jsonURL != "") { 809 | loadJSON(jsonURL, parseJSON); 810 | } 811 | } 812 | 813 | CrystalDocs.loadConfig = function (config) { 814 | var projectVersions = config["versions"] 815 | var currentVersion = document.querySelector("html > head > meta[name=\"crystal_docs.project_version\"]").getAttribute("content") 816 | 817 | var currentVersionInList = projectVersions.find(function (element) { 818 | return element.name == currentVersion 819 | }) 820 | 821 | if (!currentVersionInList) { 822 | projectVersions.unshift({ name: currentVersion, url: '#' }) 823 | } 824 | 825 | $version = document.querySelector(".project-summary > .project-version") 826 | $version.innerHTML = "" 827 | 828 | $select = document.createElement("select") 829 | $select.classList.add("project-versions-nav") 830 | $select.addEventListener("change", function () { 831 | window.location.href = this.value 832 | }) 833 | projectVersions.forEach(function (version) { 834 | $item = document.createElement("option") 835 | $item.setAttribute("value", version.url) 836 | $item.append(document.createTextNode(version.name)) 837 | 838 | if (version.name == currentVersion) { 839 | $item.setAttribute("selected", true) 840 | $item.setAttribute("disabled", true) 841 | } 842 | $select.append($item) 843 | }); 844 | $form = document.createElement("form") 845 | $form.setAttribute("autocomplete", "off") 846 | $form.append($select) 847 | $version.append($form) 848 | } 849 | 850 | document.addEventListener("DOMContentLoaded", function () { 851 | CrystalDocs.initializeVersions() 852 | }) 853 | 854 | var UsageModal = function(title, content) { 855 | var $body = document.body; 856 | var self = this; 857 | var $modalBackground = document.createElement("div"); 858 | $modalBackground.classList.add("modal-background"); 859 | var $usageModal = document.createElement("div"); 860 | $usageModal.classList.add("usage-modal"); 861 | $modalBackground.appendChild($usageModal); 862 | var $title = document.createElement("h3"); 863 | $title.classList.add("modal-title"); 864 | $title.innerHTML = title 865 | $usageModal.appendChild($title); 866 | var $closeButton = document.createElement("span"); 867 | $closeButton.classList.add("close-button"); 868 | $closeButton.setAttribute("title", "Close modal"); 869 | $closeButton.innerText = '×'; 870 | $usageModal.appendChild($closeButton); 871 | $usageModal.insertAdjacentHTML("beforeend", content); 872 | 873 | $modalBackground.addEventListener('click', function(event) { 874 | var element = event.target || event.srcElement; 875 | 876 | if(element == $modalBackground) { 877 | self.hide(); 878 | } 879 | }); 880 | $closeButton.addEventListener('click', function(event) { 881 | self.hide(); 882 | }); 883 | 884 | $body.insertAdjacentElement('beforeend', $modalBackground); 885 | 886 | this.show = function(){ 887 | $body.classList.add("js-modal-visible"); 888 | }; 889 | this.hide = function(){ 890 | $body.classList.remove("js-modal-visible"); 891 | }; 892 | this.isVisible = function(){ 893 | return $body.classList.contains("js-modal-visible"); 894 | } 895 | } 896 | 897 | 898 | document.addEventListener('DOMContentLoaded', function() { 899 | var sessionStorage; 900 | try { 901 | sessionStorage = window.sessionStorage; 902 | } catch (e) { } 903 | if(!sessionStorage) { 904 | sessionStorage = { 905 | setItem: function() {}, 906 | getItem: function() {}, 907 | removeItem: function() {} 908 | }; 909 | } 910 | 911 | var repositoryName = document.querySelector('[name=repository-name]').getAttribute('content'); 912 | var typesList = document.querySelector('.types-list'); 913 | var searchInput = document.querySelector('.search-input'); 914 | var parents = document.querySelectorAll('.types-list li.parent'); 915 | 916 | var scrollSidebarToOpenType = function(){ 917 | var openTypes = typesList.querySelectorAll('.current'); 918 | if (openTypes.length > 0) { 919 | var lastOpenType = openTypes[openTypes.length - 1]; 920 | lastOpenType.scrollIntoView(); 921 | } 922 | } 923 | 924 | scrollSidebarToOpenType(); 925 | 926 | var setPersistentSearchQuery = function(value){ 927 | sessionStorage.setItem(repositoryName + '::search-input:value', value); 928 | } 929 | 930 | for(var i = 0; i < parents.length; i++) { 931 | var _parent = parents[i]; 932 | _parent.addEventListener('click', function(e) { 933 | e.stopPropagation(); 934 | 935 | if(e.target.tagName.toLowerCase() == 'li') { 936 | if(e.target.className.match(/open/)) { 937 | sessionStorage.removeItem(e.target.getAttribute('data-id')); 938 | e.target.className = e.target.className.replace(/ +open/g, ''); 939 | } else { 940 | sessionStorage.setItem(e.target.getAttribute('data-id'), '1'); 941 | if(e.target.className.indexOf('open') == -1) { 942 | e.target.className += ' open'; 943 | } 944 | } 945 | } 946 | }); 947 | 948 | if(sessionStorage.getItem(_parent.getAttribute('data-id')) == '1') { 949 | _parent.className += ' open'; 950 | } 951 | } 952 | 953 | var leaveSearchScope = function(){ 954 | CrystalDocs.toggleResultsList(false); 955 | window.focus(); 956 | } 957 | 958 | var navigator = new Navigator(document.querySelector('.types-list'), searchInput, document.querySelector(".search-results"), leaveSearchScope); 959 | 960 | CrystalDocs.loadIndex(); 961 | var searchTimeout; 962 | var lastSearchText = false; 963 | var performSearch = function() { 964 | document.dispatchEvent(new Event("CrystalDocs:searchDebounceStarted")); 965 | 966 | clearTimeout(searchTimeout); 967 | searchTimeout = setTimeout(function() { 968 | var text = searchInput.value; 969 | 970 | if(text == "") { 971 | CrystalDocs.toggleResultsList(false); 972 | }else if(text == lastSearchText){ 973 | document.dispatchEvent(new Event("CrystalDocs:searchDebounceStopped")); 974 | }else{ 975 | CrystalDocs.search(text); 976 | navigator.highlightFirst(); 977 | searchInput.focus(); 978 | } 979 | lastSearchText = text; 980 | setPersistentSearchQuery(text); 981 | }, 200); 982 | }; 983 | 984 | if(location.hash.length > 3 && location.hash.substring(0,3) == "#q="){ 985 | // allows directly linking a search query which is then executed on the client 986 | // this comes handy for establishing a custom browser search engine with https://crystal-lang.org/api/#q=%s as a search URL 987 | // TODO: Add OpenSearch description 988 | var searchQuery = location.hash.substring(3); 989 | history.pushState({searchQuery: searchQuery}, "Search for " + searchQuery, location.href.replace(/#q=.*/, "")); 990 | searchInput.value = searchQuery; 991 | document.addEventListener('CrystalDocs:loaded', performSearch); 992 | } 993 | 994 | if (searchInput.value.length == 0) { 995 | var searchText = sessionStorage.getItem(repositoryName + '::search-input:value'); 996 | if(searchText){ 997 | searchInput.value = searchText; 998 | } 999 | } 1000 | searchInput.addEventListener('keyup', performSearch); 1001 | searchInput.addEventListener('input', performSearch); 1002 | 1003 | var usageModal = new UsageModal('Keyboard Shortcuts', '' + 1004 | '' 1042 | ); 1043 | 1044 | function handleShortkeys(event) { 1045 | var element = event.target || event.srcElement; 1046 | 1047 | if(element.tagName == "INPUT" || element.tagName == "TEXTAREA" || element.parentElement.tagName == "TEXTAREA"){ 1048 | return; 1049 | } 1050 | 1051 | switch(event.key) { 1052 | case "?": 1053 | usageModal.show(); 1054 | break; 1055 | 1056 | case "Escape": 1057 | usageModal.hide(); 1058 | break; 1059 | 1060 | case "s": 1061 | case "/": 1062 | if(usageModal.isVisible()) { 1063 | return; 1064 | } 1065 | event.stopPropagation(); 1066 | navigator.focus(); 1067 | performSearch(); 1068 | break; 1069 | } 1070 | } 1071 | 1072 | document.addEventListener('keyup', handleShortkeys); 1073 | 1074 | var scrollToEntryFromLocationHash = function() { 1075 | var hash = window.location.hash; 1076 | if (hash) { 1077 | var targetAnchor = decodeURI(hash.substr(1)); 1078 | var targetEl = document.getElementById(targetAnchor) 1079 | if (targetEl) { 1080 | targetEl.offsetParent.scrollTop = targetEl.offsetTop; 1081 | } 1082 | } 1083 | }; 1084 | window.addEventListener("hashchange", scrollToEntryFromLocationHash, false); 1085 | scrollToEntryFromLocationHash(); 1086 | }); 1087 | -------------------------------------------------------------------------------- /docs/search-index.js: -------------------------------------------------------------------------------- 1 | crystal_doc_search_index_callback({"repository_name":"rate_limiter","body":"![GitHub release](https://img.shields.io/github/release/lbarasti/rate_limiter.svg)\n![Build Status](https://github.com/lbarasti/rate_limiter/workflows/Crystal%20spec/badge.svg)\n[![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://opensource.org/licenses/MIT)\n[![Docs](https://img.shields.io/badge/docs-available-brightgreen.svg)](https://lbarasti.github.io/rate_limiter/docs)\n\n# rate_limiter\n\nThis shard provides a Crystal implementation of the [token bucket](https://en.wikipedia.org/wiki/Token_bucket) algorithm for rate limiting. You can check out the API documentation [here](https://lbarasti.com/rate_limiter/).\n\n## Installation\n\n1 - Add the dependency to your `shard.yml`:\n\n```yaml\ndependencies:\n rate_limiter:\n github: lbarasti/rate_limiter\n```\n\n2 - Run `shards install`\n\n## Usage\n\nRemember to import the shard at the top of your file.\n```crystal\nrequire \"rate_limiter\"\n```\n\nNow you can initialise a rate limiter that produces 1 token every few seconds\n```crystal\nrl = RateLimiter.new(interval: 3.seconds)\n```\n\nMind that the first call to `#get` will return immediately, as buckets are initialised with 1 token, by default.\n```crystal\nrl.get # => RateLimiter::Token(2020-11-29 20:36:56 UTC)\n```\n\nThe next call to `#get` will block for approx. 3 seconds\n```crystal\nstart_time = Time.utc\nrl.get\nTime.utc - start_time # => 00:00:03.000426843\n```\n\nWe can also provide a `max_wait` parameter to `#get`.\n```crystal\nrl.get(0.5.seconds)\n```\nThis call will block for at most 0.5 seconds. If a token is not returned within that interval, then a `RateLimiter::Timeout` is returned.\n\nRate limiters also expose non-blocking methods.\n```crystal\nrl.get? # returns `nil` if no token is available\n\nrl.get! # raises a RateLimiter::Timeout exception if no token is available\n```\n\nYou can pass `#get!` a `max_wait` parameter.\n```crystal\nrl.get!(1.second)\n```\nThis will raise a `RateLimiter::Timeout` exception if no token is returned within a 1 second interval.\n\n### Burst size\nYou can define a rate limiter that accumulates unused tokens up to the specified value by providing a `max_burst` parameter to `RateLimiter.new` - the default is 1.\n```crystal\nRateLimiter.new(rate: 0.5, max_burst: 10)\n```\nThis will generate 1 token every 2 seconds and store up to 10 unused tokens for later use. See Wikipedia's [Burst size](https://en.wikipedia.org/wiki/Token_bucket#Burst_size) for more details.\n\n### Multi-limiters\nIn the scenario where a part of your code needs to abide to two or more rate limits, you can combine multiple rate limiters into a `MultiLimiter`.\n\n```crystal\napi_limiter = RateLimiter.new(rate: 10, max_burst: 60)\ndb_limiter = RateLimiter.new(rate: 100)\nmulti = RateLimiter::MultiLimiter.new(api_limiter, db_limiter)\n```\n\nYou can also use the convenience constructor on the `RateLimiter` module.\n\n```crystal\nmulti = RateLimiter.new(api_limiter, db_limiter)\n```\n\nA `MultiLimiter` exposes the same API as a regular `Limiter` - they both include the `LimiterLike` module - so you can call the same flavours of `#get` methods on it.\n\nWhen calling `get` on a `MultiLimiter`, it will try to acquire tokens from each one of the underlying rate limiters, and only return a token then.\n\n## Under the hood\n![A rate limiter produces one token in each interval. If the bucket has no more room available, then no token will be added for the interval.](./media/diagram_1.png)\n\n## Why do I need a rate limiter?\n* We're calling an API that throttles us when we\n call it too frequently, and we'd rather avoid that.\n* We are exposing an API to customers and want to\n ensure we don't get flooded with requests. For example, we might want to rate limit calls by client id, so that one misbehaving client will not affect the others.\n* One of our ETL stages talks to a datastore that limits the number of requests per second we can send.\n* We have to run a database migration in production and we don't\n want to affect the responsiveness of the service.\n\n## Development\n\nRun the following to run the tests.\n```\ncrystal spec\n```\n\n## Contributing\n\n1. Fork it ()\n2. Create your feature branch (`git checkout -b my-new-feature`)\n3. Commit your changes (`git commit -am 'Add some feature'`)\n4. Push to the branch (`git push origin my-new-feature`)\n5. Create a new Pull Request\n\n## Contributors\n\n- [lbarasti](https://github.com/lbarasti) - creator and maintainer\n","program":{"html_id":"rate_limiter/toplevel","path":"toplevel.html","kind":"module","full_name":"Top Level Namespace","name":"Top Level Namespace","abstract":false,"superclass":null,"ancestors":[],"locations":[],"repository_name":"rate_limiter","program":true,"enum":false,"alias":false,"aliased":null,"aliased_html":null,"const":false,"constants":[],"included_modules":[],"extended_modules":[],"subclasses":[],"including_types":[],"namespace":null,"doc":null,"summary":null,"class_methods":[],"constructors":[],"instance_methods":[],"macros":[],"types":[{"html_id":"rate_limiter/RateLimiter","path":"RateLimiter.html","kind":"module","full_name":"RateLimiter","name":"RateLimiter","abstract":false,"superclass":null,"ancestors":[],"locations":[{"filename":"src/rate_limiter.cr","line_number":2,"url":null}],"repository_name":"rate_limiter","program":false,"enum":false,"alias":false,"aliased":null,"aliased_html":null,"const":false,"constants":[{"id":"VERSION","name":"VERSION","value":"\"1.0.1\"","doc":null,"summary":null}],"included_modules":[],"extended_modules":[],"subclasses":[],"including_types":[],"namespace":null,"doc":"Rate limiting functionality.","summary":"

Rate limiting functionality.

","class_methods":[],"constructors":[{"id":"new(rate:Float64,max_burst:Int32=1)-class-method","html_id":"new(rate:Float64,max_burst:Int32=1)-class-method","name":"new","doc":"Creates a new `Limiter`.\n`rate`: the rate of tokens being produced in tokens/second.\n`max_burst`: maximum number of tokens that can be stored in the bucket.","summary":"

Creates a new Limiter.

","abstract":false,"args":[{"name":"rate","doc":null,"default_value":"","external_name":"rate","restriction":"Float64"},{"name":"max_burst","doc":null,"default_value":"1","external_name":"max_burst","restriction":"Int32"}],"args_string":"(rate : Float64, max_burst : Int32 = 1)","args_html":"(rate : Float64, max_burst : Int32 = 1)","location":{"filename":"src/rate_limiter.cr","line_number":7,"url":null},"def":{"name":"new","args":[{"name":"rate","doc":null,"default_value":"","external_name":"rate","restriction":"Float64"},{"name":"max_burst","doc":null,"default_value":"1","external_name":"max_burst","restriction":"Int32"}],"double_splat":null,"splat_index":null,"yields":null,"block_arg":null,"return_type":"","visibility":"Public","body":"Limiter.new(rate, max_burst)"}},{"id":"new(interval:Time::Span,max_burst:Int32=1)-class-method","html_id":"new(interval:Time::Span,max_burst:Int32=1)-class-method","name":"new","doc":"Creates a new `Limiter`.\n`interval`: the interval at which new tokens are generated.\n`max_burst`: maximum number of tokens that can be stored in the bucket.","summary":"

Creates a new Limiter.

","abstract":false,"args":[{"name":"interval","doc":null,"default_value":"","external_name":"interval","restriction":"Time::Span"},{"name":"max_burst","doc":null,"default_value":"1","external_name":"max_burst","restriction":"Int32"}],"args_string":"(interval : Time::Span, max_burst : Int32 = 1)","args_html":"(interval : Time::Span, max_burst : Int32 = 1)","location":{"filename":"src/rate_limiter.cr","line_number":14,"url":null},"def":{"name":"new","args":[{"name":"interval","doc":null,"default_value":"","external_name":"interval","restriction":"Time::Span"},{"name":"max_burst","doc":null,"default_value":"1","external_name":"max_burst","restriction":"Int32"}],"double_splat":null,"splat_index":null,"yields":null,"block_arg":null,"return_type":"","visibility":"Public","body":"rate = 1 / interval.total_seconds\nLimiter.new(rate, max_burst)\n"}},{"id":"new(*limiters:Limiter)-class-method","html_id":"new(*limiters:Limiter)-class-method","name":"new","doc":"Creates a `MultiLimiter`.\n`limiters`: a set of rate limiters.","summary":"

Creates a MultiLimiter.

","abstract":false,"args":[{"name":"limiters","doc":null,"default_value":"","external_name":"limiters","restriction":"Limiter"}],"args_string":"(*limiters : Limiter)","args_html":"(*limiters : Limiter)","location":{"filename":"src/rate_limiter.cr","line_number":21,"url":null},"def":{"name":"new","args":[{"name":"limiters","doc":null,"default_value":"","external_name":"limiters","restriction":"Limiter"}],"double_splat":null,"splat_index":0,"yields":null,"block_arg":null,"return_type":"","visibility":"Public","body":"MultiLimiter.new(*limiters)"}}],"instance_methods":[],"macros":[],"types":[{"html_id":"rate_limiter/RateLimiter/Limiter","path":"RateLimiter/Limiter.html","kind":"class","full_name":"RateLimiter::Limiter","name":"Limiter","abstract":false,"superclass":{"html_id":"rate_limiter/Reference","kind":"class","full_name":"Reference","name":"Reference"},"ancestors":[{"html_id":"rate_limiter/RateLimiter/LimiterLike","kind":"module","full_name":"RateLimiter::LimiterLike","name":"LimiterLike"},{"html_id":"rate_limiter/Reference","kind":"class","full_name":"Reference","name":"Reference"},{"html_id":"rate_limiter/Object","kind":"class","full_name":"Object","name":"Object"}],"locations":[{"filename":"src/rate_limiter.cr","line_number":83,"url":null}],"repository_name":"rate_limiter","program":false,"enum":false,"alias":false,"aliased":null,"aliased_html":null,"const":false,"constants":[],"included_modules":[{"html_id":"rate_limiter/RateLimiter/LimiterLike","kind":"module","full_name":"RateLimiter::LimiterLike","name":"LimiterLike"}],"extended_modules":[],"subclasses":[],"including_types":[],"namespace":{"html_id":"rate_limiter/RateLimiter","kind":"module","full_name":"RateLimiter","name":"RateLimiter"},"doc":"A rate limiter erogating tokens at the specified rate.\n\nThis is powered by the token bucket algorithm.","summary":"

A rate limiter erogating tokens at the specified rate.

","class_methods":[],"constructors":[{"id":"new(rate:Float64,max_burst:Int32=1)-class-method","html_id":"new(rate:Float64,max_burst:Int32=1)-class-method","name":"new","doc":null,"summary":null,"abstract":false,"args":[{"name":"rate","doc":null,"default_value":"","external_name":"rate","restriction":"Float64"},{"name":"max_burst","doc":null,"default_value":"1","external_name":"max_burst","restriction":"Int32"}],"args_string":"(rate : Float64, max_burst : Int32 = 1)","args_html":"(rate : Float64, max_burst : Int32 = 1)","location":{"filename":"src/rate_limiter.cr","line_number":87,"url":null},"def":{"name":"new","args":[{"name":"rate","doc":null,"default_value":"","external_name":"rate","restriction":"Float64"},{"name":"max_burst","doc":null,"default_value":"1","external_name":"max_burst","restriction":"Int32"}],"double_splat":null,"splat_index":null,"yields":null,"block_arg":null,"return_type":"","visibility":"Public","body":"_ = allocate\n_.initialize(rate, max_burst)\nif _.responds_to?(:finalize)\n ::GC.add_finalizer(_)\nend\n_\n"}}],"instance_methods":[{"id":"bucket:Channel(Nil)-instance-method","html_id":"bucket:Channel(Nil)-instance-method","name":"bucket","doc":null,"summary":null,"abstract":false,"args":[],"args_string":" : Channel(Nil)","args_html":" : Channel(Nil)","location":{"filename":"src/rate_limiter.cr","line_number":85,"url":null},"def":{"name":"bucket","args":[],"double_splat":null,"splat_index":null,"yields":null,"block_arg":null,"return_type":"","visibility":"Public","body":"@bucket"}},{"id":"get(max_wait:Time::Span):Token|Timeout-instance-method","html_id":"get(max_wait:Time::Span):Token|Timeout-instance-method","name":"get","doc":"Returns a `Token` if one is available within `max_wait` time,\notherwise it returns a `Timeout`. Blocking.","summary":"

Returns a Token if one is available within max_wait time, otherwise it returns a Timeout.

","abstract":false,"args":[{"name":"max_wait","doc":null,"default_value":"","external_name":"max_wait","restriction":"Time::Span"}],"args_string":"(max_wait : Time::Span) : Token | Timeout","args_html":"(max_wait : Time::Span) : Token | Timeout","location":{"filename":"src/rate_limiter.cr","line_number":109,"url":null},"def":{"name":"get","args":[{"name":"max_wait","doc":null,"default_value":"","external_name":"max_wait","restriction":"Time::Span"}],"double_splat":null,"splat_index":null,"yields":null,"block_arg":null,"return_type":"Token | Timeout","visibility":"Public","body":"select\nwhen @bucket.receive\n Token.new\nwhen timeout(max_wait)\n Timeout.new\nend\n"}},{"id":"get:Token-instance-method","html_id":"get:Token-instance-method","name":"get","doc":"Returns a `Token` as soon as available. Blocking.","summary":"

Returns a Token as soon as available.

","abstract":false,"args":[],"args_string":" : Token","args_html":" : Token","location":{"filename":"src/rate_limiter.cr","line_number":104,"url":null},"def":{"name":"get","args":[],"double_splat":null,"splat_index":null,"yields":null,"block_arg":null,"return_type":"Token","visibility":"Public","body":"@bucket.receive\nToken.new\n"}},{"id":"rate:Float64-instance-method","html_id":"rate:Float64-instance-method","name":"rate","doc":null,"summary":null,"abstract":false,"args":[],"args_string":" : Float64","args_html":" : Float64","location":{"filename":"src/rate_limiter.cr","line_number":85,"url":null},"def":{"name":"rate","args":[],"double_splat":null,"splat_index":null,"yields":null,"block_arg":null,"return_type":"","visibility":"Public","body":"@rate"}}],"macros":[],"types":[]},{"html_id":"rate_limiter/RateLimiter/LimiterLike","path":"RateLimiter/LimiterLike.html","kind":"module","full_name":"RateLimiter::LimiterLike","name":"LimiterLike","abstract":false,"superclass":null,"ancestors":[],"locations":[{"filename":"src/rate_limiter.cr","line_number":26,"url":null}],"repository_name":"rate_limiter","program":false,"enum":false,"alias":false,"aliased":null,"aliased_html":null,"const":false,"constants":[],"included_modules":[],"extended_modules":[],"subclasses":[],"including_types":[{"html_id":"rate_limiter/RateLimiter/Limiter","kind":"class","full_name":"RateLimiter::Limiter","name":"Limiter"},{"html_id":"rate_limiter/RateLimiter/MultiLimiter","kind":"class","full_name":"RateLimiter::MultiLimiter","name":"MultiLimiter"}],"namespace":{"html_id":"rate_limiter/RateLimiter","kind":"module","full_name":"RateLimiter","name":"RateLimiter"},"doc":"Defines the API for a rate-limiter-like instance.","summary":"

Defines the API for a rate-limiter-like instance.

","class_methods":[],"constructors":[],"instance_methods":[{"id":"get(max_wait:Time::Span):Token|Timeout-instance-method","html_id":"get(max_wait:Time::Span):Token|Timeout-instance-method","name":"get","doc":"Returns a `Token` if one is available within `max_wait` time,\notherwise it returns a `Timeout`. Blocking.","summary":"

Returns a Token if one is available within max_wait time, otherwise it returns a Timeout.

","abstract":true,"args":[{"name":"max_wait","doc":null,"default_value":"","external_name":"max_wait","restriction":"Time::Span"}],"args_string":"(max_wait : Time::Span) : Token | Timeout","args_html":"(max_wait : Time::Span) : Token | Timeout","location":{"filename":"src/rate_limiter.cr","line_number":32,"url":null},"def":{"name":"get","args":[{"name":"max_wait","doc":null,"default_value":"","external_name":"max_wait","restriction":"Time::Span"}],"double_splat":null,"splat_index":null,"yields":null,"block_arg":null,"return_type":"Token | Timeout","visibility":"Public","body":""}},{"id":"get:Token-instance-method","html_id":"get:Token-instance-method","name":"get","doc":"Returns a `Token` as soon as available. Blocking.","summary":"

Returns a Token as soon as available.

","abstract":true,"args":[],"args_string":" : Token","args_html":" : Token","location":{"filename":"src/rate_limiter.cr","line_number":28,"url":null},"def":{"name":"get","args":[],"double_splat":null,"splat_index":null,"yields":null,"block_arg":null,"return_type":"Token","visibility":"Public","body":""}},{"id":"get!(max_wait:Time::Span):Token-instance-method","html_id":"get!(max_wait:Time::Span):Token-instance-method","name":"get!","doc":"Raises `RateLimiter::Timeout` if no token is available after the given\ntime span. Blocking for at most a `max_wait` duration.","summary":"

Raises RateLimiter::Timeout if no token is available after the given time span.

","abstract":false,"args":[{"name":"max_wait","doc":null,"default_value":"","external_name":"max_wait","restriction":"Time::Span"}],"args_string":"(max_wait : Time::Span) : Token","args_html":"(max_wait : Time::Span) : Token","location":{"filename":"src/rate_limiter.cr","line_number":46,"url":null},"def":{"name":"get!","args":[{"name":"max_wait","doc":null,"default_value":"","external_name":"max_wait","restriction":"Time::Span"}],"double_splat":null,"splat_index":null,"yields":null,"block_arg":null,"return_type":"Token","visibility":"Public","body":"case res = get(max_wait: max_wait)\nin Token\n res\nin Timeout\n raise(res)\nend"}},{"id":"get!:Token-instance-method","html_id":"get!:Token-instance-method","name":"get!","doc":"Raises `RateLimiter::Timeout` if no token is available at call time. Non-blocking.","summary":"

Raises RateLimiter::Timeout if no token is available at call time.

","abstract":false,"args":[],"args_string":" : Token","args_html":" : Token","location":{"filename":"src/rate_limiter.cr","line_number":56,"url":null},"def":{"name":"get!","args":[],"double_splat":null,"splat_index":null,"yields":null,"block_arg":null,"return_type":"Token","visibility":"Public","body":"get!(max_wait: 0.seconds)"}},{"id":"get?:Token?-instance-method","html_id":"get?:Token?-instance-method","name":"get?","doc":"Returns `nil` if no token is available at call time. Non-blocking.","summary":"

Returns nil if no token is available at call time.

","abstract":false,"args":[],"args_string":" : Token?","args_html":" : Token?","location":{"filename":"src/rate_limiter.cr","line_number":35,"url":null},"def":{"name":"get?","args":[],"double_splat":null,"splat_index":null,"yields":null,"block_arg":null,"return_type":"Token | Nil","visibility":"Public","body":"case t = get(max_wait: 0.seconds)\nin Token\n t\nin Timeout\n nil\nend"}}],"macros":[],"types":[]},{"html_id":"rate_limiter/RateLimiter/MultiLimiter","path":"RateLimiter/MultiLimiter.html","kind":"class","full_name":"RateLimiter::MultiLimiter","name":"MultiLimiter","abstract":false,"superclass":{"html_id":"rate_limiter/Reference","kind":"class","full_name":"Reference","name":"Reference"},"ancestors":[{"html_id":"rate_limiter/RateLimiter/LimiterLike","kind":"module","full_name":"RateLimiter::LimiterLike","name":"LimiterLike"},{"html_id":"rate_limiter/Reference","kind":"class","full_name":"Reference","name":"Reference"},{"html_id":"rate_limiter/Object","kind":"class","full_name":"Object","name":"Object"}],"locations":[{"filename":"src/rate_limiter.cr","line_number":123,"url":null}],"repository_name":"rate_limiter","program":false,"enum":false,"alias":false,"aliased":null,"aliased_html":null,"const":false,"constants":[],"included_modules":[{"html_id":"rate_limiter/RateLimiter/LimiterLike","kind":"module","full_name":"RateLimiter::LimiterLike","name":"LimiterLike"}],"extended_modules":[],"subclasses":[],"including_types":[],"namespace":{"html_id":"rate_limiter/RateLimiter","kind":"module","full_name":"RateLimiter","name":"RateLimiter"},"doc":"A rate limiter combining multiple `Limiter`s.\n\nA MultiLimter tries to acquire tokens from limiters producing at the lowest rate first.\nThis mitigates the scenario where tokens are acquired and then wasted due to a single rate limiter timing out. ","summary":"

A rate limiter combining multiple Limiters.

","class_methods":[],"constructors":[{"id":"new(*rate_limiters:Limiter)-class-method","html_id":"new(*rate_limiters:Limiter)-class-method","name":"new","doc":null,"summary":null,"abstract":false,"args":[{"name":"rate_limiters","doc":null,"default_value":"","external_name":"rate_limiters","restriction":"Limiter"}],"args_string":"(*rate_limiters : Limiter)","args_html":"(*rate_limiters : Limiter)","location":{"filename":"src/rate_limiter.cr","line_number":128,"url":null},"def":{"name":"new","args":[{"name":"rate_limiters","doc":null,"default_value":"","external_name":"rate_limiters","restriction":"Limiter"}],"double_splat":null,"splat_index":0,"yields":null,"block_arg":null,"return_type":"","visibility":"Public","body":"_ = allocate\n_.initialize(*rate_limiters)\nif _.responds_to?(:finalize)\n ::GC.add_finalizer(_)\nend\n_\n"}}],"instance_methods":[{"id":"get(max_wait:Time::Span):Token|Timeout-instance-method","html_id":"get(max_wait:Time::Span):Token|Timeout-instance-method","name":"get","doc":"Returns a `Token` if one is available within `max_wait` time,\notherwise it returns a `Timeout`. Blocking.","summary":"

Returns a Token if one is available within max_wait time, otherwise it returns a Timeout.

","abstract":false,"args":[{"name":"max_wait","doc":null,"default_value":"","external_name":"max_wait","restriction":"Time::Span"}],"args_string":"(max_wait : Time::Span) : Token | Timeout","args_html":"(max_wait : Time::Span) : Token | Timeout","location":{"filename":"src/rate_limiter.cr","line_number":137,"url":null},"def":{"name":"get","args":[{"name":"max_wait","doc":null,"default_value":"","external_name":"max_wait","restriction":"Time::Span"}],"double_splat":null,"splat_index":null,"yields":null,"block_arg":null,"return_type":"Token | Timeout","visibility":"Public","body":"_, remainder = @rate_limiters.map(&.bucket).reduce({Time.utc, max_wait}) do |__arg3, bucket|\n started_at = __arg3[0]\n time_left = __arg3[1]\n select\nwhen bucket.receive\n new_started_at = Time.utc\n elapsed = new_started_at - started_at\n {new_started_at, time_left - elapsed}\nwhen timeout(time_left)\n break {nil, nil}\nend\n\nend\nremainder.nil? ? Timeout.new : Token.new\n"}},{"id":"get:Token-instance-method","html_id":"get:Token-instance-method","name":"get","doc":"Returns a `Token` as soon as available. Blocking.","summary":"

Returns a Token as soon as available.

","abstract":false,"args":[],"args_string":" : Token","args_html":" : Token","location":{"filename":"src/rate_limiter.cr","line_number":132,"url":null},"def":{"name":"get","args":[],"double_splat":null,"splat_index":null,"yields":null,"block_arg":null,"return_type":"Token","visibility":"Public","body":"@rate_limiters.each(&.get)\nToken.new\n"}}],"macros":[],"types":[]},{"html_id":"rate_limiter/RateLimiter/Timeout","path":"RateLimiter/Timeout.html","kind":"class","full_name":"RateLimiter::Timeout","name":"Timeout","abstract":false,"superclass":{"html_id":"rate_limiter/Exception","kind":"class","full_name":"Exception","name":"Exception"},"ancestors":[{"html_id":"rate_limiter/Exception","kind":"class","full_name":"Exception","name":"Exception"},{"html_id":"rate_limiter/Reference","kind":"class","full_name":"Reference","name":"Reference"},{"html_id":"rate_limiter/Object","kind":"class","full_name":"Object","name":"Object"}],"locations":[{"filename":"src/rate_limiter.cr","line_number":62,"url":null}],"repository_name":"rate_limiter","program":false,"enum":false,"alias":false,"aliased":null,"aliased_html":null,"const":false,"constants":[],"included_modules":[],"extended_modules":[],"subclasses":[],"including_types":[],"namespace":{"html_id":"rate_limiter/RateLimiter","kind":"module","full_name":"RateLimiter","name":"RateLimiter"},"doc":"Returned or raised whenever a `Token` is not available within a given time constraint.","summary":"

Returned or raised whenever a Token is not available within a given time constraint.

","class_methods":[],"constructors":[],"instance_methods":[],"macros":[],"types":[]},{"html_id":"rate_limiter/RateLimiter/Token","path":"RateLimiter/Token.html","kind":"class","full_name":"RateLimiter::Token","name":"Token","abstract":false,"superclass":{"html_id":"rate_limiter/Reference","kind":"class","full_name":"Reference","name":"Reference"},"ancestors":[{"html_id":"rate_limiter/Reference","kind":"class","full_name":"Reference","name":"Reference"},{"html_id":"rate_limiter/Object","kind":"class","full_name":"Object","name":"Object"}],"locations":[{"filename":"src/rate_limiter.cr","line_number":66,"url":null}],"repository_name":"rate_limiter","program":false,"enum":false,"alias":false,"aliased":null,"aliased_html":null,"const":false,"constants":[],"included_modules":[],"extended_modules":[],"subclasses":[],"including_types":[],"namespace":{"html_id":"rate_limiter/RateLimiter","kind":"module","full_name":"RateLimiter","name":"RateLimiter"},"doc":"Represents the availability of capacity to perform operations in the current time bucket.","summary":"

Represents the availability of capacity to perform operations in the current time bucket.

","class_methods":[],"constructors":[{"id":"new(created_at=Time.utc)-class-method","html_id":"new(created_at=Time.utc)-class-method","name":"new","doc":null,"summary":null,"abstract":false,"args":[{"name":"created_at","doc":null,"default_value":"Time.utc","external_name":"created_at","restriction":""}],"args_string":"(created_at = Time.utc)","args_html":"(created_at = Time.utc)","location":{"filename":"src/rate_limiter.cr","line_number":69,"url":null},"def":{"name":"new","args":[{"name":"created_at","doc":null,"default_value":"Time.utc","external_name":"created_at","restriction":""}],"double_splat":null,"splat_index":null,"yields":null,"block_arg":null,"return_type":"","visibility":"Public","body":"_ = allocate\n_.initialize(created_at)\nif _.responds_to?(:finalize)\n ::GC.add_finalizer(_)\nend\n_\n"}}],"instance_methods":[{"id":"created_at:Time-instance-method","html_id":"created_at:Time-instance-method","name":"created_at","doc":null,"summary":null,"abstract":false,"args":[],"args_string":" : Time","args_html":" : Time","location":{"filename":"src/rate_limiter.cr","line_number":67,"url":null},"def":{"name":"created_at","args":[],"double_splat":null,"splat_index":null,"yields":null,"block_arg":null,"return_type":"Time","visibility":"Public","body":"@created_at"}},{"id":"to_s(io)-instance-method","html_id":"to_s(io)-instance-method","name":"to_s","doc":null,"summary":null,"abstract":false,"args":[{"name":"io","doc":null,"default_value":"","external_name":"io","restriction":""}],"args_string":"(io)","args_html":"(io)","location":{"filename":"src/rate_limiter.cr","line_number":73,"url":null},"def":{"name":"to_s","args":[{"name":"io","doc":null,"default_value":"","external_name":"io","restriction":""}],"double_splat":null,"splat_index":null,"yields":null,"block_arg":null,"return_type":"","visibility":"Public","body":"io << \"#{{{ @type }}}(\"\n@created_at.to_s(io)\nio << \")\"\n"}}],"macros":[],"types":[]}]}]}}) -------------------------------------------------------------------------------- /media/diagram_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lbarasti/rate_limiter/833c4848df3bd0f9d4bb62bf9f336dce26fe71d8/media/diagram_1.png -------------------------------------------------------------------------------- /shard.yml: -------------------------------------------------------------------------------- 1 | name: rate_limiter 2 | version: 1.0.1 3 | 4 | description: "A Crystal implementation of the token bucket algorithm for rate limiting" 5 | 6 | authors: 7 | - lbarasti 8 | 9 | crystal: ">= 0.35.1" 10 | 11 | license: MIT 12 | -------------------------------------------------------------------------------- /spec/rate_limiter_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | describe RateLimiter do 4 | describe "RateLimiter.new" do 5 | it "initializes a Limiter with a rate" do 6 | RateLimiter.new(rate: 0.33) 7 | end 8 | 9 | it "initializes a Limiter with an interval" do 10 | RateLimiter.new(interval: 0.5.seconds) 11 | end 12 | 13 | it "initializes a Limiter with max burst parameter" do 14 | RateLimiter.new(interval: 0.5.minutes, max_burst: 2) 15 | RateLimiter.new(rate: 0.5, max_burst: 5) 16 | end 17 | 18 | it "initializes a MultiLimiter combining the given Limiters" do 19 | rl_1 = RateLimiter.new(interval: 0.5.seconds) 20 | rl_2 = RateLimiter.new(rate: 0.5) 21 | multi = RateLimiter.new(rl_1, rl_2) 22 | end 23 | end 24 | 25 | describe RateLimiter::LimiterLike do 26 | it "raises a Timeout exception - without blocking - if no token is available on `#get!`" do 27 | interval = 0.4.seconds 28 | api_limiter = RateLimiter.new(interval: interval) 29 | api_limiter.get # empty bucket 30 | 31 | time { 32 | expect_raises(RateLimiter::Timeout) { 33 | api_limiter.get! 34 | } 35 | }.last.should be_close(0, 1e-2) # call should be non-blocking 36 | 37 | sleep interval * 1.5 # allow time for bucket to be refilled 38 | tkn, elapsed = time { api_limiter.get! } 39 | 40 | elapsed.should be_close(0, 1e-2) 41 | tkn.should be_a RateLimiter::Token 42 | end 43 | 44 | it "raises a Timeout exception if no token is available on `#get!`, after the specified max_wait duration" do 45 | interval = 0.4.seconds 46 | interval_fraction = interval / 3 47 | api_limiter = RateLimiter.new(interval: interval) 48 | api_limiter.get # empty bucket 49 | 50 | time { 51 | expect_raises(RateLimiter::Timeout) { 52 | api_limiter.get!(interval_fraction) 53 | } 54 | }.last.should be_close(interval_fraction.to_f, 1e-2) 55 | 56 | tkn, elapsed = time { api_limiter.get!(interval * 1.2) } 57 | elapsed.should be_close(2 * interval_fraction.to_f, 1e-2) 58 | tkn.should be_a RateLimiter::Token 59 | end 60 | 61 | it "returns nil if no token is available on `#get?`, without blocking" do 62 | interval = 0.4.seconds 63 | api_limiter = RateLimiter.new(interval: interval) 64 | api_limiter.get # empty bucket 65 | 66 | res, elapsed = time { api_limiter.get? } 67 | elapsed.should be_close(0, 1e-2) 68 | res.should be nil 69 | 70 | sleep interval * 1.5 71 | tkn, elapsed = time { api_limiter.get? } 72 | elapsed.should be_close(0, 1e-2) 73 | tkn.should be_a RateLimiter::Token 74 | end 75 | end 76 | 77 | describe RateLimiter::Limiter do 78 | it "returns a token on `#get`" do 79 | RateLimiter.new(0.5, 2).get 80 | .should be_a RateLimiter::Token 81 | end 82 | 83 | it "fills the bucket with the number of tokens set by max_burst from the onset" do 84 | api_limiter = RateLimiter.new(0.5, 2) 85 | time { 86 | 2.times { api_limiter.get } 87 | }.last.should be_close(0, 1e-4) # no waiting, 2 tokens are already in the bucket 88 | end 89 | 90 | it "produces tokens at the given rate" do 91 | api_limiter = RateLimiter.new(0.5, 2) 92 | 2.times { api_limiter.get } # empty bucket 93 | time { api_limiter.get }.last.should be_close(2, 1e-2) 94 | time { api_limiter.get }.last.should be_close(2, 1e-2) 95 | end 96 | 97 | it "produces a timeout if a token is not made available within the given interval" do 98 | api_limiter = RateLimiter.new(0.5, 2) 99 | 2.times { api_limiter.get } # empty bucket 100 | 101 | err, elapsed = time { api_limiter.get(1.second) } 102 | elapsed.should be_close(1, 1e-2) 103 | err.should be_a RateLimiter::Timeout 104 | # if we now set 2 seconds as max waiting time, we'll actually retrieve the token 105 | # in approx 1 second, as one second of the current time interval has already gone 106 | # by with the previous `#.get` call 107 | tkn, elapsed = time { api_limiter.get(2.seconds) } 108 | elapsed.should be_close(1, 1e-2) 109 | tkn.should be_a RateLimiter::Token 110 | end 111 | end 112 | 113 | describe RateLimiter::MultiLimiter do 114 | api_burst = 6 115 | db_burst = api_burst // 2 116 | api_interval = 1.second 117 | db_interval = 0.1.seconds 118 | 119 | it "combines 2 or more rate limiters" do 120 | api_limiter = RateLimiter.new(interval: api_interval, max_burst: api_burst) 121 | db_limiter = RateLimiter.new(interval: db_interval, max_burst: db_burst) 122 | multi = RateLimiter.new(api_limiter, db_limiter) 123 | 124 | time { db_burst.times { # empty db bucket 125 | multi.get 126 | } 127 | }.last.should be_close(0, 1e-4) 128 | 129 | # we receive tokens with the db rate, while the api limiter still has burst tokens left 130 | time_to_empty_api_bucket = db_interval.to_f * (api_burst - db_burst) 131 | _, elapsed = time { 132 | (api_burst - db_burst).times { multi.get } # empty api bucket 133 | } 134 | elapsed.should be_close(time_to_empty_api_bucket, 1e-1) 135 | 136 | # then switch to the api rate, while accounting for time_to_empty_api_bucket 137 | time_to_next_api_token = api_interval.to_f - time_to_empty_api_bucket 138 | time { multi.get }.last.should be_close(time_to_next_api_token, 1e-2) 139 | 140 | # from this point on, multi's rate is bounded by the api rate 141 | iterations = rand(10) 142 | time { 143 | iterations.times { multi.get } 144 | }.last.should be_close(iterations * api_interval.to_f, 1e-1) 145 | 146 | err, elapsed = time { multi.get(api_interval / 10) } 147 | elapsed.should be_close(api_interval.to_f / 10, 1e-2) 148 | err.should be_a RateLimiter::Timeout 149 | end 150 | end 151 | end 152 | -------------------------------------------------------------------------------- /spec/spec_helper.cr: -------------------------------------------------------------------------------- 1 | require "spec" 2 | require "../src/rate_limiter" 3 | 4 | Spec.override_default_formatter(Spec::VerboseFormatter.new) 5 | 6 | def time(&block : -> T) : {T, Float64} forall T 7 | start_time = Time.utc 8 | res = block.call 9 | elapsed = (Time.utc - start_time).total_seconds 10 | {res, elapsed} 11 | end -------------------------------------------------------------------------------- /spec/token_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | describe RateLimiter::Token do 4 | expected_pattern = /RateLimiter::Token\(20\d{2}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} UTC\)/ 5 | 6 | it "has a human-friendly string representation" do 7 | t = RateLimiter::Token.new 8 | t.to_s.should match expected_pattern 9 | end 10 | 11 | it "exposes a `created_at` field" do 12 | t = RateLimiter::Token.new 13 | t.created_at.should be_a Time 14 | end 15 | end -------------------------------------------------------------------------------- /src/rate_limiter.cr: -------------------------------------------------------------------------------- 1 | # Rate limiting functionality. 2 | module RateLimiter 3 | VERSION = "1.0.1" 4 | # Creates a new `Limiter`. 5 | # `rate`: the rate of tokens being produced in tokens/second. 6 | # `max_burst`: maximum number of tokens that can be stored in the bucket. 7 | def self.new(rate : Float64, max_burst : Int32 = 1) 8 | Limiter.new(rate, max_burst) 9 | end 10 | 11 | # Creates a new `Limiter`. 12 | # `interval`: the interval at which new tokens are generated. 13 | # `max_burst`: maximum number of tokens that can be stored in the bucket. 14 | def self.new(interval : Time::Span, max_burst : Int32 = 1) 15 | rate = 1 / interval.total_seconds 16 | Limiter.new(rate, max_burst) 17 | end 18 | 19 | # Creates a `MultiLimiter`. 20 | # `limiters`: a set of rate limiters. 21 | def self.new(*limiters : Limiter) 22 | MultiLimiter.new(*limiters) 23 | end 24 | 25 | # Defines the API for a rate-limiter-like instance. 26 | module LimiterLike 27 | # Returns a `Token` as soon as available. Blocking. 28 | abstract def get : Token 29 | 30 | # Returns a `Token` if one is available within `max_wait` time, 31 | # otherwise it returns a `Timeout`. Blocking. 32 | abstract def get(max_wait : Time::Span) : Token | Timeout 33 | 34 | # Returns `nil` if no token is available at call time. Non-blocking. 35 | def get? : Token | Nil 36 | case t = get(max_wait: 0.seconds) 37 | in Token 38 | t 39 | in Timeout 40 | nil 41 | end 42 | end 43 | 44 | # Raises `RateLimiter::Timeout` if no token is available after the given 45 | # time span. Blocking for at most a `max_wait` duration. 46 | def get!(max_wait : Time::Span) : Token 47 | case res = get(max_wait: max_wait) 48 | in Token 49 | res 50 | in Timeout 51 | raise res 52 | end 53 | end 54 | 55 | # Raises `RateLimiter::Timeout` if no token is available at call time. Non-blocking. 56 | def get! : Token 57 | get!(max_wait: 0.seconds) 58 | end 59 | end 60 | 61 | # Returned or raised whenever a `Token` is not available within a given time constraint. 62 | class Timeout < Exception 63 | end 64 | 65 | # Represents the availability of capacity to perform operations in the current time bucket. 66 | class Token 67 | getter created_at : Time 68 | 69 | def initialize(@created_at = Time.utc) 70 | end 71 | 72 | 73 | def to_s(io) 74 | io << "#{ {{ @type }} }(" 75 | @created_at.to_s(io) 76 | io << ")" 77 | end 78 | end 79 | 80 | # A rate limiter erogating tokens at the specified rate. 81 | # 82 | # This is powered by the token bucket algorithm. 83 | class Limiter 84 | include LimiterLike 85 | getter rate, bucket 86 | 87 | def initialize(@rate : Float64, max_burst : Int32 = 1) 88 | interval = (1 / @rate).seconds 89 | @bucket = Channel(Nil).new(capacity: max_burst) 90 | max_burst.times { @bucket.send nil } 91 | 92 | spawn(name: "filler") { 93 | loop do 94 | sleep interval 95 | select 96 | when @bucket.send nil 97 | else 98 | # do nothing, the bucket is full 99 | end 100 | end 101 | } 102 | end 103 | 104 | def get : Token 105 | @bucket.receive 106 | Token.new 107 | end 108 | 109 | def get(max_wait : Time::Span) : Token | Timeout 110 | select 111 | when @bucket.receive 112 | Token.new 113 | when timeout(max_wait) 114 | Timeout.new 115 | end 116 | end 117 | end 118 | 119 | # A rate limiter combining multiple `Limiter`s. 120 | # 121 | # A MultiLimter tries to acquire tokens from limiters producing at the lowest rate first. 122 | # This mitigates the scenario where tokens are acquired and then wasted due to a single rate limiter timing out. 123 | class MultiLimiter 124 | include LimiterLike 125 | 126 | @rate_limiters : Array(Limiter) 127 | 128 | def initialize(*rate_limiters : Limiter) 129 | @rate_limiters = rate_limiters.to_a.sort_by(&.rate) 130 | end 131 | 132 | def get : Token 133 | @rate_limiters.each(&.get) 134 | Token.new 135 | end 136 | 137 | def get(max_wait : Time::Span) : Token | Timeout 138 | _, remainder = @rate_limiters 139 | .map(&.bucket) 140 | .reduce({Time.utc, max_wait}) { |(started_at, time_left), bucket| 141 | select 142 | when bucket.receive 143 | new_started_at = Time.utc 144 | elapsed = new_started_at - started_at 145 | {new_started_at, time_left - elapsed} 146 | when timeout(time_left) 147 | break {nil, nil} 148 | end 149 | } 150 | remainder.nil? ? Timeout.new : Token.new 151 | end 152 | end 153 | end 154 | --------------------------------------------------------------------------------