diff --git a/README.md b/README.md index 7af794a..52fcf65 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/lib/kredis/attributes.rb b/lib/kredis/attributes.rb index b844501..cd00319 100644 --- a/lib/kredis/attributes.rb +++ b/lib/kredis/attributes.rb @@ -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 diff --git a/lib/kredis/types.rb b/lib/kredis/types.rb index 6ccdb2a..91c8db6 100644 --- a/lib/kredis/types.rb +++ b/lib/kredis/types.rb @@ -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| @@ -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" diff --git a/lib/kredis/types/limiter.rb b/lib/kredis/types/limiter.rb new file mode 100644 index 0000000..884ce7e --- /dev/null +++ b/lib/kredis/types/limiter.rb @@ -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 diff --git a/test/attributes_test.rb b/test/attributes_test.rb index c86e6b4..6fa0b4e 100644 --- a/test/attributes_test.rb +++ b/test/attributes_test.rb @@ -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" @@ -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" @@ -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 diff --git a/test/types/limiter_test.rb b/test/types/limiter_test.rb new file mode 100644 index 0000000..edc845d --- /dev/null +++ b/test/types/limiter_test.rb @@ -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