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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a3a2879a7549a5e41367824a5abccc4fa6cf54b8d78dd1b042f97e8a7bb5f401
4
- data.tar.gz: 870bac3b9a9cb8e6dfca8b738022deb25db8d8ce33b027a9f3dd2268e3000971
3
+ metadata.gz: c0e0c8fa1a6bc734dc645b5b021264eda84bcadd1bec8da65c533a33cd243fb6
4
+ data.tar.gz: c6c053d8ed02f8e180786d4614c607087ca76b016b8acac35b35a3b3cb544391
5
5
  SHA512:
6
- metadata.gz: 83f230bf892dd891ef913238c1bc403cc5d9734afaea5b66698109e8792b93197af0de10fc6a9a7716dcbc64ead32acf75c64f13e60708bcd9855e9840e5e203
7
- data.tar.gz: b6d4bcbb64558f08082f2603d3c791c6b951ef993fb0090e8e2b5998edeae4346dec618e667f365f0f9042453a29b148327a78e5c789f5bea602d6aadd9d5e62
6
+ metadata.gz: 02207b3f3d52aa635a960c3914768237a25da4b4cdd7a5ad5da222adf44e738ac987f5182392d0617c60848c745fb0cc5225e2e2392dff50b6e83f58b703d191
7
+ data.tar.gz: 350d04b9e366d229d87146465574632a00e5e0c88eda7cbc540b494d8ab1d3520d87092f5ae6a6b11f0dd323cc7fd15436728c9b6210fe2264be384b0e9505b4
@@ -1,21 +1,46 @@
1
1
  name: CI
2
2
 
3
3
  on:
4
- - push
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-22.04
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
- ruby:
17
- - '2.7'
18
- - '3.2'
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: Checkout
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
- - name: Setup Ruby
63
+
64
+ - name: Set up Ruby
36
65
  uses: ruby/setup-ruby@v1
37
66
  with:
38
- ruby-version: ${{ matrix.ruby }}
67
+ ruby-version: ${{ matrix.gemfile_and_ruby[1] }}
39
68
  bundler-cache: true
40
- - name: "Tests and Lint"
41
- run: bundle exec rake
42
- env:
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
@@ -7,4 +7,5 @@
7
7
  /spec/reports/
8
8
  /tmp/
9
9
  Gemfile.lock
10
+ gemfiles/*.lock
10
11
  .ruby-version
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
- # Specify your gem's dependencies in pecorino.gemspec
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-#{request.ip}", over_time: 1.minute, capacity: 5, block_for: 30.minutes)
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
- 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
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-#{request.ip}", capacity: 10, over_time: 2.seconds, block_for: 2.minutes)
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-#{request.ip}", capacity: 10, over_time: 2.seconds, block_for: 2.minutes)
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`. Then, run `rake test` to run the tests.
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
- 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).
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 default: [:test, :standard]
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 interfact to a data storage backend - a database, usually.
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?
@@ -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-#{request.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 `Pecorino::LeakyBucket.new`
104
- # @see PecorinoLeakyBucket.new
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
- # t.request!
135
- # Note.create!(note_params)
136
+ # t.request!
137
+ # Note.create!(note_params)
136
138
  # rescue Pecorino::Throttle::Throttled => e
137
- # [429, {"Retry-After" => e.retry_after.to_s}, []]
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 filling up the leaky bucket / trying to pass the block
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?
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pecorino
4
- VERSION = "0.7.1"
4
+ VERSION = "0.7.3"
5
5
  end
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
- # @param adapter[Pecorino::Adapters::BaseAdapter]
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").reject { |f| f.match(%r{\A(?:test|spec|features)/}) }
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