pecorino 0.7.1 → 0.7.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +42 -19
- data/.gitignore +1 -0
- data/.yardopts +1 -0
- data/CHANGELOG.md +8 -0
- data/Gemfile +12 -1
- data/README.md +31 -10
- data/Rakefile +13 -1
- data/gemfiles/Gemfile_ruby27_rails7 +19 -0
- data/gemfiles/Gemfile_ruby30_rails8 +15 -0
- data/lib/pecorino/adapters/base_adapter.rb +1 -1
- data/lib/pecorino/cached_throttle.rb +6 -2
- data/lib/pecorino/throttle.rb +12 -11
- data/lib/pecorino/version.rb +1 -1
- data/lib/pecorino.rb +2 -2
- data/pecorino.gemspec +1 -14
- data/rbi/pecorino.rbi +909 -0
- data/rbi/pecorino.rbs +791 -0
- data/test/adapters/adapter_test_methods.rb +259 -0
- data/test/adapters/memory_adapter_test.rb +12 -0
- data/test/adapters/postgres_adapter_test.rb +69 -0
- data/test/adapters/redis_adapter_test.rb +27 -0
- data/test/adapters/sqlite_adapter_test.rb +46 -0
- data/test/block_test.rb +23 -0
- data/test/cached_throttle_test.rb +100 -0
- data/test/leaky_bucket_test.rb +161 -0
- data/test/pecorino_test.rb +9 -0
- data/test/test_helper.rb +12 -0
- data/test/throttle_test.rb +119 -0
- metadata +19 -138
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: c0e0c8fa1a6bc734dc645b5b021264eda84bcadd1bec8da65c533a33cd243fb6
|
4
|
+
data.tar.gz: c6c053d8ed02f8e180786d4614c607087ca76b016b8acac35b35a3b3cb544391
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 02207b3f3d52aa635a960c3914768237a25da4b4cdd7a5ad5da222adf44e738ac987f5182392d0617c60848c745fb0cc5225e2e2392dff50b6e83f58b703d191
|
7
|
+
data.tar.gz: 350d04b9e366d229d87146465574632a00e5e0c88eda7cbc540b494d8ab1d3520d87092f5ae6a6b11f0dd323cc7fd15436728c9b6210fe2264be384b0e9505b4
|
data/.github/workflows/ci.yml
CHANGED
@@ -1,21 +1,46 @@
|
|
1
1
|
name: CI
|
2
2
|
|
3
3
|
on:
|
4
|
-
|
4
|
+
pull_request:
|
5
|
+
push:
|
6
|
+
branches: [ main ]
|
5
7
|
|
6
8
|
env:
|
9
|
+
PGHOST: localhost
|
10
|
+
PGUSER: postgres
|
11
|
+
PGPASSWORD: postgres
|
7
12
|
BUNDLE_PATH: vendor/bundle
|
8
13
|
|
9
14
|
jobs:
|
15
|
+
lint:
|
16
|
+
name: "Lint"
|
17
|
+
runs-on: ubuntu-latest
|
18
|
+
env:
|
19
|
+
BUNDLE_GEMFILE: ${{ github.workspace }}/gemfiles/Gemfile_ruby27_rails7 # Linting should always align with the minimum Ruby version
|
20
|
+
steps:
|
21
|
+
- name: Checkout code
|
22
|
+
uses: actions/checkout@v4
|
23
|
+
|
24
|
+
- name: Set up Ruby
|
25
|
+
uses: ruby/setup-ruby@v1
|
26
|
+
with:
|
27
|
+
ruby-version: 2.7.7
|
28
|
+
bundler-cache: true
|
29
|
+
|
30
|
+
- name: Lint code for consistent style
|
31
|
+
run: bundle exec standardrb
|
32
|
+
|
10
33
|
test:
|
11
|
-
name: Tests
|
12
|
-
runs-on: ubuntu-
|
13
|
-
if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository
|
34
|
+
name: "Tests"
|
35
|
+
runs-on: ubuntu-latest
|
14
36
|
strategy:
|
37
|
+
fail-fast: false # We want both to run to completion
|
15
38
|
matrix:
|
16
|
-
|
17
|
-
-
|
18
|
-
-
|
39
|
+
gemfile_and_ruby:
|
40
|
+
- ["/gemfiles/Gemfile_ruby27_rails7", "2.7.7"]
|
41
|
+
- ["/gemfiles/Gemfile_ruby30_rails8", "3.2.2"]
|
42
|
+
env:
|
43
|
+
BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile_and_ruby[0] }}
|
19
44
|
services:
|
20
45
|
postgres:
|
21
46
|
image: postgres
|
@@ -30,19 +55,17 @@ jobs:
|
|
30
55
|
ports:
|
31
56
|
- 6379:6379
|
32
57
|
steps:
|
33
|
-
- name:
|
58
|
+
- name: Install packages
|
59
|
+
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y curl libjemalloc2 sqlite3
|
60
|
+
|
61
|
+
- name: Checkout code
|
34
62
|
uses: actions/checkout@v4
|
35
|
-
|
63
|
+
|
64
|
+
- name: Set up Ruby
|
36
65
|
uses: ruby/setup-ruby@v1
|
37
66
|
with:
|
38
|
-
ruby-version:
|
67
|
+
ruby-version: ${{ matrix.gemfile_and_ruby[1] }}
|
39
68
|
bundler-cache: true
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
PGHOST: localhost
|
44
|
-
PGUSER: postgres
|
45
|
-
PGPASSWORD: postgres
|
46
|
-
TESTOPTS: "--fail-fast"
|
47
|
-
# MYSQL_HOST: 127.0.0.1
|
48
|
-
# MYSQL_PORT: 3306
|
69
|
+
|
70
|
+
- name: Run tests
|
71
|
+
run: bundle exec rake test
|
data/.gitignore
CHANGED
data/.yardopts
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
--markup markdown - README.md CHANGELOG.md LICENSE.txt
|
data/CHANGELOG.md
CHANGED
@@ -1,3 +1,11 @@
|
|
1
|
+
## 0.7.3
|
2
|
+
|
3
|
+
- Fix a number of YARD issues and generate both .rbi and .rbs typedefs
|
4
|
+
|
5
|
+
## 0.7.2
|
6
|
+
|
7
|
+
- Set up a workable test harness for testing on both Rails 8 (Ruby 3.x) and Rails 7 (Ruby 2.x)
|
8
|
+
|
1
9
|
## 0.7.1
|
2
10
|
|
3
11
|
- Release dependency constraint to permit Rails 8 (not thoroughly validated yet)
|
data/Gemfile
CHANGED
@@ -2,5 +2,16 @@
|
|
2
2
|
|
3
3
|
source "https://rubygems.org"
|
4
4
|
|
5
|
-
|
5
|
+
ruby ">= 3.0"
|
6
6
|
gemspec
|
7
|
+
|
8
|
+
gem "pg"
|
9
|
+
gem "sqlite3"
|
10
|
+
gem "activesupport", ">= 8"
|
11
|
+
gem "rake", "~> 13.0"
|
12
|
+
gem "minitest", "~> 5.0"
|
13
|
+
gem "redis", "~> 5", "< 6"
|
14
|
+
gem "yard"
|
15
|
+
gem "standard"
|
16
|
+
gem "sord"
|
17
|
+
gem "redcarpet"
|
data/README.md
CHANGED
@@ -31,7 +31,7 @@ Once the installation is done you can use Pecorino to start defining your thrott
|
|
31
31
|
We call this pattern **prefix usage** - apply throttle before allowing the action to proceed. This is more secure than registering an action after it has taken place.
|
32
32
|
|
33
33
|
```ruby
|
34
|
-
throttle = Pecorino::Throttle.new(key: "password-attempts-#{
|
34
|
+
throttle = Pecorino::Throttle.new(key: "password-attempts-#{the_request.ip}", over_time: 1.minute, capacity: 5, block_for: 30.minutes)
|
35
35
|
throttle.request!
|
36
36
|
```
|
37
37
|
In a Rails controller you can then rescue from this exception to render the appropriate response:
|
@@ -119,11 +119,11 @@ class WalletController < ApplicationController
|
|
119
119
|
end
|
120
120
|
|
121
121
|
def withdraw
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
122
|
+
Wallet.transaction do
|
123
|
+
t = Pecorino::Throttle.new("wallet_#{current_user.id}_max_withdrawal", capacity: 200_00, over_time: 5.minutes)
|
124
|
+
t.request!(10_00)
|
125
|
+
current_user.wallet.withdraw(Money.new(10, "EUR"))
|
126
|
+
end
|
127
127
|
end
|
128
128
|
end
|
129
129
|
```
|
@@ -182,12 +182,26 @@ We recommend running the following bit of code every couple of hours (via cron o
|
|
182
182
|
Pecorino.prune!
|
183
183
|
```
|
184
184
|
|
185
|
+
## Testing your application
|
186
|
+
|
187
|
+
The Pecorino buckets and blocks are stateful. If you are not running tests with a transaction rollback, the rate limiters that got hit in a test case may interfere with other test cases you are running. Normally you will not notice this (if you are using the same database as the rest of your models), but we recommend adding this section to your global test case setup:
|
188
|
+
|
189
|
+
```ruby
|
190
|
+
setup do
|
191
|
+
# Delete all transient records
|
192
|
+
ActiveRecord::Base.connection.execute("TRUNCATE TABLE pecorino_blocks")
|
193
|
+
ActiveRecord::Base.connection.execute("TRUNCATE TABLE pecorino_leaky_buckets")
|
194
|
+
end
|
195
|
+
```
|
196
|
+
|
197
|
+
If you are using Redis, you may want to ensure it gets truncated/reset for every test case - or that parallel test case runners [each use a separate Redis database.](https://redis.io/docs/latest/commands/select/)
|
198
|
+
|
185
199
|
## Using cached throttles
|
186
200
|
|
187
201
|
If a throttle is triggered, Pecorino sets a "block" record for that throttle key. Any request to that throttle will fail until the block is lifted. If you are getting hammered by requests which are getting throttled, it might be a good idea to install a caching layer which will respond with a "rate limit exceeded" error even before hitting your database - until the moment when the block would be lifted. You can use any [ActiveSupport::Cache::Store](https://api.rubyonrails.org/classes/ActiveSupport/Cache/Store.html) to store your blocks. If you have a fast Rails cache configured, create a wrapped throttle:
|
188
202
|
|
189
203
|
```ruby
|
190
|
-
throttle = Pecorino::Throttle.new(key: "ip-#{
|
204
|
+
throttle = Pecorino::Throttle.new(key: "ip-#{the_request.ip}", capacity: 10, over_time: 2.seconds, block_for: 2.minutes)
|
191
205
|
cached_throttle = Pecorino::CachedThrottle.new(Rails.cache, throttle)
|
192
206
|
cached_throttle.request!
|
193
207
|
```
|
@@ -200,7 +214,7 @@ config.pecorino_throttle_cache = ActiveSupport::Cache::MemoryStore.new
|
|
200
214
|
|
201
215
|
# in your controller
|
202
216
|
|
203
|
-
throttle = Pecorino::Throttle.new(key: "ip-#{
|
217
|
+
throttle = Pecorino::Throttle.new(key: "ip-#{the_request.ip}", capacity: 10, over_time: 2.seconds, block_for: 2.minutes)
|
204
218
|
cached_throttle = Pecorino::CachedThrottle.new(Rails.application.config.pecorino_throttle_cache, throttle)
|
205
219
|
cached_throttle.request!
|
206
220
|
```
|
@@ -216,9 +230,16 @@ ActiveRecord::Base.connection.execute("ALTER TABLE pecorino_blocks SET UNLOGGED"
|
|
216
230
|
|
217
231
|
## Development
|
218
232
|
|
219
|
-
After checking out the repo, run `bundle
|
233
|
+
After checking out the repo, run `bundle install` and then do the thing you need to do.
|
234
|
+
|
235
|
+
**Note:** CI runs other Gemfiles, because we can't test all Ruby versions and Rails versions just by swapping Gemfiles. If you need to debug something with a particular Ruby and Rails version, do this:
|
236
|
+
|
237
|
+
```bash
|
238
|
+
$ bundle rbenv local 2.7.7 && export BUNDLE_GEMFILE=gemfiles/Gemfile_ruby27_rails7 && bundle install
|
239
|
+
$ bundle exec rake
|
240
|
+
```
|
220
241
|
|
221
|
-
|
242
|
+
Then proceed as normal. Make sure to unset `BUNDLE_GEMFILE` when you are done. CI will run both the oldest supported dependencies and newest supported dependencies.
|
222
243
|
|
223
244
|
## Contributing
|
224
245
|
|
data/Rakefile
CHANGED
@@ -3,6 +3,9 @@
|
|
3
3
|
require "bundler/gem_tasks"
|
4
4
|
require "rake/testtask"
|
5
5
|
require "standard/rake"
|
6
|
+
require "yard"
|
7
|
+
|
8
|
+
YARD::Rake::YardocTask.new(:doc)
|
6
9
|
|
7
10
|
Rake::TestTask.new(:test) do |t|
|
8
11
|
t.libs << "test"
|
@@ -15,4 +18,13 @@ task :format do
|
|
15
18
|
`bundle exec magic_frozen_string_literal .`
|
16
19
|
end
|
17
20
|
|
18
|
-
task
|
21
|
+
task :generate_typedefs do
|
22
|
+
`bundle exec sord rbi/pecorino.rbi`
|
23
|
+
`bundle exec sord rbi/pecorino.rbs`
|
24
|
+
end
|
25
|
+
|
26
|
+
task default: [:test, :standard, :generate_typedefs]
|
27
|
+
|
28
|
+
# When building the gem, generate typedefs beforehand,
|
29
|
+
# so that they get included
|
30
|
+
Rake::Task["build"].enhance(["generate_typedefs"])
|
@@ -0,0 +1,19 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
source "https://rubygems.org"
|
4
|
+
|
5
|
+
ruby ">= 2.7", "< 3.0"
|
6
|
+
|
7
|
+
gemspec path: ".."
|
8
|
+
|
9
|
+
gem "pg"
|
10
|
+
gem "sqlite3", "< 1.7"
|
11
|
+
gem "activesupport", "< 8.0"
|
12
|
+
gem "rake", "~> 13.0"
|
13
|
+
gem "minitest", "~> 5.0"
|
14
|
+
gem "redis", "~> 5", "< 6"
|
15
|
+
gem "yard"
|
16
|
+
gem "standard"
|
17
|
+
gem "rubocop", "< 1.65" # "`EnsureNode#body` is deprecated and will be changed"...
|
18
|
+
gem "magic_frozen_string_literal"
|
19
|
+
gem "sord"
|
@@ -0,0 +1,15 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
source "https://rubygems.org"
|
4
|
+
|
5
|
+
ruby ">= 3.0"
|
6
|
+
gemspec path: ".."
|
7
|
+
|
8
|
+
gem "pg"
|
9
|
+
gem "sqlite3"
|
10
|
+
gem "activesupport", ">= 8"
|
11
|
+
gem "rake", "~> 13.0"
|
12
|
+
gem "minitest", "~> 5.0"
|
13
|
+
gem "redis", "~> 5", "< 6"
|
14
|
+
gem "yard" # Not used under this Ruby version but the Rakefile includes it
|
15
|
+
gem "standard" # Not used but the Rakefile includes it
|
@@ -1,7 +1,7 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
# An adapter allows Pecorino throttles, leaky buckets and other
|
4
|
-
# resources to
|
4
|
+
# resources to interface with a data storage backend - a database, usually.
|
5
5
|
class Pecorino::Adapters::BaseAdapter
|
6
6
|
# Returns the state of a leaky bucket. The state should be a tuple of two
|
7
7
|
# values: the current level (Float) and whether the bucket is now at capacity (Boolean)
|
@@ -15,6 +15,10 @@ class Pecorino::CachedThrottle
|
|
15
15
|
@throttle = throttle
|
16
16
|
end
|
17
17
|
|
18
|
+
# Increments the cached throttle by the given number of tokens. If there is currently a known cached block on that throttle
|
19
|
+
# an exception will be raised immediately instead of querying the actual throttle data. Otherwise the call gets forwarded
|
20
|
+
# to the underlying throttle.
|
21
|
+
#
|
18
22
|
# @see Pecorino::Throttle#request!
|
19
23
|
def request!(n = 1)
|
20
24
|
blocked_state = read_cached_blocked_state
|
@@ -28,9 +32,9 @@ class Pecorino::CachedThrottle
|
|
28
32
|
end
|
29
33
|
end
|
30
34
|
|
31
|
-
# Returns cached `state` for the throttle if there is a currently active block for that throttle in the cache. Otherwise forwards to underlying throttle.
|
35
|
+
# Returns the cached `state` for the throttle if there is a currently active block for that throttle in the cache. Otherwise forwards to underlying throttle.
|
32
36
|
#
|
33
|
-
# @see Pecorino::Throttle#request
|
37
|
+
# @see Pecorino::Throttle#request!
|
34
38
|
def request(n = 1)
|
35
39
|
blocked_state = read_cached_blocked_state
|
36
40
|
return blocked_state if blocked_state&.blocked?
|
data/lib/pecorino/throttle.rb
CHANGED
@@ -91,7 +91,7 @@ class Pecorino::Throttle
|
|
91
91
|
|
92
92
|
# The key for that throttle. Each key defines a unique throttle based on either a given name or
|
93
93
|
# discriminators. If there is a component you want to key your throttle by, include it in the
|
94
|
-
# `key` keyword argument to the constructor, like `"t-ip-#{
|
94
|
+
# `key` keyword argument to the constructor, like `"t-ip-#{your_rails_request.ip}"`
|
95
95
|
#
|
96
96
|
# @return [String]
|
97
97
|
attr_reader :key
|
@@ -100,8 +100,8 @@ class Pecorino::Throttle
|
|
100
100
|
# @param block_for[Numeric] the number of seconds to block any further requests for. Defaults to time it takes
|
101
101
|
# the bucket to leak out to the level of 0
|
102
102
|
# @param adapter[Pecorino::Adapters::BaseAdapter] a compatible adapter
|
103
|
-
# @param leaky_bucket_options Options for
|
104
|
-
# @see
|
103
|
+
# @param leaky_bucket_options Options for {Pecorino::LeakyBucket.new}
|
104
|
+
# @see Pecorino::LeakyBucket.new
|
105
105
|
def initialize(key:, block_for: nil, adapter: Pecorino.adapter, **leaky_bucket_options)
|
106
106
|
@adapter = adapter
|
107
107
|
leaky_bucket_options.delete(:adapter)
|
@@ -129,16 +129,16 @@ class Pecorino::Throttle
|
|
129
129
|
# The exception can be rescued later to provide a 429 response. This method is better
|
130
130
|
# to use before performing the unit of work that the throttle is guarding:
|
131
131
|
#
|
132
|
+
# If the method call returns it means that the request is not getting throttled.
|
133
|
+
#
|
132
134
|
# @example
|
133
135
|
# begin
|
134
|
-
#
|
135
|
-
#
|
136
|
+
# t.request!
|
137
|
+
# Note.create!(note_params)
|
136
138
|
# rescue Pecorino::Throttle::Throttled => e
|
137
|
-
#
|
139
|
+
# [429, {"Retry-After" => e.retry_after.to_s}, []]
|
138
140
|
# end
|
139
|
-
#
|
140
|
-
# If the method call succeeds it means that the request is not getting throttled.
|
141
|
-
#
|
141
|
+
# @param n [Numeric] how many tokens to place into the bucket or remove from the bucket. May be fractional or negative.
|
142
142
|
# @return [State] the state of the throttle after filling up the leaky bucket / trying to pass the block
|
143
143
|
def request!(n = 1)
|
144
144
|
request(n).tap do |state_after|
|
@@ -156,8 +156,8 @@ class Pecorino::Throttle
|
|
156
156
|
# Entry.create!(entry_params)
|
157
157
|
# t.request
|
158
158
|
# end
|
159
|
-
#
|
160
|
-
# @return [State] the state of the throttle after
|
159
|
+
# @param n [Numeric] how many tokens to place into the bucket or remove from the bucket. May be fractional or negative.
|
160
|
+
# @return [State] the state of the throttle after the attempt to fill up the leaky bucket
|
161
161
|
def request(n = 1)
|
162
162
|
existing_blocked_until = Pecorino::Block.blocked_until(key: @key, adapter: @adapter)
|
163
163
|
return State.new(existing_blocked_until.utc) if existing_blocked_until
|
@@ -181,6 +181,7 @@ class Pecorino::Throttle
|
|
181
181
|
# @example
|
182
182
|
# t.throttled { Slack.alert("Things are going wrong") }
|
183
183
|
#
|
184
|
+
# @param blk The block to run. Will only run if the throttle accepts the call.
|
184
185
|
# @return [Object] the return value of the block if the block gets executed, or `nil` if the call got throttled
|
185
186
|
def throttled(&blk)
|
186
187
|
return if request(1).blocked?
|
data/lib/pecorino/version.rb
CHANGED
data/lib/pecorino.rb
CHANGED
@@ -60,9 +60,9 @@ module Pecorino
|
|
60
60
|
|
61
61
|
# Returns the database implementation for setting the values atomically. Since the implementation
|
62
62
|
# differs per database, this method will return a different adapter depending on which database is
|
63
|
-
# being used
|
63
|
+
# being used.
|
64
64
|
#
|
65
|
-
# @
|
65
|
+
# @return [Pecorino::Adapters::BaseAdapter]
|
66
66
|
def self.default_adapter_from_main_database
|
67
67
|
model_class = ActiveRecord::Base
|
68
68
|
adapter_name = model_class.connection.adapter_name
|
data/pecorino.gemspec
CHANGED
@@ -23,24 +23,11 @@ Gem::Specification.new do |spec|
|
|
23
23
|
# Specify which files should be added to the gem when it is released.
|
24
24
|
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
25
25
|
spec.files = Dir.chdir(File.expand_path(__dir__)) do
|
26
|
-
`git ls-files -z`.split("\x0")
|
26
|
+
`git ls-files -z`.split("\x0")
|
27
27
|
end
|
28
28
|
spec.bindir = "exe"
|
29
29
|
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
|
30
30
|
spec.require_paths = ["lib"]
|
31
31
|
|
32
|
-
# Uncomment to register a new dependency of your gem
|
33
32
|
spec.add_dependency "activerecord", ">= 7"
|
34
|
-
spec.add_development_dependency "pg"
|
35
|
-
spec.add_development_dependency "sqlite3"
|
36
|
-
spec.add_development_dependency "activesupport", ">= 7"
|
37
|
-
spec.add_development_dependency "rake", "~> 13.0"
|
38
|
-
spec.add_development_dependency "minitest", "~> 5.0"
|
39
|
-
spec.add_development_dependency "standard"
|
40
|
-
spec.add_development_dependency "magic_frozen_string_literal"
|
41
|
-
spec.add_development_dependency "minitest-fail-fast"
|
42
|
-
spec.add_development_dependency "redis", "~> 5", "< 6"
|
43
|
-
|
44
|
-
# For more information and examples about making a new gem, checkout our
|
45
|
-
# guide at: https://bundler.io/guides/creating_gem.html
|
46
33
|
end
|