pecorino 0.4.1 → 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1326301c772aaee4b63feaba7e29b385c274d4f6b256a3b413d3b7c094654db8
4
- data.tar.gz: 8a3973b17c1ded234abcc76937de9225a0099dd4445aefd313a7ade0d9ecb843
3
+ metadata.gz: 2cd65abe4917de817a0b0b08672376d5a7dd1e16c01b3a4e0c47ff1467600a1e
4
+ data.tar.gz: 58ca1578813b7a5bcc058d9a37a869e778a1614b736becced4bc82f78efba2c5
5
5
  SHA512:
6
- metadata.gz: 7d8462bc60d93b6cbbd4729a14fdf84fb9abd18f9b3ce539dc110784cc56f5a9fe2bd107eb370a9caff1e229200a6b345ca3e73ac95a98013a618923e963c8fc
7
- data.tar.gz: ad2d89319ba89786e0e2befc3449bb9d1211d0555419a9118df5ba42aa1744ad617b4b27686928fb1fc6ac9f0368a93247d4208beb1b9fab7881322779e11616
6
+ metadata.gz: bbbcdc936bef119b1b02695dc626f8421576a619414f4493a541876562ca3cf5136dcdd1a796c9c08cd6cc2a8345be8ac7fecd8f6371fddc9b480bd26d017296
7
+ data.tar.gz: cd4e2ba40164eb0c9f56e17aeef656727703cb98f7e70cedaafa3d9af9202195f9c0e1a1dbc3dfdb411f277f31e84a6a2641d5f81bf85d6d07b3b92937877cbd
data/CHANGELOG.md CHANGED
@@ -1,3 +1,11 @@
1
+ ## [0.5.0] - 2024-02-11
2
+
3
+ - Add `CachedThrottle` for caching the throttle blocks. This allows protection to the database when the throttle is in a blocked state.
4
+ - Add `Throttle#throttled` for silencing alerts
5
+ - **BREAKING CHANGE** Remove `Throttle::State#retry_after`, because there is no reasonable value for that member if the throttle is not in the "blocked" state
6
+ - Allow accessing `Throttle::State` from the `Throttled` exception so that the blocked throttle state can be cached downstream (in Rails cache, for example)
7
+ - Make `Throttle#request!` return the new state if there was no exception raised
8
+
1
9
  ## [0.4.1] - 2024-02-11
2
10
 
3
11
  - Make sure Pecorino works on Ruby 2.7 as well by removing 3.x-exclusive syntax
data/README.md CHANGED
@@ -24,8 +24,10 @@ And then execute:
24
24
 
25
25
  Once the installation 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 would overspill (your request would make it overflow), or the throttle is currently in "block" mode (has recently been triggered), a `Pecorino::Throttle::Throttled` exception will be raised.
26
26
 
27
+ 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.
28
+
27
29
  ```ruby
28
- throttle = Pecorino::Throttle.new(key: "vault", over_time: 1.second, capacity: 5)
30
+ throttle = Pecorino::Throttle.new(key: "password-attempts-#{request.ip}", over_time: 1.minute, capacity: 5, block_for: 30.minutes)
29
31
  throttle.request!
30
32
  ```
31
33
  In a Rails controller you can then rescue from this exception to render the appropriate response:
@@ -67,6 +69,40 @@ throttle.request!(20) # Attempt to withdraw 20 dollars more
67
69
  throttle.request!(2) # Attempt to withdraw 2 dollars more, will raise `Throttled` and block withdrawals for 3 hours
68
70
  ```
69
71
 
72
+ ## Performing a block only if it would be allowed by the throttle
73
+
74
+ You can use Pecorino to avoid nuisance alerting - use it to limit the alert rate:
75
+
76
+ ```ruby
77
+ alert_nuisance_t = Pecorino::Throttle.new(key: "disk-full-alert", over_time_: 2.hours, capacity: 1, block_for: 2.hours)
78
+ alert_nuisance_t.throttled do
79
+ Slack.alerts.deliver("Disk is full again! please investigate!")
80
+ end
81
+ ```
82
+
83
+ This will not raise any exceptions. The `throttled` method performs **prefix throttling** to prevent multiple callers hitting the throttle at the same time, so it is guaranteed to be atomic.
84
+
85
+ ## Postfix topup of the throttle
86
+
87
+ In addition to use case where you would want to trigger the throttle before performing an action, there are legitimate use cases where you actually want to use the throttle as a _meter_ instead, measuring the effect of an action which has already been permitted – and then only make it trigger on a subsequent action. This **postfix usage** is less secure, but it allows for a different sequencing of calls. Imagine you want to implement the popular [circuit breaker pattern](https://dzone.com/articles/introduction-to-the-circuit-breaker-pattern) where all your nodes are able to share the error rate information between them. Pecorino gives you all the tools to implement a binary state circuit breaker (open or closed) based on an error rate. Imagine you want to stop sending requests if the service you are calling raises `Timeout::Error` frequently. Then your call to the service could look like this:
88
+
89
+ ```ruby
90
+ begin
91
+ error_rate_throttle = Pecorino::Throttle.new("some-fancy-ai-api-errors", capacity: 10, over_time: 30.seconds, block_for: 120.seconds)
92
+
93
+ if error_rate_throttle.able_to_accept? # See whether adding 1 request will overflow the error rate
94
+ fancy_ai_api.post_chat_message("Imagine I am a rocket scientist on a moonbase. Invent me...")
95
+ else
96
+ raise "The error rate for fancy_ai_api has been exceeded"
97
+ end
98
+ rescue Timeout::Error
99
+ error_rate_throttle.request(1) # use bang-less method since we do not need the Throttled exception
100
+ raise
101
+ end
102
+ ```
103
+
104
+ This way, every time there is an error on the "fancy AI service" the throttle will be triggered, and if it overflows - a subsequent request will be blocked.
105
+
70
106
  ## Using just the leaky bucket
71
107
 
72
108
  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:
@@ -90,6 +126,29 @@ We recommend running the following bit of code every couple of hours (via cron o
90
126
  Pecorino.prune!
91
127
  ```
92
128
 
129
+ ## Using cached throttles
130
+
131
+ 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:
132
+
133
+ ```ruby
134
+ throttle = Pecorino::Throttle.new(key: "ip-#{request.ip}", capacity: 10, over_time: 2.seconds, block_for: 2.minutes)
135
+ cached_throttle = Pecorino::CachedThrottle.new(Rails.cache, throttle)
136
+ cached_throttle.request!
137
+ ```
138
+
139
+ Note that the idea of using a cache store here is to avoid hitting the database when the block for your throttle is in effect. Therefore, if you are using something like [solid_cache](https://github.com/rails/solid_cache) you will be hitting the database regardless! A better approach is to have a [MemoryStore](https://api.rubyonrails.org/classes/ActiveSupport/Cache/MemoryStore.html) just for throttles - it will be local to your Rails process. This will avoid a database roundtrip once the process knows a particular throttle is being blocked at the moment:
140
+
141
+ ```ruby
142
+ # in application.rb
143
+ config.pecorino_throttle_cache = ActiveSupport::Cache::MemoryStore.new
144
+
145
+ # in your controller
146
+
147
+ throttle = Pecorino::Throttle.new(key: "ip-#{request.ip}", capacity: 10, over_time: 2.seconds, block_for: 2.minutes)
148
+ cached_throttle = Pecorino::CachedThrottle.new(Rails.application.config.pecorino_throttle_cache, throttle)
149
+ cached_throttle.request!
150
+ ```
151
+
93
152
  ## Using unlogged tables for reduced replication load (PostgreSQL)
94
153
 
95
154
  Throttles and leaky buckets are transient resources. If you are using Postgres replication, it might be prudent to set the Pecorino tables to `UNLOGGED` which will exclude them from replication - and save you bandwidth and storage on your RR. To do so, add the following statements to your migration:
@@ -0,0 +1,91 @@
1
+ # The cached throttles can be used when you want to lift your throttle blocks into
2
+ # a higher-level cache. If you are dealing with clients which are hammering on your
3
+ # throttles a lot, it is useful to have a process-local cache of the timestamp when
4
+ # the blocks that are set are going to expire. If you are running, say, 10 web app
5
+ # containers - and someone is hammering at an endpoint which starts blocking -
6
+ # you don't really need to query your DB for every request. The first request indicated
7
+ # as "blocked" by Pecorino can write a cache entry into a shared in-memory table,
8
+ # and all subsequent calls to the same process can reuse that `blocked_until` value
9
+ # to quickly refuse the request
10
+ class Pecorino::CachedThrottle
11
+ # @param cache_store[ActiveSupport::Cache::Store] the store for the cached blocks. We recommend a MemoryStore per-process.
12
+ # @param throttle[Pecorino::Throttle] the throttle to cache
13
+ def initialize(cache_store, throttle)
14
+ @cache_store = cache_store
15
+ @throttle = throttle
16
+ end
17
+
18
+ # @see Pecorino::Throttle#request!
19
+ def request!(n = 1)
20
+ blocked_state = read_cached_blocked_state
21
+ raise Pecorino::Throttle::Throttled.new(@throttle, blocked_state) if blocked_state&.blocked?
22
+
23
+ begin
24
+ @throttle.request!(n)
25
+ rescue Pecorino::Throttle::Throttled => throttled_ex
26
+ write_cache_blocked_state(throttled_ex.state) if throttled_ex.throttle == @throttle
27
+ raise
28
+ end
29
+ end
30
+
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.
32
+ #
33
+ # @see Pecorino::Throttle#request
34
+ def request(n = 1)
35
+ blocked_state = read_cached_blocked_state
36
+ return blocked_state if blocked_state&.blocked?
37
+
38
+ @throttle.request(n).tap do |state|
39
+ write_cache_blocked_state(state) if state.blocked_until
40
+ end
41
+ end
42
+
43
+ # Returns `false` if there is a currently active block for that throttle in the cache. Otherwise forwards to underlying throttle.
44
+ #
45
+ # @see Pecorino::Throttle#able_to_accept?
46
+ def able_to_accept?(n = 1)
47
+ blocked_state = read_cached_blocked_state
48
+ return false if blocked_state&.blocked?
49
+
50
+ @throttle.able_to_accept?(n)
51
+ end
52
+
53
+ # Does not run the block if there is a currently active block for that throttle in the cache. Otherwise forwards to underlying throttle.
54
+ #
55
+ # @see Pecorino::Throttle#throttled
56
+ def throttled(&blk)
57
+ # We can't wrap the implementation of "throttled". Or - we can, but it will be obtuse.
58
+ return if request(1).blocked?
59
+ yield
60
+ end
61
+
62
+ # Returns the key of the throttle
63
+ #
64
+ # @see Pecorino::Throttle#key
65
+ def key
66
+ @throttle.key
67
+ end
68
+
69
+ # Returns `false` if there is a currently active block for that throttle in the cache. Otherwise forwards to underlying throttle.
70
+ #
71
+ # @see Pecorino::Throttle#able_to_accept?
72
+ def state
73
+ blocked_state = read_cached_blocked_state
74
+ warn "Read blocked state #{blocked_state.inspect}"
75
+ return blocked_state if blocked_state&.blocked?
76
+
77
+ @throttle.state.tap do |state|
78
+ write_cache_blocked_state(state) if state.blocked?
79
+ end
80
+ end
81
+
82
+ private
83
+
84
+ def write_cache_blocked_state(state)
85
+ @cache_store.write("pecorino-cached-throttle-state-#{@throttle.key}", state, expires_after: state.blocked_until)
86
+ end
87
+
88
+ def read_cached_blocked_state
89
+ @cache_store.read("pecorino-cached-throttle-state-#{@throttle.key}")
90
+ end
91
+ end
@@ -6,23 +6,28 @@
6
6
  # the block is lifted. The block time can be arbitrarily higher or lower than the amount
7
7
  # of time it takes for the leaky bucket to leak out
8
8
  class Pecorino::Throttle
9
- State = Struct.new(:blocked_until) do
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
9
+ # The state represents a snapshot of the throttle state in time
10
+ class State
11
+ # @return [Time]
12
+ attr_reader :blocked_until
13
+
14
+ def initialize(blocked_until)
15
+ @blocked_until = blocked_until
15
16
  end
16
17
 
17
- # Returns the number of seconds until the block will be lifted, rouded up to the closest
18
- # whole second. This value can be used in a "Retry-After" HTTP response header.
18
+ # Tells whether this throttle still is in the blocked state.
19
+ # If the `blocked_until` value lies in the past, the method will
20
+ # return `false` - this is done so that the `State` can be cached.
19
21
  #
20
- # @return [Integer]
21
- def retry_after
22
- (blocked_until - Time.now.utc).ceil
22
+ # @return [Boolean]
23
+ def blocked?
24
+ !!(@blocked_until && @blocked_until > Time.now)
23
25
  end
24
26
  end
25
27
 
28
+ # {Pecorino::Throttle} will raise this exception from `request!`. The exception can be used
29
+ # to do matching, for setting appropriate response headers, and for distinguishing between
30
+ # multiple different throttles.
26
31
  class Throttled < StandardError
27
32
  # Returns the throttle which raised the exception. Can be used to disambiguiate between
28
33
  # multiple Throttled exceptions when multiple throttles are applied in a layered fashion:
@@ -34,21 +39,63 @@ class Pecorino::Throttle
34
39
  # db_insert_throttle.request!(n_items_to_insert)
35
40
  # rescue Pecorino::Throttled => e
36
41
  # deliver_notification(user) if e.throttle == user_email_throttle
42
+ # firewall.ban_ip(ip) if e.throttle == ip_addr_throttle
37
43
  # end
38
44
  #
39
45
  # @return [Throttle]
40
46
  attr_reader :throttle
41
47
 
42
- # Returns the `retry_after` value in seconds, suitable for use in an HTTP header
43
- attr_reader :retry_after
48
+ # Returns the throttle state based on which the exception is getting raised. This can
49
+ # be used for caching the exception, because the state can tell when the block will be
50
+ # lifted. This can be used to shift the throttle verification into a faster layer of the
51
+ # system (like a blocklist in a firewall) or caching the state in an upstream cache. A block
52
+ # in Pecorino is set once and is active until expiry. If your service is under an attack
53
+ # and you know that the call is blocked until a certain future time, the block can be
54
+ # lifted up into a faster/cheaper storage destination, like Rails cache:
55
+ #
56
+ # @example
57
+ # begin
58
+ # ip_addr_throttle.request!
59
+ # rescue Pecorino::Throttled => e
60
+ # firewall.ban_ip(request.ip, ttl_seconds: e.state.retry_after)
61
+ # render :rate_limit_exceeded
62
+ # end
63
+ #
64
+ # @example
65
+ # state = Rails.cache.read(ip_addr_throttle.key)
66
+ # return render :rate_limit_exceeded if state && state.blocked? # No need to call Pecorino for this
67
+ #
68
+ # begin
69
+ # ip_addr_throttle.request!
70
+ # rescue Pecorino::Throttled => e
71
+ # Rails.cache.write(ip_addr_throttle.key, e.state, expires_in: (e.state.blocked_until - Time.now))
72
+ # render :rate_limit_exceeded
73
+ # end
74
+ #
75
+ # @return [Throttle::State]
76
+ attr_reader :state
44
77
 
45
78
  def initialize(from_throttle, state)
46
79
  @throttle = from_throttle
47
- @retry_after = state.retry_after
80
+ @state = state
48
81
  super("Block in effect until #{state.blocked_until.iso8601}")
49
82
  end
83
+
84
+ # Returns the `retry_after` value in seconds, suitable for use in an HTTP header
85
+ #
86
+ # @return [Integer]
87
+ def retry_after
88
+ (@state.blocked_until - Time.now).ceil
89
+ end
50
90
  end
51
91
 
92
+ # The key for that throttle. Each key defines a unique throttle based on either a given name or
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}"`
95
+ #
96
+ # @return [String]
97
+ attr_reader :key
98
+
52
99
  # @param key[String] the key for both the block record and the leaky bucket
53
100
  # @param block_for[Numeric] the number of seconds to block any further requests for. Defaults to time it takes
54
101
  # the bucket to leak out to the level of 0
@@ -73,8 +120,8 @@ class Pecorino::Throttle
73
120
  end
74
121
 
75
122
  # Register that a request is being performed. Will raise Throttled
76
- # if there is a block in place on that key, or if the bucket has been filled up
77
- # and a block has been put in place as a result of this particular request.
123
+ # if there is a block in place for that throttle, or if the bucket cannot accept
124
+ # this fillup and the block has just been installed as a result of this particular request.
78
125
  #
79
126
  # The exception can be rescued later to provide a 429 response. This method is better
80
127
  # to use before performing the unit of work that the throttle is guarding:
@@ -89,11 +136,11 @@ class Pecorino::Throttle
89
136
  #
90
137
  # If the method call succeeds it means that the request is not getting throttled.
91
138
  #
92
- # @return void
139
+ # @return [State] the state of the throttle after filling up the leaky bucket / trying to pass the block
93
140
  def request!(n = 1)
94
- state = request(n)
95
- raise Throttled.new(self, state) if state.blocked?
96
- nil
141
+ request(n).tap do |state_after|
142
+ raise Throttled.new(self, state_after) if state_after.blocked?
143
+ end
97
144
  end
98
145
 
99
146
  # Register that a request is being performed. Will not raise any exceptions but return
@@ -122,4 +169,18 @@ class Pecorino::Throttle
122
169
  State.new(fresh_blocked_until.utc)
123
170
  end
124
171
  end
172
+
173
+ # Fillup the throttle with 1 request and then perform the passed block. This is useful to perform actions which should
174
+ # be rate-limited - alerts, calls to external services and the like. If the call is allowed to proceed,
175
+ # the passed block will be executed. If the throttle is in the blocked state or if the call puts the throttle in
176
+ # the blocked state the block will not be executed
177
+ #
178
+ # @example
179
+ # t.throttled { Slack.alert("Things are going wrong") }
180
+ #
181
+ # @return [Object] the return value of the block if the block gets executed, or `nil` if the call got throttled
182
+ def throttled(&blk)
183
+ return if request(1).blocked?
184
+ yield
185
+ end
125
186
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pecorino
4
- VERSION = "0.4.1"
4
+ VERSION = "0.5.0"
5
5
  end
data/lib/pecorino.rb CHANGED
@@ -7,6 +7,7 @@ require_relative "pecorino/version"
7
7
  require_relative "pecorino/leaky_bucket"
8
8
  require_relative "pecorino/throttle"
9
9
  require_relative "pecorino/railtie" if defined?(Rails::Railtie)
10
+ require_relative "pecorino/cached_throttle"
10
11
 
11
12
  module Pecorino
12
13
  autoload :Postgres, "pecorino/postgres"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pecorino
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.1
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Julik Tarkhanov
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2024-02-10 00:00:00.000000000 Z
11
+ date: 2024-02-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -154,6 +154,7 @@ files:
154
154
  - README.md
155
155
  - Rakefile
156
156
  - lib/pecorino.rb
157
+ - lib/pecorino/cached_throttle.rb
157
158
  - lib/pecorino/install_generator.rb
158
159
  - lib/pecorino/leaky_bucket.rb
159
160
  - lib/pecorino/migrations/create_pecorino_tables.rb.erb
@@ -185,7 +186,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
185
186
  - !ruby/object:Gem::Version
186
187
  version: '0'
187
188
  requirements: []
188
- rubygems_version: 3.3.7
189
+ rubygems_version: 3.4.10
189
190
  signing_key:
190
191
  specification_version: 4
191
192
  summary: Database-based rate limiter using leaky buckets