Skip to content

Commit

Permalink
Add limiter type
Browse files Browse the repository at this point in the history
  • Loading branch information
dhh committed Dec 29, 2023
1 parent cc9b5c1 commit 9790ff9
Show file tree
Hide file tree
Showing 6 changed files with 93 additions and 0 deletions.
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,20 @@ sleep 0.5.seconds
true == flag.marked? #=> EXISTS myflag
sleep 0.6.seconds
false == flag.marked? #=> EXISTS myflag

limiter = Kredis.limiter "mylimit", limit: 3, expires_in: 5.seconds
0 == limiter.value # => GET "limiter"
limiter.poke # => SET limiter 0 NX + INCRBY limiter 1
limiter.poke # => SET limiter 0 NX + INCRBY limiter 1
limiter.poke # => SET limiter 0 NX + INCRBY limiter 1
false == limiter.exceeded? # => GET "limiter"
limiter.poke # => SET limiter 0 NX + INCRBY limiter 1
true == limiter.exceeded? # => GET "limiter"
sleep 6
limiter.poke # => SET limiter 0 NX + INCRBY limiter 1
limiter.poke # => SET limiter 0 NX + INCRBY limiter 1
limiter.poke # => SET limiter 0 NX + INCRBY limiter 1
false == limiter.exceeded? # => GET "limiter"
```

### Models
Expand Down
4 changes: 4 additions & 0 deletions lib/kredis/attributes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ def kredis_counter(name, key: nil, default: nil, config: :shared, after_change:
kredis_connection_with __method__, name, key, default: default, config: config, after_change: after_change, expires_in: expires_in
end

def kredis_limiter(name, limit:, key: nil, config: :shared, after_change: nil, expires_in: nil)
kredis_connection_with __method__, name, key, limit: limit, config: config, after_change: after_change, expires_in: expires_in
end

def kredis_hash(name, key: nil, default: nil, typed: :string, config: :shared, after_change: nil)
kredis_connection_with __method__, name, key, default: default, typed: typed, config: config, after_change: after_change
end
Expand Down
5 changes: 5 additions & 0 deletions lib/kredis/types.rb
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,10 @@ def slots(key, available:, config: :shared, after_change: nil)
type_from(Slots, config, key, after_change: after_change, available: available)
end

def limiter(key, limit:, expires_in: nil, config: :shared, after_change: nil)
type_from(Limiter, config, key, after_change: after_change, expires_in: expires_in, limit: limit)
end

private
def type_from(type_klass, config, key, after_change: nil, **options)
type_klass.new(configured_for(config), namespaced_key(key), **options).then do |type|
Expand All @@ -107,3 +111,4 @@ def type_from(type_klass, config, key, after_change: nil, **options)
require "kredis/types/set"
require "kredis/types/ordered_set"
require "kredis/types/slots"
require "kredis/types/limiter"
24 changes: 24 additions & 0 deletions lib/kredis/types/limiter.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# frozen_string_literal: true

# A limiter is a specialized form of a counter that can be checked whether it has been exceeded and is provided fail safe. This means it can be used to guard login screens from brute force attacks without denying access in case Redis is offline.
#
# It will usually be used as an expiring limiter. Note that the limiter expires in total after the `expires_in` time used upon the first poke.
#
# It offers no guarentee that you can't poke yourself above the limit. You're responsible for checking `#exceeded?` yourself first, and this may produce a race condition. So only use this when the exact number of pokes is not critical.
class Kredis::Types::Limiter < Kredis::Types::Counter
class LimitExceeded < StandardError; end

attr_accessor :limit

def poke
failsafe returning: true do
increment
end
end

def exceeded?
failsafe returning: false do
value >= limit
end
end
end
20 changes: 20 additions & 0 deletions test/attributes_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ class Person
kredis_hash :high_scores_with_default_via_lambda, typed: :integer, default: ->(p) { { high_score: JSON.parse(p.scores).max } }
kredis_boolean :onboarded
kredis_boolean :adult_with_default_via_lambda, default: ->(p) { Date.today.year - p.birthdate.year >= 18 }
kredis_limiter :update_limit, limit: 3, expires_in: 1.second

def self.name
"Person"
Expand Down Expand Up @@ -88,6 +89,14 @@ def vacation_destinations
].to_json
end

def update!
if update_limit.exceeded?
raise "Limiter exceeded"
else
update_limit.poke
end
end

private
def generate_key
"some-generated-key"
Expand Down Expand Up @@ -408,4 +417,15 @@ def suddenly_implemented_person.id; 8; end
sleep 0.6.seconds
end
end

test "limiter exceeded" do
3.times { @person.update! }
assert_raises { @person.update! }
end

test "expiring limiter" do
3.times { @person.update! }
sleep 1.1
assert_nothing_raised { 3.times { @person.update! } }
end
end
26 changes: 26 additions & 0 deletions test/types/limiter_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# frozen_string_literal: true

require "test_helper"

class LimiterTest < ActiveSupport::TestCase
setup { @limiter = Kredis.limiter "mylimit", limit: 5 }

test "exceeded after limit is reached" do
4.times do
@limiter.poke
assert_not @limiter.exceeded?
end

@limiter.poke
assert @limiter.exceeded?
end

test "never exceeded when redis is down" do
stub_redis_down(@limiter) do
10.times do
@limiter.poke
assert_not @limiter.exceeded?
end
end
end
end

0 comments on commit 9790ff9

Please sign in to comment.