Skip to content

Commit

Permalink
Add Throttle#throttled
Browse files Browse the repository at this point in the history
since this is a pattern which we turned out to use frequently
  • Loading branch information
julik committed Feb 11, 2024
1 parent 4ae0f7f commit 9d54035
Show file tree
Hide file tree
Showing 4 changed files with 43 additions and 1 deletion.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
- Add `Throttle#throttled` for silencing alerts
- **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
- Allow accessing `Throttle::State` from the `Throttled` exception so that the blocked throttle state can be cached downstream (in Rails cache, for example)
- Make `Throttle#request!` return the new state if there was no exception raised
Expand Down
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ Once the installation is done you can use Pecorino to start defining your thrott
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.

```ruby
throttle = Pecorino::Throttle.new(key: "vault", over_time: 1.second, capacity: 5)
throttle = Pecorino::Throttle.new(key: "password-attempts-#{request.ip}", over_time: 1.minute, capacity: 5, block_for: 30.minutes)
throttle.request!
```
In a Rails controller you can then rescue from this exception to render the appropriate response:
Expand Down Expand Up @@ -69,6 +69,19 @@ throttle.request!(20) # Attempt to withdraw 20 dollars more
throttle.request!(2) # Attempt to withdraw 2 dollars more, will raise `Throttled` and block withdrawals for 3 hours
```

## Performing a block only if it would be allowed by the throttle

You can use Pecorino to avoid nuisance alerting - use it to limit the alert rate:

```ruby
alert_nuisance_t = Pecorino::Throttle.new(key: "disk-full-alert", over_time_: 2.hours, capacity: 1, block_for: 2.hours)
alert_nuisance_t.throttled do
Slack.alerts.deliver("Disk is full again! please investigate!")
end
```

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.

## Postfix topup of the throttle

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:
Expand Down
14 changes: 14 additions & 0 deletions lib/pecorino/throttle.rb
Original file line number Diff line number Diff line change
Expand Up @@ -162,4 +162,18 @@ def request(n = 1)
State.new(fresh_blocked_until.utc)
end
end

# Fillup the throttle with 1 request and then perform the passed block. This is useful to perform actions which should
# be rate-limited - alerts, calls to external services and the like. If the call is allowed to proceed,
# the passed block will be executed. If the throttle is in the blocked state or if the call puts the throttle in
# the blocked state the block will not be executed
#
# @example
# t.throttled { Slack.alert("Things are going wrong") }
#
# @return [Object] the return value of the block if the block gets executed, or `nil` if the call got throttled
def throttled(&blk)
return if request(1).blocked?
yield
end
end
14 changes: 14 additions & 0 deletions test/throttle_postgres_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -106,4 +106,18 @@ def teardown

assert_in_delta err.retry_after, 3, 0.5
end

test "throttled() calls the block just once" do
throttle = Pecorino::Throttle.new(key: Random.uuid, over_time: 1.minute, capacity: 1)

counter = 0

10.times do
throttle.throttled do
counter += 1
end
end

assert_equal 1, counter
end
end

0 comments on commit 9d54035

Please sign in to comment.