pecorino 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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
@@ -0,0 +1,8 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /_yardoc/
4
+ /coverage/
5
+ /doc/
6
+ /pkg/
7
+ /spec/reports/
8
+ /tmp/
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 3.1.0
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2023-10-30
4
+
5
+ - Initial release
@@ -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
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ # Specify your gem's dependencies in pecorino.gemspec
6
+ gemspec
7
+
8
+ gem "rake", "~> 13.0"
9
+
10
+ gem "minitest", "~> 5.0"
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,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rake/testtask"
5
+
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.libs << "test"
8
+ t.libs << "lib"
9
+ t.test_files = FileList["test/**/*_test.rb"]
10
+ end
11
+
12
+ task default: :test
@@ -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,5 @@
1
+ class CreatePecorinoTables < ActiveRecord::Migration<%= migration_version %>
2
+ def change
3
+ Pecorino.create_tables(self)
4
+ end
5
+ end
@@ -0,0 +1,7 @@
1
+ module Pecorino
2
+ class Railtie < Rails::Railtie
3
+ generators do
4
+ require_relative "install_generator"
5
+ end
6
+ end
7
+ 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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pecorino
4
+ VERSION = "0.1.0"
5
+ 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: []