Commit 85ffa31a authored by Max Woolf's avatar Max Woolf
Browse files

refactor(rate_limit): rule-level count_distinct in place of check_unique API

Replaces the per-call check_unique/peek_unique entry points with an
optional count_distinct: Symbol field on Rule. Callers always use
#check(identifier) / #peek(identifier); the rule's shape decides
INCR vs SADD+SCARD. Per !292 review.

A matched count_distinct rule whose identifier is missing the named
key fails open: emits rate_limit_missing_count_distinct, bumps
errors_total, and the loop continues to the next rule. peek does not
require the count_distinct key (reads bucket cardinality).

Co-Authored-By: default avatarClaude Opus 4.7 (1M context) <noreply@anthropic.com>
parent 4e78944c
Loading
Loading
Loading
Loading
Loading
+0 −13
Original line number Diff line number Diff line
@@ -47,19 +47,6 @@ module Labkit
      def check(name:, identifier:, rules:, redis: nil, logger: nil)
        Limiter.new(name: name, rules: rules, redis: redis, logger: logger).check(identifier)
      end

      # Convenience wrapper for set-cardinality checks. See {Limiter#check_unique}.
      #
      # @param name [String] call site name
      # @param identifier [Identifier, Hash] caller attributes
      # @param rules [Array<Rule>] ordered list of rules (first match wins)
      # @param member [#to_s] the subject to add to the set
      # @param redis [Object, nil] Redis client; falls back to config.redis
      # @param logger [Logger, nil] logger; falls back to config.logger
      # @return [Result]
      def check_unique(name:, identifier:, rules:, member:, redis: nil, logger: nil)
        Limiter.new(name: name, rules: rules, redis: redis, logger: logger).check_unique(identifier, member: member)
      end
    end
  end
end
+35 −68
Original line number Diff line number Diff line
@@ -40,40 +40,25 @@ module Labkit
        Result.new(matched: false, error: true, action: :allow)
      end

      # Set-cardinality variant of {#check}: adds `member` to a Redis SET and
      # uses SCARD as the counter, so the limit constrains the number of
      # distinct members observed within the rule's period rather than the
      # number of calls. Members already in the set don't advance the count.
      #
      # Mirrors {#check}'s Result shape (`info.count` is the post-add SCARD)
      # and fail-open behavior.
      def check_unique(identifier, member:)
        check_unique_rules(identifier, member)
      rescue StandardError => e
        report_error_metrics
        log_error(e, identifier)
        Result.new(matched: false, error: true, action: :allow)
      end

      # Read-without-write counterpart to {#check_unique}. Returns the current
      # SCARD without adding to the set or extending the TTL. A missing key
      # reports count=0.
      def peek_unique(identifier)
        peek_unique_rules(identifier)
      rescue StandardError => e
        report_error_metrics
        log_error(e, identifier)
        Result.new(matched: false, error: true, action: :allow)
      end

      private

      # :log rules are non-terminating: they emit metrics and continue,
      # so a shadow :log rule cannot disable a following :block rule.
      #
      # SET-mode rules (rule.count_distinct set) that match but whose identifier
      # is missing the count_distinct key fail open + log + bump errors_total, and
      # the loop continues to the next rule (the rule is treated as not applicable
      # rather than aborting the whole evaluation).
      def check_rules(identifier)
        @rules.each do |rule|
          next unless rule_matches?(rule, identifier)

          if rule.count_distinct && missing_count_distinct_value?(rule, identifier)
            log_missing_count_distinct(rule, identifier)
            report_error_metrics
            next
          end

          result = evaluate_rule(rule, identifier)
          report_matched_metrics(result)
          return result unless rule.action == :log
@@ -85,6 +70,10 @@ module Labkit

      # Mirror of check_rules without metrics: peek skips :log rules (their state
      # is unobservable through peek).
      #
      # peek does not need the count_distinct identifier key - it reads SCARD on
      # the rule-keyed compound key, which contains the cardinality across all
      # members. So missing-key fail-open does not apply here.
      def peek_rules(identifier)
        @rules.each do |rule|
          next if rule.action == :log
@@ -96,67 +85,35 @@ module Labkit
        Result.new(matched: false, action: :allow)
      end

      def check_unique_rules(identifier, member)
        @rules.each do |rule|
          next unless rule_matches?(rule, identifier)

          result = evaluate_unique_rule(rule, identifier, member)
          report_matched_metrics(result)
          return result unless rule.action == :log
        end

        report_unmatched_metrics
        Result.new(matched: false, action: :allow)
      end

      def peek_unique_rules(identifier)
        @rules.each do |rule|
          next if rule.action == :log
          next unless rule_matches?(rule, identifier)

          return peek_unique_rule(rule, identifier)
        end

        Result.new(matched: false, action: :allow)
      end

      def rule_matches?(rule, identifier)
        rule.match.all? { |key, matcher| matcher.match?(identifier[key]) }
      end

      def evaluate_rule(rule, identifier)
        redis_key = build_redis_key(rule, identifier)
        resolved_limit = Integer(resolve_value(rule.limit))
        resolved_period = Integer(resolve_value(rule.period))

        count, ttl = incr_with_ttl(redis_key, resolved_period)
        build_result(rule, resolved_limit, resolved_period, count, ttl)
      def missing_count_distinct_value?(rule, identifier)
        value = identifier[rule.count_distinct]
        value.nil? || value.to_s.empty?
      end

      def peek_rule(rule, identifier)
      def evaluate_rule(rule, identifier)
        redis_key = build_redis_key(rule, identifier)
        resolved_limit = Integer(resolve_value(rule.limit))
        resolved_period = Integer(resolve_value(rule.period))

        count, ttl = read_with_ttl(redis_key)
        build_result(rule, resolved_limit, resolved_period, count, ttl)
        count, ttl = if rule.count_distinct
                       sadd_with_ttl(redis_key, identifier[rule.count_distinct], resolved_period)
                     else
                       incr_with_ttl(redis_key, resolved_period)
                     end

      def evaluate_unique_rule(rule, identifier, member)
        redis_key = build_redis_key(rule, identifier)
        resolved_limit = Integer(resolve_value(rule.limit))
        resolved_period = Integer(resolve_value(rule.period))

        count, ttl = sadd_with_ttl(redis_key, member, resolved_period)
        build_result(rule, resolved_limit, resolved_period, count, ttl)
      end

      def peek_unique_rule(rule, identifier)
      def peek_rule(rule, identifier)
        redis_key = build_redis_key(rule, identifier)
        resolved_limit = Integer(resolve_value(rule.limit))
        resolved_period = Integer(resolve_value(rule.period))

        count, ttl = scard_with_ttl(redis_key)
        count, ttl = rule.count_distinct ? scard_with_ttl(redis_key) : read_with_ttl(redis_key)
        build_result(rule, resolved_limit, resolved_period, count, ttl)
      end

@@ -268,6 +225,16 @@ module Labkit
        )
      end

      def log_missing_count_distinct(rule, identifier)
        @logger.warn(
          message: "rate_limit_missing_count_distinct",
          name: @name,
          rule: rule.name,
          count_distinct: rule.count_distinct.to_s,
          identifier: identifier&.to_h
        )
      end

      def report_matched_metrics(result)
        Metrics.calls_total.increment(
          rate_limiter: @name,
+0 −26
Original line number Diff line number Diff line
@@ -57,32 +57,6 @@ module Labkit
        @evaluator.peek(id)
      end

      # Set-cardinality check: adds +member+ to a Redis SET keyed by the
      # matched rule's identifier and uses SCARD as the counter. Use this
      # when the limit constrains the number of distinct subjects observed
      # within the rule's period (e.g. "max N unique projects downloaded
      # per user per hour"), as opposed to {#check}'s "max N calls" shape.
      #
      # @param identifier [Identifier, Hash] caller attributes for this request
      # @param member [#to_s] the subject to add to the set. Must be non-nil.
      # @return [Result]
      def check_unique(identifier, member:)
        raise ArgumentError, "member must be non-nil" if member.nil?

        id = identifier.is_a?(Identifier) ? identifier : Identifier.new(identifier)
        @evaluator.check_unique(id, member: member)
      end

      # Read-without-write counterpart to {#check_unique}: returns the current
      # SCARD without adding to the set or extending the TTL.
      #
      # @param identifier [Identifier, Hash] caller attributes for this request
      # @return [Result]
      def peek_unique(identifier)
        id = identifier.is_a?(Identifier) ? identifier : Identifier.new(identifier)
        @evaluator.peek_unique(id)
      end

      private

      def validate_name!(name)
+26 −3
Original line number Diff line number Diff line
@@ -16,12 +16,32 @@ module Labkit
    #                   evaluation continues to subsequent rules), or :allow
    #                   (bypass: short-circuit evaluation with no Redis writes)
    # characteristics - identifier keys used to build the compound Redis counter key
    # count_distinct  - optional Symbol naming an identifier key. When set, the rule
    #                   counts the number of distinct values seen for that key within
    #                   the (characteristics-bucketed) period, backed by a Redis SET.
    #                   When nil (default), the rule counts the number of calls,
    #                   backed by INCR. The named key must not overlap +characteristics+.
    #
    # +name+ must be a lowercase alphanumeric-and-underscore string of at most 64
    # characters. It is used as the middle segment of every Redis counter key for
    # this rule, so changing a rule's name mid-window abandons its in-flight counters.
    Rule = Data.define(:name, :match, :limit, :period, :action, :characteristics) do
      def initialize(name:, limit:, period:, characteristics:, match: {}, action: :block)
    Rule = Data.define(:name, :match, :limit, :period, :action, :characteristics, :count_distinct) do
      def self.normalize_count_distinct(value, characteristics_arr)
        sym =
          case value
          when nil    then nil
          when Symbol then value
          when String then value.to_sym
          else
            raise ArgumentError, "count_distinct must be a Symbol or nil, got #{value.class}"
          end

        raise ArgumentError, "count_distinct #{sym.inspect} must not overlap characteristics #{characteristics_arr.inspect}" if sym && characteristics_arr.include?(sym)

        sym
      end

      def initialize(name:, limit:, period:, characteristics:, match: {}, action: :block, count_distinct: nil)
        raise ArgumentError, "name must be a String or Symbol, got #{name.class}" unless name.is_a?(String) || name.is_a?(Symbol)

        name_str = name.to_s
@@ -35,13 +55,16 @@ module Labkit
          raise ArgumentError, "Rule name too long: #{name.inspect}. Maximum 64 characters" if name_str.length > RULE_NAME_MAX_LENGTH
        end

        characteristics_arr = Array(characteristics).map(&:to_sym).freeze

        super(
          name: name_str.freeze,
          match: match.transform_keys(&:to_sym).transform_values { |v| Matcher.build(v) }.freeze,
          limit: limit,
          period: period,
          action: action_sym,
          characteristics: Array(characteristics).map(&:to_sym).freeze
          characteristics: characteristics_arr,
          count_distinct: self.class.normalize_count_distinct(count_distinct, characteristics_arr)
        )
      end
    end
+130 −45
Original line number Diff line number Diff line
@@ -12,10 +12,10 @@ RSpec.describe Labkit::RateLimit::Evaluator do
  let(:identifier) { Labkit::RateLimit::Identifier.new(user: 42, ip: "1.2.3.4") }
  let(:pipe) { instance_double(Redis) }

  def make_rule(name: "default", match: {}, limit: 100, period: 60, action: :block, characteristics: [:user])
  def make_rule(name: "default", match: {}, limit: 100, period: 60, action: :block, characteristics: [:user], count_distinct: nil)
    Labkit::RateLimit::Rule.new(
      name: name, match: match, limit: limit, period: period,
      action: action, characteristics: characteristics
      action: action, characteristics: characteristics, count_distinct: count_distinct
    )
  end

@@ -951,8 +951,9 @@ RSpec.describe Labkit::RateLimit::Evaluator do
    end
  end

  describe "#check_unique (SET-mode)" do
  describe "#check with count_distinct (SET-mode)" do
    let(:unique_pipe) { instance_double(Redis) }
    let(:unique_id) { Labkit::RateLimit::Identifier.new(user: 42, ip: "1.2.3.4", project: 99) }

    before do
      allow(unique_pipe).to receive_messages(sadd?: nil, scard: nil, ttl: nil)
@@ -960,59 +961,60 @@ RSpec.describe Labkit::RateLimit::Evaluator do
      allow(raw_redis).to receive(:pipelined).and_yield(unique_pipe).and_return([true, 1, -1])
    end

    it "SADDs the member to the rule-keyed compound key" do
      rule = make_rule(name: "uniq_rule", characteristics: [:user])
    it "SADDs the count_distinct value to the rule-keyed compound key" do
      rule = make_rule(name: "uniq_rule", characteristics: [:user], count_distinct: :project)

      expect(unique_pipe).to receive(:sadd?).with("labkit:rl:rack_request:uniq_rule:user:42", "99")
      expect(unique_pipe).to receive(:scard).with("labkit:rl:rack_request:uniq_rule:user:42")
      expect(unique_pipe).to receive(:ttl).with("labkit:rl:rack_request:uniq_rule:user:42")
      evaluator(rules: [rule]).check_unique(identifier, member: 99)
      evaluator(rules: [rule]).check(unique_id)
    end

    it "coerces non-string members via to_s" do
      rule = make_rule(name: "uniq_rule", characteristics: [:user])
    it "coerces non-string count_distinct values via to_s" do
      rule = make_rule(name: "uniq_rule", characteristics: [:user], count_distinct: :project)
      expect(unique_pipe).to receive(:sadd?).with(anything, "99")
      evaluator(rules: [rule]).check_unique(identifier, member: 99)
      evaluator(rules: [rule]).check(unique_id)
    end

    it "SHA-256-encodes members longer than CHAR_VALUE_MAX_LENGTH" do
      long_member = "x" * 201
      expected_hash = OpenSSL::Digest::SHA256.hexdigest(long_member)
      rule = make_rule(name: "uniq_rule", characteristics: [:user])
    it "SHA-256-encodes count_distinct values longer than CHAR_VALUE_MAX_LENGTH" do
      long_value = "x" * 201
      expected_hash = OpenSSL::Digest::SHA256.hexdigest(long_value)
      rule = make_rule(name: "uniq_rule", characteristics: [:user], count_distinct: :project)
      id = Labkit::RateLimit::Identifier.new(user: 42, project: long_value)

      expect(unique_pipe).to receive(:sadd?).with(anything, expected_hash)
      evaluator(rules: [rule]).check_unique(identifier, member: long_member)
      evaluator(rules: [rule]).check(id)
    end

    it "EXPIREs on first write (added=true AND scard=1)" do
      allow(raw_redis).to receive(:pipelined).and_yield(unique_pipe).and_return([true, 1, -1])
      rule = make_rule(name: "uniq_rule", period: 120)
      rule = make_rule(name: "uniq_rule", period: 120, count_distinct: :project)

      expect(raw_redis).to receive(:expire).with(anything, 120)
      evaluator(rules: [rule]).check_unique(identifier, member: 1)
      evaluator(rules: [rule]).check(unique_id)
    end

    it "does not EXPIRE when scard > 1 (existing key, new member)" do
      allow(raw_redis).to receive(:pipelined).and_yield(unique_pipe).and_return([true, 5, 30])
      rule = make_rule(name: "uniq_rule")
      rule = make_rule(name: "uniq_rule", count_distinct: :project)

      expect(raw_redis).not_to receive(:expire)
      evaluator(rules: [rule]).check_unique(identifier, member: 1)
      evaluator(rules: [rule]).check(unique_id)
    end

    it "does not EXPIRE when member was already in the set (added=false)" do
    it "does not EXPIRE when the value was already in the set (added=false)" do
      allow(raw_redis).to receive(:pipelined).and_yield(unique_pipe).and_return([false, 1, 30])
      rule = make_rule(name: "uniq_rule")
      rule = make_rule(name: "uniq_rule", count_distinct: :project)

      expect(raw_redis).not_to receive(:expire)
      evaluator(rules: [rule]).check_unique(identifier, member: 1)
      evaluator(rules: [rule]).check(unique_id)
    end

    it "reports exceeded when scard > resolved_limit" do
      allow(raw_redis).to receive(:pipelined).and_yield(unique_pipe).and_return([true, 6, 55])
      rule = make_rule(name: "uniq_rule", limit: 5, action: :block)
      rule = make_rule(name: "uniq_rule", limit: 5, action: :block, count_distinct: :project)

      result = evaluator(rules: [rule]).check_unique(identifier, member: 99)
      result = evaluator(rules: [rule]).check(unique_id)

      expect(result.exceeded?).to be(true)
      expect(result.action).to eq(:block)
@@ -1026,20 +1028,20 @@ RSpec.describe Labkit::RateLimit::Evaluator do
        hash_including(message: "rate_limit_error", error: "RuntimeError")
      )

      rule = make_rule(name: "uniq_rule")
      rule = make_rule(name: "uniq_rule", count_distinct: :project)
      result = described_class.new(name: "rack_request", rules: [rule], redis: redis, logger: logger)
        .check_unique(identifier, member: 1)
        .check(unique_id)

      expect(result.error?).to be(true)
      expect(result.exceeded?).to be(false)
    end

    it "still reports exceeded on re-SADD of an existing member when scard is already over the limit" do
      # added=false because the member was already in the set; scard unchanged at 6 (over limit=5)
    it "still reports exceeded on re-SADD of an existing value when scard is already over the limit" do
      # added=false because the value was already in the set; scard unchanged at 6 (over limit=5)
      allow(raw_redis).to receive(:pipelined).and_yield(unique_pipe).and_return([false, 6, 30])
      rule = make_rule(name: "uniq_rule", limit: 5, action: :block)
      rule = make_rule(name: "uniq_rule", limit: 5, action: :block, count_distinct: :project)

      result = evaluator(rules: [rule]).check_unique(identifier, member: 99)
      result = evaluator(rules: [rule]).check(unique_id)

      expect(result.exceeded?).to be(true)
      expect(result.action).to eq(:block)
@@ -1047,28 +1049,99 @@ RSpec.describe Labkit::RateLimit::Evaluator do
    end

    it "treats a :log rule as non-terminating and continues to the next rule" do
      log_r = make_rule(name: "log_r", action: :log, limit: 1)
      block_r = make_rule(name: "block_r", action: :block, limit: 100)
      log_r = make_rule(name: "log_r", action: :log, limit: 1, count_distinct: :project)
      block_r = make_rule(name: "block_r", action: :block, limit: 100, count_distinct: :project)
      # both rules will SADD; second one returns the same shape
      allow(raw_redis).to receive(:pipelined).and_yield(unique_pipe).and_return([true, 2, 30])

      result = evaluator(rules: [log_r, block_r]).check_unique(identifier, member: 1)
      result = evaluator(rules: [log_r, block_r]).check(unique_id)

      expect(result.rule).to eq(block_r)
      expect(result.action).to eq(:allow)
    end

    it "uses _unknown_ sentinel for missing characteristics in the rule key (member is separate)" do
      rule = make_rule(name: "uniq_rule", characteristics: [:user, :ip])
      id = Labkit::RateLimit::Identifier.new(user: 42)
    it "uses _unknown_ sentinel for missing characteristics in the rule key (count_distinct is separate)" do
      rule = make_rule(name: "uniq_rule", characteristics: [:user, :ip], count_distinct: :project)
      id = Labkit::RateLimit::Identifier.new(user: 42, project: 99)

      expect(unique_pipe).to receive(:sadd?)
        .with("labkit:rl:rack_request:uniq_rule:user:42:ip:_unknown_", "99")
      evaluator(rules: [rule]).check_unique(id, member: 99)
      evaluator(rules: [rule]).check(id)
    end

    describe "fail-open + log when count_distinct identifier key is missing" do
      it "logs rate_limit_missing_count_distinct and continues to the next rule when the key is absent" do
        rule = make_rule(name: "uniq_rule", count_distinct: :project)
        id = Labkit::RateLimit::Identifier.new(user: 42)
        logger = instance_double(Labkit::Logging::JsonLogger)
        expect(logger).to receive(:warn).with(
          hash_including(
            message: "rate_limit_missing_count_distinct",
            rule: "uniq_rule",
            count_distinct: "project"
          )
        )

        result = described_class.new(name: "rack_request", rules: [rule], redis: redis, logger: logger).check(id)

        expect(result.matched?).to be(false)
        expect(result.action).to eq(:allow)
      end

  describe "#peek_unique (SET-mode)" do
      it "logs when the value is nil" do
        rule = make_rule(name: "uniq_rule", count_distinct: :project)
        id = Labkit::RateLimit::Identifier.new(user: 42, project: nil)

        expect(null_logger).to receive(:warn).with(hash_including(message: "rate_limit_missing_count_distinct"))
        evaluator(rules: [rule]).check(id)
      end

      it "logs when the value is an empty string" do
        rule = make_rule(name: "uniq_rule", count_distinct: :project)
        id = Labkit::RateLimit::Identifier.new(user: 42, project: "")

        expect(null_logger).to receive(:warn).with(hash_including(message: "rate_limit_missing_count_distinct"))
        evaluator(rules: [rule]).check(id)
      end

      it "does not call SADD when the count_distinct key is missing" do
        rule = make_rule(name: "uniq_rule", count_distinct: :project)
        id = Labkit::RateLimit::Identifier.new(user: 42)

        expect(unique_pipe).not_to receive(:sadd?)
        expect(raw_redis).not_to receive(:expire)
        evaluator(rules: [rule]).check(id)
      end

      it "falls through to a following matching rule when the SET-mode rule's key is missing" do
        # SET-mode rule comes first; its key is missing, so it's skipped.
        # A subsequent INCR-mode rule then applies normally.
        uniq = make_rule(name: "uniq_first", count_distinct: :project, limit: 5)
        plain = make_rule(name: "plain_after", limit: 10, action: :block)
        id = Labkit::RateLimit::Identifier.new(user: 42)

        allow(raw_redis).to receive(:pipelined).and_yield(pipe).and_return([1, -1])
        expect(pipe).to receive(:incr).with("labkit:rl:rack_request:plain_after:user:42")

        result = evaluator(rules: [uniq, plain]).check(id)
        expect(result.rule).to eq(plain)
        expect(result.matched?).to be(true)
      end

      it "bumps errors_total when a count_distinct rule is skipped" do
        rule = make_rule(name: "uniq_rule", count_distinct: :project)
        id = Labkit::RateLimit::Identifier.new(user: 42)

        expect(Labkit::RateLimit::Metrics.errors_total)
          .to receive(:increment).with(rate_limiter: "rack_request")
        allow(Labkit::RateLimit::Metrics.calls_total).to receive(:increment)

        evaluator(rules: [rule]).check(id)
      end
    end
  end

  describe "#peek with count_distinct (SET-mode)" do
    let(:unique_pipe) { instance_double(Redis) }

    before do
@@ -1077,20 +1150,20 @@ RSpec.describe Labkit::RateLimit::Evaluator do
    end

    it "reads SCARD and TTL without SADD or EXPIRE" do
      rule = make_rule(name: "uniq_rule", characteristics: [:user])
      rule = make_rule(name: "uniq_rule", characteristics: [:user], count_distinct: :project)

      expect(unique_pipe).not_to receive(:sadd?)
      expect(raw_redis).not_to receive(:expire)
      expect(unique_pipe).to receive(:scard).with("labkit:rl:rack_request:uniq_rule:user:42")
      expect(unique_pipe).to receive(:ttl).with("labkit:rl:rack_request:uniq_rule:user:42")
      evaluator(rules: [rule]).peek_unique(identifier)
      evaluator(rules: [rule]).peek(identifier)
    end

    it "reports count=0 when key is missing (SCARD returns 0)" do
      allow(raw_redis).to receive(:pipelined).and_yield(unique_pipe).and_return([0, -2])
      rule = make_rule(name: "uniq_rule", limit: 5, period: 60)
      rule = make_rule(name: "uniq_rule", limit: 5, period: 60, count_distinct: :project)

      result = evaluator(rules: [rule]).peek_unique(identifier)
      result = evaluator(rules: [rule]).peek(identifier)

      expect(result.matched?).to be(true)
      expect(result.exceeded?).to be(false)
@@ -1100,9 +1173,9 @@ RSpec.describe Labkit::RateLimit::Evaluator do

    it "reports exceeded when the current cardinality is over the limit" do
      allow(raw_redis).to receive(:pipelined).and_yield(unique_pipe).and_return([10, 30])
      rule = make_rule(name: "uniq_rule", limit: 5, action: :block)
      rule = make_rule(name: "uniq_rule", limit: 5, action: :block, count_distinct: :project)

      result = evaluator(rules: [rule]).peek_unique(identifier)
      result = evaluator(rules: [rule]).peek(identifier)

      expect(result.exceeded?).to be(true)
      expect(result.action).to eq(:block)
@@ -1110,12 +1183,24 @@ RSpec.describe Labkit::RateLimit::Evaluator do

    it "fails open on Redis error" do
      allow(raw_redis).to receive(:pipelined).and_raise(RuntimeError, "down")
      rule = make_rule(name: "uniq_rule")
      rule = make_rule(name: "uniq_rule", count_distinct: :project)

      result = evaluator(rules: [rule]).peek_unique(identifier)
      result = evaluator(rules: [rule]).peek(identifier)

      expect(result.error?).to be(true)
      expect(result.exceeded?).to be(false)
    end

    it "does not require the count_distinct identifier key (reads the bucket cardinality)" do
      rule = make_rule(name: "uniq_rule", characteristics: [:user], count_distinct: :project)
      id = Labkit::RateLimit::Identifier.new(user: 42)
      allow(raw_redis).to receive(:pipelined).and_yield(unique_pipe).and_return([3, 30])

      expect(null_logger).not_to receive(:warn)
      result = evaluator(rules: [rule]).peek(id)

      expect(result.matched?).to be(true)
      expect(result.info.count).to eq(3)
    end
  end
end
Loading