Skip to content

Commit 4e7154a

Browse files
authored
Add limiter type (#136)
1 parent cc9b5c1 commit 4e7154a

File tree

6 files changed

+93
-0
lines changed

6 files changed

+93
-0
lines changed

README.md

+14
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,20 @@ sleep 0.5.seconds
161161
true == flag.marked? #=> EXISTS myflag
162162
sleep 0.6.seconds
163163
false == flag.marked? #=> EXISTS myflag
164+
165+
limiter = Kredis.limiter "mylimit", limit: 3, expires_in: 5.seconds
166+
0 == limiter.value # => GET "limiter"
167+
limiter.poke # => SET limiter 0 NX + INCRBY limiter 1
168+
limiter.poke # => SET limiter 0 NX + INCRBY limiter 1
169+
limiter.poke # => SET limiter 0 NX + INCRBY limiter 1
170+
false == limiter.exceeded? # => GET "limiter"
171+
limiter.poke # => SET limiter 0 NX + INCRBY limiter 1
172+
true == limiter.exceeded? # => GET "limiter"
173+
sleep 6
174+
limiter.poke # => SET limiter 0 NX + INCRBY limiter 1
175+
limiter.poke # => SET limiter 0 NX + INCRBY limiter 1
176+
limiter.poke # => SET limiter 0 NX + INCRBY limiter 1
177+
false == limiter.exceeded? # => GET "limiter"
164178
```
165179

166180
### Models

lib/kredis/attributes.rb

+4
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,10 @@ def kredis_counter(name, key: nil, default: nil, config: :shared, after_change:
7272
kredis_connection_with __method__, name, key, default: default, config: config, after_change: after_change, expires_in: expires_in
7373
end
7474

75+
def kredis_limiter(name, limit:, key: nil, config: :shared, after_change: nil, expires_in: nil)
76+
kredis_connection_with __method__, name, key, limit: limit, config: config, after_change: after_change, expires_in: expires_in
77+
end
78+
7579
def kredis_hash(name, key: nil, default: nil, typed: :string, config: :shared, after_change: nil)
7680
kredis_connection_with __method__, name, key, default: default, typed: typed, config: config, after_change: after_change
7781
end

lib/kredis/types.rb

+5
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,10 @@ def slots(key, available:, config: :shared, after_change: nil)
8585
type_from(Slots, config, key, after_change: after_change, available: available)
8686
end
8787

88+
def limiter(key, limit:, expires_in: nil, config: :shared, after_change: nil)
89+
type_from(Limiter, config, key, after_change: after_change, expires_in: expires_in, limit: limit)
90+
end
91+
8892
private
8993
def type_from(type_klass, config, key, after_change: nil, **options)
9094
type_klass.new(configured_for(config), namespaced_key(key), **options).then do |type|
@@ -107,3 +111,4 @@ def type_from(type_klass, config, key, after_change: nil, **options)
107111
require "kredis/types/set"
108112
require "kredis/types/ordered_set"
109113
require "kredis/types/slots"
114+
require "kredis/types/limiter"

lib/kredis/types/limiter.rb

+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# frozen_string_literal: true
2+
3+
# 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.
4+
#
5+
# 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.
6+
#
7+
# 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.
8+
class Kredis::Types::Limiter < Kredis::Types::Counter
9+
class LimitExceeded < StandardError; end
10+
11+
attr_accessor :limit
12+
13+
def poke
14+
failsafe returning: true do
15+
increment
16+
end
17+
end
18+
19+
def exceeded?
20+
failsafe returning: false do
21+
value >= limit
22+
end
23+
end
24+
end

test/attributes_test.rb

+20
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ class Person
4444
kredis_hash :high_scores_with_default_via_lambda, typed: :integer, default: ->(p) { { high_score: JSON.parse(p.scores).max } }
4545
kredis_boolean :onboarded
4646
kredis_boolean :adult_with_default_via_lambda, default: ->(p) { Date.today.year - p.birthdate.year >= 18 }
47+
kredis_limiter :update_limit, limit: 3, expires_in: 1.second
4748

4849
def self.name
4950
"Person"
@@ -88,6 +89,14 @@ def vacation_destinations
8889
].to_json
8990
end
9091

92+
def update!
93+
if update_limit.exceeded?
94+
raise "Limiter exceeded"
95+
else
96+
update_limit.poke
97+
end
98+
end
99+
91100
private
92101
def generate_key
93102
"some-generated-key"
@@ -408,4 +417,15 @@ def suddenly_implemented_person.id; 8; end
408417
sleep 0.6.seconds
409418
end
410419
end
420+
421+
test "limiter exceeded" do
422+
3.times { @person.update! }
423+
assert_raises { @person.update! }
424+
end
425+
426+
test "expiring limiter" do
427+
3.times { @person.update! }
428+
sleep 1.1
429+
assert_nothing_raised { 3.times { @person.update! } }
430+
end
411431
end

test/types/limiter_test.rb

+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# frozen_string_literal: true
2+
3+
require "test_helper"
4+
5+
class LimiterTest < ActiveSupport::TestCase
6+
setup { @limiter = Kredis.limiter "mylimit", limit: 5 }
7+
8+
test "exceeded after limit is reached" do
9+
4.times do
10+
@limiter.poke
11+
assert_not @limiter.exceeded?
12+
end
13+
14+
@limiter.poke
15+
assert @limiter.exceeded?
16+
end
17+
18+
test "never exceeded when redis is down" do
19+
stub_redis_down(@limiter) do
20+
10.times do
21+
@limiter.poke
22+
assert_not @limiter.exceeded?
23+
end
24+
end
25+
end
26+
end

0 commit comments

Comments
 (0)