diff --git a/lib/flipper.rb b/lib/flipper.rb index 61de5d069..8d5452f54 100644 --- a/lib/flipper.rb +++ b/lib/flipper.rb @@ -175,10 +175,11 @@ def groups_registry=(registry) require 'flipper/actor' require 'flipper/adapter' require 'flipper/adapters/wrapper' +require 'flipper/adapters/actor_limit' +require 'flipper/adapters/instrumented' require 'flipper/adapters/memoizable' require 'flipper/adapters/memory' require 'flipper/adapters/strict' -require 'flipper/adapters/instrumented' require 'flipper/adapter_builder' require 'flipper/configuration' require 'flipper/dsl' diff --git a/lib/flipper/adapters/actor_limit.rb b/lib/flipper/adapters/actor_limit.rb new file mode 100644 index 000000000..e97376b1c --- /dev/null +++ b/lib/flipper/adapters/actor_limit.rb @@ -0,0 +1,28 @@ +module Flipper + module Adapters + class ActorLimit < Wrapper + LimitExceeded = Class.new(Flipper::Error) + + attr_reader :limit + + def initialize(adapter, limit = 100) + super(adapter) + @limit = limit + end + + def enable(feature, gate, resource) + if gate.is_a?(Flipper::Gates::Actor) && over_limit?(feature) + raise LimitExceeded, "Actor limit of #{@limit} exceeded for feature #{feature.key}. See https://www.flippercloud.io/docs/features/actors#limitations" + else + super + end + end + + private + + def over_limit?(feature) + feature.actors_value.size >= @limit + end + end + end +end diff --git a/lib/flipper/engine.rb b/lib/flipper/engine.rb index 6f853fdc8..f1a1091c3 100644 --- a/lib/flipper/engine.rb +++ b/lib/flipper/engine.rb @@ -25,6 +25,7 @@ def self.default_strict_value log: ENV.fetch('FLIPPER_LOG', 'true').casecmp('true').zero?, cloud_path: "_flipper", strict: default_strict_value, + actor_limit: ENV["FLIPPER_ACTOR_LIMIT"]&.to_i || 100, test_help: Flipper::Typecast.to_boolean(ENV["FLIPPER_TEST_HELP"] || Rails.env.test?), ) end @@ -65,13 +66,12 @@ def self.default_strict_value end end - initializer "flipper.strict", after: :load_config_initializers do |app| + initializer "flipper.adapters", after: :load_config_initializers do |app| flipper = app.config.flipper - if flipper.strict - Flipper.configure do |config| - config.use Flipper::Adapters::Strict, flipper.strict - end + Flipper.configure do |config| + config.use Flipper::Adapters::Strict, flipper.strict if flipper.strict + config.use Flipper::Adapters::ActorLimit, flipper.actor_limit if flipper.actor_limit end end diff --git a/spec/flipper/adapters/actor_limit_spec.rb b/spec/flipper/adapters/actor_limit_spec.rb new file mode 100644 index 000000000..26b1d5100 --- /dev/null +++ b/spec/flipper/adapters/actor_limit_spec.rb @@ -0,0 +1,20 @@ +require "flipper/adapters/actor_limit" + +RSpec.describe Flipper::Adapters::ActorLimit do + it_should_behave_like 'a flipper adapter' do + let(:limit) { 5 } + let(:adapter) { Flipper::Adapters::ActorLimit.new(Flipper::Adapters::Memory.new, limit) } + + subject { adapter } + + describe '#enable' do + it "fails when limit exceeded" do + 5.times { |i| feature.enable Flipper::Actor.new("User;#{i}") } + + expect { + feature.enable Flipper::Actor.new("User;6") + }.to raise_error(Flipper::Adapters::ActorLimit::LimitExceeded) + end + end + end +end diff --git a/spec/flipper/engine_spec.rb b/spec/flipper/engine_spec.rb index 638158004..3f8e5332e 100644 --- a/spec/flipper/engine_spec.rb +++ b/spec/flipper/engine_spec.rb @@ -53,7 +53,7 @@ ENV['FLIPPER_STRICT'] = 'false' subject expect(config.strict).to eq(false) - expect(adapter).to be_instance_of(Flipper::Adapters::Memory) + expect(adapter).not_to be_instance_of(Flipper::Adapters::Strict) end [true, :raise, :warn].each do |value| @@ -69,7 +69,7 @@ initializer { config.strict = false } subject expect(config.strict).to eq(false) - expect(adapter).to be_instance_of(Flipper::Adapters::Memory) + expect(adapter).not_to be_instance_of(Flipper::Adapters::Strict) end it "defaults to strict=:warn in RAILS_ENV=development" do @@ -85,7 +85,7 @@ expect(Rails.env).to eq(env) subject expect(config.strict).to eq(false) - expect(adapter).to be_instance_of(Flipper::Adapters::Memory) + expect(adapter).not_to be_instance_of(Flipper::Adapters::Strict) end end @@ -336,6 +336,33 @@ expect(ActiveRecord::Base.ancestors).to include(Flipper::Model::ActiveRecord) end + describe "config.actor_limit" do + let(:adapter) do + application.initialize! + Flipper.adapter.adapter.adapter + end + + it "defaults to 100" do + expect(adapter).to be_instance_of(Flipper::Adapters::ActorLimit) + expect(adapter.limit).to eq(100) + end + + it "can be set from FLIPPER_ACTOR_LIMIT env" do + ENV["FLIPPER_ACTOR_LIMIT"] = "500" + expect(adapter.limit).to eq(500) + end + + it "can be set from an initializer" do + initializer { config.actor_limit = 99 } + expect(adapter.limit).to eq(99) + end + + it "can be disabled from an initializer" do + initializer { config.actor_limit = false } + expect(adapter).not_to be_instance_of(Flipper::Adapters::ActorLimit) + end + end + # Add app initializer in the same order as config/initializers/* def initializer(&block) application.initializer 'spec', before: :load_config_initializers do diff --git a/test/adapters/actor_limit_test.rb b/test/adapters/actor_limit_test.rb new file mode 100644 index 000000000..392dd9581 --- /dev/null +++ b/test/adapters/actor_limit_test.rb @@ -0,0 +1,20 @@ +require "test_helper" +require "flipper/test/shared_adapter_test" +require "flipper/adapters/actor_limit" + +class Flipper::Adapters::ActorLimitTest < MiniTest::Test + prepend Flipper::Test::SharedAdapterTests + + def setup + @memory = Flipper::Adapters::Memory.new + @adapter = Flipper::Adapters::ActorLimit.new(@memory, 5) + end + + def test_enable_fails_when_limit_exceeded + 5.times { |i| @feature.enable Flipper::Actor.new("User;#{i}") } + + assert_raises Flipper::Adapters::ActorLimit::LimitExceeded do + @feature.enable Flipper::Actor.new("User;6") + end + end +end