pecorino 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.github/workflows/main.yml +16 -0
- data/.gitignore +8 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +84 -0
- data/Gemfile +10 -0
- data/Gemfile.lock +156 -0
- data/LICENSE.txt +21 -0
- data/README.md +91 -0
- data/Rakefile +12 -0
- data/lib/pecorino/install_generator.rb +29 -0
- data/lib/pecorino/leaky_bucket.rb +192 -0
- data/lib/pecorino/migrations/create_raclette_tables.rb.erb +5 -0
- data/lib/pecorino/railtie.rb +7 -0
- data/lib/pecorino/throttle.rb +132 -0
- data/lib/pecorino/version.rb +5 -0
- data/lib/pecorino.rb +49 -0
- data/pecorino.gemspec +40 -0
- metadata +121 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: a78723f311ad1a8b214716714e1b0ba5cf51e5860811612eaa931430cd7008ba
|
4
|
+
data.tar.gz: 89489e7423c8196e5644ee6166b4cd7520c54ed1bfefa627937c4c26f8b934fe
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 36f3d6f7dfe4dc9ed21ba46fc2f79f2899cc38538c9220aacc9c6a9ca35d1f83de4d46f957ce1df782cc6bc19f5d531ca193398d6b8e2fe9bad3548dc43d88a9
|
7
|
+
data.tar.gz: 49f2acdb92bd5077a59eb47dafd335ec11ffb7639d5316d1fca39d851fad5c04460bf2054f5bc1856e41b12bca06b53207548023bf87a5900701bafd0df975e7
|
@@ -0,0 +1,16 @@
|
|
1
|
+
name: Ruby
|
2
|
+
|
3
|
+
on: [push,pull_request]
|
4
|
+
|
5
|
+
jobs:
|
6
|
+
build:
|
7
|
+
runs-on: ubuntu-latest
|
8
|
+
steps:
|
9
|
+
- uses: actions/checkout@v2
|
10
|
+
- name: Set up Ruby
|
11
|
+
uses: ruby/setup-ruby@v1
|
12
|
+
with:
|
13
|
+
ruby-version: 2.6.3
|
14
|
+
bundler-cache: true
|
15
|
+
- name: Run the default task
|
16
|
+
run: bundle exec rake
|
data/.gitignore
ADDED
data/.ruby-version
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
3.1.0
|
data/CHANGELOG.md
ADDED
data/CODE_OF_CONDUCT.md
ADDED
@@ -0,0 +1,84 @@
|
|
1
|
+
# Contributor Covenant Code of Conduct
|
2
|
+
|
3
|
+
## Our Pledge
|
4
|
+
|
5
|
+
We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
|
6
|
+
|
7
|
+
We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
|
8
|
+
|
9
|
+
## Our Standards
|
10
|
+
|
11
|
+
Examples of behavior that contributes to a positive environment for our community include:
|
12
|
+
|
13
|
+
* Demonstrating empathy and kindness toward other people
|
14
|
+
* Being respectful of differing opinions, viewpoints, and experiences
|
15
|
+
* Giving and gracefully accepting constructive feedback
|
16
|
+
* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
|
17
|
+
* Focusing on what is best not just for us as individuals, but for the overall community
|
18
|
+
|
19
|
+
Examples of unacceptable behavior include:
|
20
|
+
|
21
|
+
* The use of sexualized language or imagery, and sexual attention or
|
22
|
+
advances of any kind
|
23
|
+
* Trolling, insulting or derogatory comments, and personal or political attacks
|
24
|
+
* Public or private harassment
|
25
|
+
* Publishing others' private information, such as a physical or email
|
26
|
+
address, without their explicit permission
|
27
|
+
* Other conduct which could reasonably be considered inappropriate in a
|
28
|
+
professional setting
|
29
|
+
|
30
|
+
## Enforcement Responsibilities
|
31
|
+
|
32
|
+
Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
|
33
|
+
|
34
|
+
Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate.
|
35
|
+
|
36
|
+
## Scope
|
37
|
+
|
38
|
+
This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
|
39
|
+
|
40
|
+
## Enforcement
|
41
|
+
|
42
|
+
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at me@julik.nl. All complaints will be reviewed and investigated promptly and fairly.
|
43
|
+
|
44
|
+
All community leaders are obligated to respect the privacy and security of the reporter of any incident.
|
45
|
+
|
46
|
+
## Enforcement Guidelines
|
47
|
+
|
48
|
+
Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:
|
49
|
+
|
50
|
+
### 1. Correction
|
51
|
+
|
52
|
+
**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
|
53
|
+
|
54
|
+
**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
|
55
|
+
|
56
|
+
### 2. Warning
|
57
|
+
|
58
|
+
**Community Impact**: A violation through a single incident or series of actions.
|
59
|
+
|
60
|
+
**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
|
61
|
+
|
62
|
+
### 3. Temporary Ban
|
63
|
+
|
64
|
+
**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior.
|
65
|
+
|
66
|
+
**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
|
67
|
+
|
68
|
+
### 4. Permanent Ban
|
69
|
+
|
70
|
+
**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
|
71
|
+
|
72
|
+
**Consequence**: A permanent ban from any sort of public interaction within the community.
|
73
|
+
|
74
|
+
## Attribution
|
75
|
+
|
76
|
+
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0,
|
77
|
+
available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
78
|
+
|
79
|
+
Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity).
|
80
|
+
|
81
|
+
[homepage]: https://www.contributor-covenant.org
|
82
|
+
|
83
|
+
For answers to common questions about this code of conduct, see the FAQ at
|
84
|
+
https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations.
|
data/Gemfile
ADDED
data/Gemfile.lock
ADDED
@@ -0,0 +1,156 @@
|
|
1
|
+
PATH
|
2
|
+
remote: .
|
3
|
+
specs:
|
4
|
+
pecorino (0.1.0)
|
5
|
+
activerecord (~> 7)
|
6
|
+
pg
|
7
|
+
|
8
|
+
GEM
|
9
|
+
remote: https://rubygems.org/
|
10
|
+
specs:
|
11
|
+
actioncable (7.0.4)
|
12
|
+
actionpack (= 7.0.4)
|
13
|
+
activesupport (= 7.0.4)
|
14
|
+
nio4r (~> 2.0)
|
15
|
+
websocket-driver (>= 0.6.1)
|
16
|
+
actionmailbox (7.0.4)
|
17
|
+
actionpack (= 7.0.4)
|
18
|
+
activejob (= 7.0.4)
|
19
|
+
activerecord (= 7.0.4)
|
20
|
+
activestorage (= 7.0.4)
|
21
|
+
activesupport (= 7.0.4)
|
22
|
+
mail (>= 2.7.1)
|
23
|
+
net-imap
|
24
|
+
net-pop
|
25
|
+
net-smtp
|
26
|
+
actionmailer (7.0.4)
|
27
|
+
actionpack (= 7.0.4)
|
28
|
+
actionview (= 7.0.4)
|
29
|
+
activejob (= 7.0.4)
|
30
|
+
activesupport (= 7.0.4)
|
31
|
+
mail (~> 2.5, >= 2.5.4)
|
32
|
+
net-imap
|
33
|
+
net-pop
|
34
|
+
net-smtp
|
35
|
+
rails-dom-testing (~> 2.0)
|
36
|
+
actionpack (7.0.4)
|
37
|
+
actionview (= 7.0.4)
|
38
|
+
activesupport (= 7.0.4)
|
39
|
+
rack (~> 2.0, >= 2.2.0)
|
40
|
+
rack-test (>= 0.6.3)
|
41
|
+
rails-dom-testing (~> 2.0)
|
42
|
+
rails-html-sanitizer (~> 1.0, >= 1.2.0)
|
43
|
+
actiontext (7.0.4)
|
44
|
+
actionpack (= 7.0.4)
|
45
|
+
activerecord (= 7.0.4)
|
46
|
+
activestorage (= 7.0.4)
|
47
|
+
activesupport (= 7.0.4)
|
48
|
+
globalid (>= 0.6.0)
|
49
|
+
nokogiri (>= 1.8.5)
|
50
|
+
actionview (7.0.4)
|
51
|
+
activesupport (= 7.0.4)
|
52
|
+
builder (~> 3.1)
|
53
|
+
erubi (~> 1.4)
|
54
|
+
rails-dom-testing (~> 2.0)
|
55
|
+
rails-html-sanitizer (~> 1.1, >= 1.2.0)
|
56
|
+
activejob (7.0.4)
|
57
|
+
activesupport (= 7.0.4)
|
58
|
+
globalid (>= 0.3.6)
|
59
|
+
activemodel (7.0.4)
|
60
|
+
activesupport (= 7.0.4)
|
61
|
+
activerecord (7.0.4)
|
62
|
+
activemodel (= 7.0.4)
|
63
|
+
activesupport (= 7.0.4)
|
64
|
+
activestorage (7.0.4)
|
65
|
+
actionpack (= 7.0.4)
|
66
|
+
activejob (= 7.0.4)
|
67
|
+
activerecord (= 7.0.4)
|
68
|
+
activesupport (= 7.0.4)
|
69
|
+
marcel (~> 1.0)
|
70
|
+
mini_mime (>= 1.1.0)
|
71
|
+
activesupport (7.0.4)
|
72
|
+
concurrent-ruby (~> 1.0, >= 1.0.2)
|
73
|
+
i18n (>= 1.6, < 2)
|
74
|
+
minitest (>= 5.1)
|
75
|
+
tzinfo (~> 2.0)
|
76
|
+
builder (3.2.4)
|
77
|
+
concurrent-ruby (1.1.10)
|
78
|
+
crass (1.0.6)
|
79
|
+
erubi (1.11.0)
|
80
|
+
globalid (1.0.0)
|
81
|
+
activesupport (>= 5.0)
|
82
|
+
i18n (1.12.0)
|
83
|
+
concurrent-ruby (~> 1.0)
|
84
|
+
loofah (2.19.0)
|
85
|
+
crass (~> 1.0.2)
|
86
|
+
nokogiri (>= 1.5.9)
|
87
|
+
mail (2.7.1)
|
88
|
+
mini_mime (>= 0.1.1)
|
89
|
+
marcel (1.0.2)
|
90
|
+
method_source (1.0.0)
|
91
|
+
mini_mime (1.1.2)
|
92
|
+
minitest (5.16.3)
|
93
|
+
net-imap (0.3.1)
|
94
|
+
net-protocol
|
95
|
+
net-pop (0.1.2)
|
96
|
+
net-protocol
|
97
|
+
net-protocol (0.1.3)
|
98
|
+
timeout
|
99
|
+
net-smtp (0.3.2)
|
100
|
+
net-protocol
|
101
|
+
nio4r (2.5.8)
|
102
|
+
nokogiri (1.13.8-x86_64-darwin)
|
103
|
+
racc (~> 1.4)
|
104
|
+
pg (1.3.2)
|
105
|
+
racc (1.6.0)
|
106
|
+
rack (2.2.4)
|
107
|
+
rack-test (2.0.2)
|
108
|
+
rack (>= 1.3)
|
109
|
+
rails (7.0.4)
|
110
|
+
actioncable (= 7.0.4)
|
111
|
+
actionmailbox (= 7.0.4)
|
112
|
+
actionmailer (= 7.0.4)
|
113
|
+
actionpack (= 7.0.4)
|
114
|
+
actiontext (= 7.0.4)
|
115
|
+
actionview (= 7.0.4)
|
116
|
+
activejob (= 7.0.4)
|
117
|
+
activemodel (= 7.0.4)
|
118
|
+
activerecord (= 7.0.4)
|
119
|
+
activestorage (= 7.0.4)
|
120
|
+
activesupport (= 7.0.4)
|
121
|
+
bundler (>= 1.15.0)
|
122
|
+
railties (= 7.0.4)
|
123
|
+
rails-dom-testing (2.0.3)
|
124
|
+
activesupport (>= 4.2.0)
|
125
|
+
nokogiri (>= 1.6)
|
126
|
+
rails-html-sanitizer (1.4.3)
|
127
|
+
loofah (~> 2.3)
|
128
|
+
railties (7.0.4)
|
129
|
+
actionpack (= 7.0.4)
|
130
|
+
activesupport (= 7.0.4)
|
131
|
+
method_source
|
132
|
+
rake (>= 12.2)
|
133
|
+
thor (~> 1.0)
|
134
|
+
zeitwerk (~> 2.5)
|
135
|
+
rake (13.0.6)
|
136
|
+
thor (1.2.1)
|
137
|
+
timeout (0.3.0)
|
138
|
+
tzinfo (2.0.5)
|
139
|
+
concurrent-ruby (~> 1.0)
|
140
|
+
websocket-driver (0.7.5)
|
141
|
+
websocket-extensions (>= 0.1.0)
|
142
|
+
websocket-extensions (0.1.5)
|
143
|
+
zeitwerk (2.6.1)
|
144
|
+
|
145
|
+
PLATFORMS
|
146
|
+
x86_64-darwin-19
|
147
|
+
|
148
|
+
DEPENDENCIES
|
149
|
+
activesupport (~> 7)
|
150
|
+
minitest (~> 5.0)
|
151
|
+
pecorino!
|
152
|
+
rails (~> 7)
|
153
|
+
rake (~> 13.0)
|
154
|
+
|
155
|
+
BUNDLED WITH
|
156
|
+
2.3.5
|
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2023 Julik Tarkhanov
|
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.
|
data/README.md
ADDED
@@ -0,0 +1,91 @@
|
|
1
|
+
# Pecorino
|
2
|
+
|
3
|
+
Pecorino is a rate limiter based on the concept of leaky buckets. It uses your DB as the storage backend for the throttles. It is compact, easy to install, and does not require additional infrastructure. The approach used by Pecorino has been previously used by [prorate](https://github.com/WeTransfer/prorate) with Redis, and that approach has proven itself.
|
4
|
+
|
5
|
+
Pecorino is designed to integrate seamlessly into any Rails application using a Postgres database (at the moment there is no MySQL support, we would be delighted if you could add it).
|
6
|
+
|
7
|
+
## Installation
|
8
|
+
|
9
|
+
Add this line to your application's Gemfile:
|
10
|
+
|
11
|
+
```ruby
|
12
|
+
gem 'pecorino'
|
13
|
+
```
|
14
|
+
|
15
|
+
And then execute:
|
16
|
+
|
17
|
+
$ bundle install
|
18
|
+
|
19
|
+
Or install it yourself as:
|
20
|
+
|
21
|
+
$ gem install pecorino
|
22
|
+
|
23
|
+
## Usage
|
24
|
+
|
25
|
+
First, add and run the migration to create the pecorino tables:
|
26
|
+
|
27
|
+
$ bin/rails g pecorino:install
|
28
|
+
$ bin/rails db:migrate
|
29
|
+
|
30
|
+
Once that is done, you can use Pecorino to start defining your throttles. Imagine you have a resource called `vault` and you want to limit the number of updates to it to 5 per second. To achieve that, instantiate a new `Throttle` in your controller or job code, and then trigger it using `Throttle#request!`. A call to `request!` registers 1 token getting added to the bucket. If the bucket is full, or the throttle is currently in "block" mode (has recently been triggered), a `Pecorino::Throttle::Throttled` exception will be raised.
|
31
|
+
|
32
|
+
```ruby
|
33
|
+
throttle = Pecorino::Throttle.new(key: "vault", leak_rate: 5, capacity: 5)
|
34
|
+
throttle.request!
|
35
|
+
```
|
36
|
+
|
37
|
+
The exception has an attribute called `retry_after` which you can use to render the appropriate 429 response.
|
38
|
+
|
39
|
+
Although this approach might be susceptible to race conditions, you can interrogate your throttle before potentially causing an exception - and display an appropriate error message if the throttle would trigger anyway:
|
40
|
+
|
41
|
+
```ruby
|
42
|
+
return render :capacity_exceeded unless throttle.able_to_accept?
|
43
|
+
```
|
44
|
+
|
45
|
+
If you are dealing with a metered resource (like throughput, money, amount of storage...) you can supply the number of tokens to either `request!` or `able_to_accept?` to indicate the desired top-up of the leaky bucket. For example, if you are maintaining user wallets and want to ensure no more than 100 dollars may be taken from the wallet within a certain amount of time, you can do it like so:
|
46
|
+
|
47
|
+
```ruby
|
48
|
+
throttle = Pecorino::Throttle.new(key: "wallet_t_#{current_user.id}", leak_rate: 100 / 60.0 / 60.0, capacity: 100, block_for: 60*60*3)
|
49
|
+
throttle.request!(20) # Attempt to withdraw 20 dollars
|
50
|
+
throttle.request!(20) # Attempt to withdraw 20 dollars more
|
51
|
+
throttle.request!(20) # Attempt to withdraw 20 dollars more
|
52
|
+
throttle.request!(20) # Attempt to withdraw 20 dollars more
|
53
|
+
throttle.request!(20) # Attempt to withdraw 20 dollars more
|
54
|
+
throttle.request!(2) # Attempt to withdraw 2 dollars more, will raise `Throttled` and block withdrawals for 3 hours
|
55
|
+
```
|
56
|
+
|
57
|
+
Sometimes you don't want to use a throttle, but you want to track the amount added to the leaky bucket over time. A lower-level abstraction is available for that purpose in the form of the `LeakyBucket` class. It will not raise any exceptions and will not install blocks, but will permit you to track a bucket's state over time:
|
58
|
+
|
59
|
+
|
60
|
+
```ruby
|
61
|
+
b = Pecorino::LeakyBucket.new(key: "some_b", capacity: 100, leak_rate: 5)
|
62
|
+
b.fillup(2) #=> Pecorino::LeakyBucket::State(full?: false, level: 2.0)
|
63
|
+
sleep 0.2
|
64
|
+
b.state #=> Pecorino::LeakyBucket::State(full?: false, level: 1.8)
|
65
|
+
```
|
66
|
+
|
67
|
+
Check out the inline YARD documentation for more options.
|
68
|
+
|
69
|
+
## Cleaning out stale locks from the database
|
70
|
+
|
71
|
+
We recommend running the following bit of code every couple of hours (via cron or similar) to delete the stale blocks and leaky buckets from the system:
|
72
|
+
|
73
|
+
Pecorino.prune!
|
74
|
+
|
75
|
+
## Development
|
76
|
+
|
77
|
+
After checking out the repo, run `bundle. Then, run `rake test` to run the tests.
|
78
|
+
|
79
|
+
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
80
|
+
|
81
|
+
## Contributing
|
82
|
+
|
83
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/julik/pecorino. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/julik/pecorino/blob/main/CODE_OF_CONDUCT.md).
|
84
|
+
|
85
|
+
## License
|
86
|
+
|
87
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
88
|
+
|
89
|
+
## Code of Conduct
|
90
|
+
|
91
|
+
Everyone interacting in the Pecorino project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/julik/pecorino/blob/main/CODE_OF_CONDUCT.md).
|
data/Rakefile
ADDED
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
require 'rails/generators'
|
3
|
+
require 'rails/generators/active_record'
|
4
|
+
|
5
|
+
module Pecorino
|
6
|
+
#
|
7
|
+
# Rails generator used for setting up GoodJob in a Rails application.
|
8
|
+
# Run it with +bin/rails g good_job:install+ in your console.
|
9
|
+
#
|
10
|
+
class InstallGenerator < Rails::Generators::Base
|
11
|
+
include ActiveRecord::Generators::Migration
|
12
|
+
|
13
|
+
TEMPLATES = File.join(File.dirname(__FILE__))
|
14
|
+
source_paths << TEMPLATES
|
15
|
+
|
16
|
+
class_option :database, type: :string, aliases: %i(--db), desc: "The database for your migration. By default, the current environment's primary database is used."
|
17
|
+
|
18
|
+
# Generates monolithic migration file that contains all database changes.
|
19
|
+
def create_migration_file
|
20
|
+
migration_template 'migrations/create_pecorino_tables.rb.erb', File.join(db_migrate_path, "create_pecorino_tables.rb")
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
def migration_version
|
26
|
+
"[#{ActiveRecord::VERSION::STRING.to_f}]"
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,192 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# This offers just the leaky bucket implementation with fill control, but without the timed lock.
|
4
|
+
# It does not raise any exceptions, it just tracks the state of a leaky bucket in Postgres.
|
5
|
+
#
|
6
|
+
# Leak rate is specified directly in tokens per second, instead of specifying the block period.
|
7
|
+
# The bucket level is stored and returned as a Float which allows for finer-grained measurement,
|
8
|
+
# but more importantly - makes testing from the outside easier.
|
9
|
+
#
|
10
|
+
# Note that this implementation has a peculiar property: the bucket is only "full" once it overflows.
|
11
|
+
# Due to a leak rate just a few microseconds after that moment the bucket is no longer going to be full
|
12
|
+
# anymore as it will have leaked some tokens by then. This means that the information about whether a
|
13
|
+
# bucket has become full or not gets returned in the bucket `State` struct right after the database
|
14
|
+
# update gets executed, and if your code needs to make decisions based on that data it has to use
|
15
|
+
# this returned state, not query the leaky bucket again. Specifically:
|
16
|
+
#
|
17
|
+
# state = bucket.fillup(1) # Record 1 request
|
18
|
+
# state.full? #=> true, this is timely information
|
19
|
+
#
|
20
|
+
# ...is the correct way to perform the check. This, however, is not:
|
21
|
+
#
|
22
|
+
# bucket.fillup(1)
|
23
|
+
# bucket.state.full? #=> false, some time has passed after the topup and some tokens have already leaked
|
24
|
+
#
|
25
|
+
# The storage use is one DB row per leaky bucket you need to manage (likely - one throttled entity such
|
26
|
+
# as a combination of an IP address + the URL you need to procect). The `key` is an arbitrary string you provide.
|
27
|
+
class Pecorino::LeakyBucket
|
28
|
+
class State < Struct.new(:level, :full)
|
29
|
+
# Returns the level of the bucket after the operation on the LeakyBucket
|
30
|
+
# object has taken place. There is a guarantee that no tokens have leaked
|
31
|
+
# from the bucket between the operation and the freezing of the State
|
32
|
+
# struct.
|
33
|
+
#
|
34
|
+
# @!attribute [r] level
|
35
|
+
# @return [Float]
|
36
|
+
|
37
|
+
# Tells whether the bucket was detected to be full when the operation on
|
38
|
+
# the LeakyBucket was performed. There is a guarantee that no tokens have leaked
|
39
|
+
# from the bucket between the operation and the freezing of the State
|
40
|
+
# struct.
|
41
|
+
#
|
42
|
+
# @!attribute [r] full
|
43
|
+
# @return [Boolean]
|
44
|
+
|
45
|
+
alias_method :full?, :full
|
46
|
+
|
47
|
+
# Returns the bucket level of the bucket state as a Float
|
48
|
+
#
|
49
|
+
# @return [Float]
|
50
|
+
def to_f
|
51
|
+
level.to_f
|
52
|
+
end
|
53
|
+
|
54
|
+
# Returns the bucket level of the bucket state rounded to an Integer
|
55
|
+
#
|
56
|
+
# @return [Integer]
|
57
|
+
def to_i
|
58
|
+
level.to_i
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
# Creates a new LeakyBucket. The object controls 1 row in Postgres which is
|
63
|
+
# specific to the bucket key.
|
64
|
+
#
|
65
|
+
# @param key[String] the key for the bucket. The key also gets used
|
66
|
+
# to derive locking keys, so that operations on a particular bucket
|
67
|
+
# are always serialized.
|
68
|
+
# @param leak_rate[Float] the leak rate of the bucket, in tokens per second
|
69
|
+
# @param capacity[Numeric] how many tokens is the bucket capped at.
|
70
|
+
# Filling up the bucket using `fillup()` will add to that number, but
|
71
|
+
# the bucket contents will then be capped at this value. So with
|
72
|
+
# bucket_capacity set to 12 and a `fillup(14)` the bucket will reach the level
|
73
|
+
# of 12, and will then immediately start leaking again.
|
74
|
+
def initialize(key:, leak_rate:, capacity:)
|
75
|
+
@key = key
|
76
|
+
@leak_rate = leak_rate.to_f
|
77
|
+
@capacity = capacity.to_f
|
78
|
+
end
|
79
|
+
|
80
|
+
# Places `n` tokens in the bucket. Once tokens are placed, the bucket is set to expire
|
81
|
+
# within 2 times the time it would take it to leak to 0, regardless of how many tokens
|
82
|
+
# get put in - since the amount of tokens put in the bucket will always be capped
|
83
|
+
# to the `capacity:` value you pass to the constructor. Calling `fillup` also deletes
|
84
|
+
# leaky buckets which have expired.
|
85
|
+
#
|
86
|
+
# @param n_tokens[Float]
|
87
|
+
# @return [State] the state of the bucket after the operation
|
88
|
+
def fillup(n_tokens)
|
89
|
+
add_tokens(n_tokens.to_f)
|
90
|
+
end
|
91
|
+
|
92
|
+
# Returns the current state of the bucket, containing the level and whether the bucket is full.
|
93
|
+
# Calling this method will not perform any database writes.
|
94
|
+
#
|
95
|
+
# @return [State] the snapshotted state of the bucket at time of query
|
96
|
+
def state
|
97
|
+
conn = ActiveRecord::Base.connection
|
98
|
+
query_params = {
|
99
|
+
key: @key,
|
100
|
+
capa: @capacity.to_f,
|
101
|
+
leak_rate: @leak_rate.to_f
|
102
|
+
}
|
103
|
+
# The `level` of the bucket is what got stored at `last_touched_at` time, and we can
|
104
|
+
# extrapolate from it to see how many tokens have leaked out since `last_touched_at` -
|
105
|
+
# we don't need to UPDATE the value in the bucket here
|
106
|
+
sql = ActiveRecord::Base.sanitize_sql_array([<<~SQL, query_params])
|
107
|
+
SELECT
|
108
|
+
GREATEST(
|
109
|
+
0.0, LEAST(
|
110
|
+
:capa,
|
111
|
+
t.level - (EXTRACT(EPOCH FROM (clock_timestamp() - t.last_touched_at)) * :leak_rate)
|
112
|
+
)
|
113
|
+
)
|
114
|
+
FROM
|
115
|
+
pecorino_leaky_buckets AS t
|
116
|
+
WHERE
|
117
|
+
key = :key
|
118
|
+
SQL
|
119
|
+
|
120
|
+
# If the return value of the query is a NULL it means no such bucket exists,
|
121
|
+
# so we assume the bucket is empty
|
122
|
+
current_level = conn.uncached { conn.select_value(sql) } || 0.0
|
123
|
+
|
124
|
+
State.new(current_level, (@capacity - current_level).abs < 0.01)
|
125
|
+
end
|
126
|
+
|
127
|
+
# Tells whether the bucket can accept the amount of tokens without overflowing.
|
128
|
+
# Calling this method will not perform any database writes. Note that this call is
|
129
|
+
# not race-safe - another caller may still overflow the bucket. Before performing
|
130
|
+
# your action, you still need to call `fillup()` - but you can preemptively refuse
|
131
|
+
# a request if you already know the bucket is full.
|
132
|
+
#
|
133
|
+
# @param n_tokens[Float]
|
134
|
+
# @return [boolean]
|
135
|
+
def able_to_accept?(n_tokens)
|
136
|
+
(state.level + n_tokens) < @capacity
|
137
|
+
end
|
138
|
+
|
139
|
+
private
|
140
|
+
|
141
|
+
def add_tokens(n_tokens)
|
142
|
+
conn = ActiveRecord::Base.connection
|
143
|
+
|
144
|
+
# Take double the time it takes the bucket to empty under normal circumstances
|
145
|
+
# until the bucket may be deleted.
|
146
|
+
may_be_deleted_after_seconds = (@capacity.to_f / @leak_rate.to_f) * 2.0
|
147
|
+
|
148
|
+
# Create the leaky bucket if it does not exist, and update
|
149
|
+
# to the new level, taking the leak rate into account - if the bucket exists.
|
150
|
+
query_params = {
|
151
|
+
key: @key,
|
152
|
+
capa: @capacity.to_f,
|
153
|
+
delete_after_s: may_be_deleted_after_seconds,
|
154
|
+
leak_rate: @leak_rate.to_f,
|
155
|
+
fillup: n_tokens.to_f
|
156
|
+
}
|
157
|
+
sql = ActiveRecord::Base.sanitize_sql_array([<<~SQL, query_params])
|
158
|
+
INSERT INTO pecorino_leaky_buckets AS t
|
159
|
+
(key, last_touched_at, may_be_deleted_after, level)
|
160
|
+
VALUES
|
161
|
+
(
|
162
|
+
:key,
|
163
|
+
clock_timestamp(),
|
164
|
+
clock_timestamp() + ':delete_after_s second'::interval,
|
165
|
+
GREATEST(0.0,
|
166
|
+
LEAST(
|
167
|
+
:capa,
|
168
|
+
:fillup
|
169
|
+
)
|
170
|
+
)
|
171
|
+
)
|
172
|
+
ON CONFLICT (key) DO UPDATE SET
|
173
|
+
last_touched_at = EXCLUDED.last_touched_at,
|
174
|
+
may_be_deleted_after = EXCLUDED.may_be_deleted_after,
|
175
|
+
level = GREATEST(0.0,
|
176
|
+
LEAST(
|
177
|
+
:capa,
|
178
|
+
t.level + :fillup - (EXTRACT(EPOCH FROM (EXCLUDED.last_touched_at - t.last_touched_at)) * :leak_rate)
|
179
|
+
)
|
180
|
+
)
|
181
|
+
RETURNING level
|
182
|
+
SQL
|
183
|
+
|
184
|
+
# Note the use of .uncached here. The AR query cache will actually see our
|
185
|
+
# query as a repeat (since we use "select_value" for the RETURNING bit) and will not call into Postgres
|
186
|
+
# correctly, thus the clock_timestamp() value would be frozen between calls. We don't want that here.
|
187
|
+
# See https://stackoverflow.com/questions/73184531/why-would-postgres-clock-timestamp-freeze-inside-a-rails-unit-test
|
188
|
+
level_after_fillup = conn.uncached { conn.select_value(sql) }
|
189
|
+
|
190
|
+
State.new(level_after_fillup, (@capacity - level_after_fillup).abs < 0.01)
|
191
|
+
end
|
192
|
+
end
|
@@ -0,0 +1,132 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Provides a throttle with a block based on the `LeakyBucket`. Once a bucket fills up,
|
4
|
+
# a block will be installed and an exception will be raised. Once a block is set, no
|
5
|
+
# checks will be done on the leaky bucket - any further requests will be refused until
|
6
|
+
# the block is lifted. The block time can be arbitrarily higher or lower than the amount
|
7
|
+
# of time it takes for the leaky bucket to leak out
|
8
|
+
class Pecorino::Throttle
|
9
|
+
class State < Struct.new(:blocked_until)
|
10
|
+
# Tells whether this throttle is blocked, either due to the leaky bucket having filled up
|
11
|
+
# or due to there being a timed block set because of an earlier event of the bucket having
|
12
|
+
# filled up
|
13
|
+
def blocked?
|
14
|
+
blocked_until ? true : false
|
15
|
+
end
|
16
|
+
|
17
|
+
def retry_after
|
18
|
+
(blocked_until - Time.now.utc).ceil
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
class Throttled < StandardError
|
23
|
+
# Returns the throttle which raised the exception. Can be used to disambiguiate between
|
24
|
+
# multiple Throttled exceptions when multiple throttles are applied in a layered fashion:
|
25
|
+
#
|
26
|
+
# ip_addr_throttle.request!
|
27
|
+
# user_email_throttle.request!
|
28
|
+
# db_insert_throttle.request!(n_items_to_insert)
|
29
|
+
# rescue Pecorino::Throttled => e
|
30
|
+
# deliver_notification(user) if e.throttle == user_email_throttle
|
31
|
+
#
|
32
|
+
# @return [Throttle]
|
33
|
+
attr_reader :throttle
|
34
|
+
|
35
|
+
# Returns the `retry_after` value in seconds, suitable for use in an HTTP header
|
36
|
+
attr_reader :retry_after
|
37
|
+
|
38
|
+
def initialize(from_throttle, state)
|
39
|
+
@throttle = from_throttle
|
40
|
+
@retry_after = state.retry_after
|
41
|
+
super("Block in effect until #{state.blocked_until.iso8601}")
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
# @param key[String] the key for both the block record and the leaky bucket
|
46
|
+
# @param block_for[Numeric] the number of seconds to block any further requests for
|
47
|
+
# @param leaky_bucket_options Options for `Pecorino::LeakyBucket.new`
|
48
|
+
# @see PecorinoLeakyBucket.new
|
49
|
+
def initialize(key:, block_for: 30, **leaky_bucket_options)
|
50
|
+
@key = key.to_s
|
51
|
+
@block_for = block_for.to_f
|
52
|
+
@bucket = Pecorino::LeakyBucket.new(key:, **leaky_bucket_options)
|
53
|
+
end
|
54
|
+
|
55
|
+
# Tells whether the throttle will let this number of requests pass without raising
|
56
|
+
# a Throttled. Note that this is not race-safe. Another request could overflow the bucket
|
57
|
+
# after you call `able_to_accept?` but before you call `throttle!`. So before performing
|
58
|
+
# the action you still need to call `throttle!`
|
59
|
+
#
|
60
|
+
# @param n_tokens[Float]
|
61
|
+
# @return [boolean]
|
62
|
+
def able_to_accept?(n_tokens = 1)
|
63
|
+
conn = ActiveRecord::Base.connection
|
64
|
+
!blocked_until(conn) && @bucket.able_to_accept?(n_tokens)
|
65
|
+
end
|
66
|
+
|
67
|
+
# Register that a request is being performed. Will raise Throttled
|
68
|
+
# if there is a block in place on that key, or if the bucket has been filled up
|
69
|
+
# and a block has been put in place as a result of this particular request.
|
70
|
+
#
|
71
|
+
# The exception can be rescued later to provide a 429 response. This method is better
|
72
|
+
# to use before performing the unit of work that the throttle is guarding:
|
73
|
+
#
|
74
|
+
# @example t.request!
|
75
|
+
# Note.create!(note_params)
|
76
|
+
# rescue Pecorino::Throttle::Throttled => e
|
77
|
+
# [429, {"Retry-After" => e.retry_after.to_s}, []]
|
78
|
+
#
|
79
|
+
# If the method call succeeds it means that the request is not getting throttled.
|
80
|
+
#
|
81
|
+
# @return void
|
82
|
+
def request!(n = 1)
|
83
|
+
state = request(n)
|
84
|
+
raise Throttled.new(self, state) if state.blocked?
|
85
|
+
end
|
86
|
+
|
87
|
+
# Register that a request is being performed. Will not raise any exceptions but return
|
88
|
+
# the time at which the block will be lifted if a block resulted from this request or
|
89
|
+
# was already in effect. Can be used for registering actions which already took place,
|
90
|
+
# but should result in subsequent actions being blocked in subsequent requests later.
|
91
|
+
#
|
92
|
+
# @example unless t.able_to_accept?
|
93
|
+
# Note.create!(note_params)
|
94
|
+
# t.request
|
95
|
+
# else
|
96
|
+
# raise "Throttled or block in effect"
|
97
|
+
# end
|
98
|
+
#
|
99
|
+
# @return [State] the state of the throttle after filling up the leaky bucket / trying to pass the block
|
100
|
+
def request(n = 1)
|
101
|
+
conn = ActiveRecord::Base.connection
|
102
|
+
existing_blocked_until = blocked_until(conn)
|
103
|
+
return State.new(existing_blocked_until.utc) if existing_blocked_until
|
104
|
+
|
105
|
+
# Topup the leaky bucket
|
106
|
+
return State.new(nil) unless @bucket.fillup(n.to_f).full?
|
107
|
+
|
108
|
+
# and set the block if we reached it
|
109
|
+
query_params = {key: @key, block_for: @block_for}
|
110
|
+
block_set_query = ActiveRecord::Base.sanitize_sql_array([<<~SQL, query_params])
|
111
|
+
INSERT INTO pecorino_blocks AS t
|
112
|
+
(key, blocked_until)
|
113
|
+
VALUES
|
114
|
+
(:key, NOW() + ':block_for seconds'::interval)
|
115
|
+
ON CONFLICT (key) DO UPDATE SET
|
116
|
+
blocked_until = GREATEST(EXCLUDED.blocked_until, t.blocked_until)
|
117
|
+
RETURNING blocked_until;
|
118
|
+
SQL
|
119
|
+
|
120
|
+
fresh_blocked_until = conn.uncached { conn.select_value(block_set_query) }
|
121
|
+
State.new(fresh_blocked_until.utc)
|
122
|
+
end
|
123
|
+
|
124
|
+
private
|
125
|
+
|
126
|
+
def blocked_until(via_connection)
|
127
|
+
block_check_query = ActiveRecord::Base.sanitize_sql_array([<<~SQL, @key])
|
128
|
+
SELECT blocked_until FROM pecorino_blocks WHERE key = ? AND blocked_until >= NOW() LIMIT 1
|
129
|
+
SQL
|
130
|
+
via_connection.uncached { via_connection.select_value(block_check_query) }
|
131
|
+
end
|
132
|
+
end
|
data/lib/pecorino.rb
ADDED
@@ -0,0 +1,49 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "pecorino/version"
|
4
|
+
require_relative "pecorino/leaky_bucket"
|
5
|
+
require_relative "pecorino/throttle"
|
6
|
+
require_relative "pecorino/railtie" if defined?(Rails::Railtie)
|
7
|
+
|
8
|
+
module Pecorino
|
9
|
+
# Deletes stale leaky buckets and blocks which have expired. Run this method regularly to
|
10
|
+
# avoid accumulating too many unused rows in your tables.
|
11
|
+
#
|
12
|
+
# @return void
|
13
|
+
def self.prune!
|
14
|
+
# Delete all the old blocks here (if we are under a heavy swarm of requests which are all
|
15
|
+
# blocked it is probably better to avoid the big delete)
|
16
|
+
ActiveRecord::Base.connection.execute("DELETE FROM pecorino_blocks WHERE blocked_until < NOW()")
|
17
|
+
|
18
|
+
# Prune buckets which are no longer used. No "uncached" needed here since we are using "execute"
|
19
|
+
ActiveRecord::Base.connection.execute("DELETE FROM pecorino_leaky_buckets WHERE may_be_deleted_after < NOW()")
|
20
|
+
end
|
21
|
+
|
22
|
+
|
23
|
+
# Creates the tables and indexes needed for Pecorino. Call this from your migrations like so:
|
24
|
+
# class CreatePecorinoTables < ActiveRecord::Migration<%= migration_version %>
|
25
|
+
#
|
26
|
+
# def change
|
27
|
+
# Pecorino.create_tables(self)
|
28
|
+
# end
|
29
|
+
#
|
30
|
+
# @param active_record_schema[ActiveRecord::SchemaMigration] the migration through which we will create the tables
|
31
|
+
# @return void
|
32
|
+
def self.create_tables(active_record_schema)
|
33
|
+
active_record_schema.create_table :pecorino_leaky_buckets, id: :uuid do |t|
|
34
|
+
t.string :key, null: false
|
35
|
+
t.float :level, null: false
|
36
|
+
t.datetime :last_touched_at, null: false
|
37
|
+
t.datetime :may_be_deleted_after, null: false
|
38
|
+
end
|
39
|
+
active_record_schema.add_index :pecorino_leaky_buckets, [:key], unique: true
|
40
|
+
active_record_schema.add_index :pecorino_leaky_buckets, [:may_be_deleted_after]
|
41
|
+
|
42
|
+
active_record_schema.create_table :pecorino_blocks, id: :uuid do |t|
|
43
|
+
t.string :key, null: false
|
44
|
+
t.datetime :blocked_until, null: false
|
45
|
+
end
|
46
|
+
active_record_schema.add_index :pecorino_blocks, [:key], unique: true
|
47
|
+
active_record_schema.add_index :pecorino_blocks, [:blocked_until]
|
48
|
+
end
|
49
|
+
end
|
data/pecorino.gemspec
ADDED
@@ -0,0 +1,40 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "lib/pecorino/version"
|
4
|
+
|
5
|
+
Gem::Specification.new do |spec|
|
6
|
+
spec.name = "pecorino"
|
7
|
+
spec.version = Pecorino::VERSION
|
8
|
+
spec.authors = ["Julik Tarkhanov"]
|
9
|
+
spec.email = ["me@julik.nl"]
|
10
|
+
|
11
|
+
spec.summary = "Database-based rate limiter using leaky buckets"
|
12
|
+
spec.description = "Pecorino allows you to define throttles and rate meters for your metered resources, all through your standard DB"
|
13
|
+
spec.homepage = "https://github.com/cheddar-me/pecorino"
|
14
|
+
spec.license = "MIT"
|
15
|
+
spec.required_ruby_version = ">= 2.4.0"
|
16
|
+
|
17
|
+
# spec.metadata["allowed_push_host"] = "TODO: Set to 'https://mygemserver.com'"
|
18
|
+
|
19
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
20
|
+
spec.metadata["source_code_uri"] = "https://github.com/cheddar-me/pecorino"
|
21
|
+
spec.metadata["changelog_uri"] = "https://github.com/cheddar-me/pecorino/CHANGELOG.md"
|
22
|
+
|
23
|
+
# Specify which files should be added to the gem when it is released.
|
24
|
+
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
25
|
+
spec.files = Dir.chdir(File.expand_path(__dir__)) do
|
26
|
+
`git ls-files -z`.split("\x0").reject { |f| f.match(%r{\A(?:test|spec|features)/}) }
|
27
|
+
end
|
28
|
+
spec.bindir = "exe"
|
29
|
+
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
|
30
|
+
spec.require_paths = ["lib"]
|
31
|
+
|
32
|
+
# Uncomment to register a new dependency of your gem
|
33
|
+
spec.add_dependency "activerecord", "~> 7"
|
34
|
+
spec.add_dependency "pg"
|
35
|
+
spec.add_development_dependency "activesupport", "~> 7"
|
36
|
+
spec.add_development_dependency "rails", "~> 7"
|
37
|
+
|
38
|
+
# For more information and examples about making a new gem, checkout our
|
39
|
+
# guide at: https://bundler.io/guides/creating_gem.html
|
40
|
+
end
|
metadata
ADDED
@@ -0,0 +1,121 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: pecorino
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Julik Tarkhanov
|
8
|
+
autorequire:
|
9
|
+
bindir: exe
|
10
|
+
cert_chain: []
|
11
|
+
date: 2023-11-01 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: activerecord
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '7'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '7'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: pg
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ">="
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '0'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - ">="
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '0'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: activesupport
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '7'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '7'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: rails
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - "~>"
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '7'
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - "~>"
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '7'
|
69
|
+
description: Pecorino allows you to define throttles and rate meters for your metered
|
70
|
+
resources, all through your standard DB
|
71
|
+
email:
|
72
|
+
- me@julik.nl
|
73
|
+
executables: []
|
74
|
+
extensions: []
|
75
|
+
extra_rdoc_files: []
|
76
|
+
files:
|
77
|
+
- ".github/workflows/main.yml"
|
78
|
+
- ".gitignore"
|
79
|
+
- ".ruby-version"
|
80
|
+
- CHANGELOG.md
|
81
|
+
- CODE_OF_CONDUCT.md
|
82
|
+
- Gemfile
|
83
|
+
- Gemfile.lock
|
84
|
+
- LICENSE.txt
|
85
|
+
- README.md
|
86
|
+
- Rakefile
|
87
|
+
- lib/pecorino.rb
|
88
|
+
- lib/pecorino/install_generator.rb
|
89
|
+
- lib/pecorino/leaky_bucket.rb
|
90
|
+
- lib/pecorino/migrations/create_raclette_tables.rb.erb
|
91
|
+
- lib/pecorino/railtie.rb
|
92
|
+
- lib/pecorino/throttle.rb
|
93
|
+
- lib/pecorino/version.rb
|
94
|
+
- pecorino.gemspec
|
95
|
+
homepage: https://github.com/cheddar-me/pecorino
|
96
|
+
licenses:
|
97
|
+
- MIT
|
98
|
+
metadata:
|
99
|
+
homepage_uri: https://github.com/cheddar-me/pecorino
|
100
|
+
source_code_uri: https://github.com/cheddar-me/pecorino
|
101
|
+
changelog_uri: https://github.com/cheddar-me/pecorino/CHANGELOG.md
|
102
|
+
post_install_message:
|
103
|
+
rdoc_options: []
|
104
|
+
require_paths:
|
105
|
+
- lib
|
106
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
107
|
+
requirements:
|
108
|
+
- - ">="
|
109
|
+
- !ruby/object:Gem::Version
|
110
|
+
version: 2.4.0
|
111
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
112
|
+
requirements:
|
113
|
+
- - ">="
|
114
|
+
- !ruby/object:Gem::Version
|
115
|
+
version: '0'
|
116
|
+
requirements: []
|
117
|
+
rubygems_version: 3.3.3
|
118
|
+
signing_key:
|
119
|
+
specification_version: 4
|
120
|
+
summary: Database-based rate limiter using leaky buckets
|
121
|
+
test_files: []
|