Class: Labkit::RateLimit::Evaluator Private

Inherits:
Object
  • Object
show all
Defined in:
lib/labkit/rate_limit/evaluator.rb

Overview

This class is part of a private API. You should avoid using this class if possible, as it may be removed or be changed in the future.

Evaluator holds the static parts of a rate limit check (name, rules, Redis) and exposes a per-request #check(identifier) method.

Defined Under Namespace

Classes: RuleCursor

Constant Summary collapse

REDIS_KEY_PREFIX =

This constant is part of a private API. You should avoid using this constant if possible, as it may be removed or be changed in the future.

"labkit:rl"
BAN_KEY_SUFFIX =

This constant is part of a private API. You should avoid using this constant if possible, as it may be removed or be changed in the future.

":ban"
CHAR_VALUE_MAX_LENGTH =

This constant is part of a private API. You should avoid using this constant if possible, as it may be removed or be changed in the future.

200
MISSING_VALUE_SENTINEL =

This constant is part of a private API. You should avoid using this constant if possible, as it may be removed or be changed in the future.

"_unknown_"
INCR_SCRIPT =

This constant is part of a private API. You should avoid using this constant if possible, as it may be removed or be changed in the future.

Atomic increment-with-TTL Lua script. The whole script runs as one operation from Redis's perspective, so there is no window between the increment and EXPIRE that can leak a key without TTL.

INCRBYFLOAT serves both count-mode (cost=1, equivalent to INCR for integer-encoded keys) and cost-mode callers, so a single script handles every rule shape. cost=0 also flows through INCRBYFLOAT; Redis treats the result as a no-op on the stored value while still observing the post-state count and TTL we return.

ttl_after < 0 covers TTL=-2 (key missing) and TTL=-1 (no expiry). The -1 case shouldn't arise with the atomic script, but self-healing recovers keys left without TTL by any prior bug.

Labkit::Redis::Script.new(<<~LUA)
  local ttl = ARGV[1]
  local cost = tonumber(ARGV[2])

  local count = redis.call('INCRBYFLOAT', KEYS[1], cost)
  local ttl_after = redis.call('TTL', KEYS[1])
  if ttl_after < 0 then
    redis.call('EXPIRE', KEYS[1], ttl)
    ttl_after = tonumber(ttl)
  end

  return {count, ttl_after}
LUA
SADD_SCRIPT =

This constant is part of a private API. You should avoid using this constant if possible, as it may be removed or be changed in the future.

Atomic SADD + SCARD + conditional EXPIRE. SET-cardinality counterpart of INCR_SCRIPT; same shape (mutate, read TTL, set TTL when missing, return post-state TTL). count is SCARD, not the SADD return.

ttl_after < 0 covers TTL=-2 (key missing) and TTL=-1 (no expiry), so this also self-heals orphan keys left without TTL.

Labkit::Redis::Script.new(<<~LUA)
  local ttl = ARGV[1]
  local member = ARGV[2]

  redis.call('SADD', KEYS[1], member)
  local count = redis.call('SCARD', KEYS[1])
  local ttl_after = redis.call('TTL', KEYS[1])
  if ttl_after < 0 then
    redis.call('EXPIRE', KEYS[1], ttl)
    ttl_after = tonumber(ttl)
  end

  return {count, ttl_after}
LUA
BAN_SCRIPT =

This constant is part of a private API. You should avoid using this constant if possible, as it may be removed or be changed in the future.

Atomic ban check + INCR + conditional ban write, for rules carrying ban_for.

An active ban short-circuits before the increment, so a banned caller cannot extend its own window, matching Rack::Attack::Allow2Ban. This applies to :log rules too: a shadow that kept counting through its own ban would report more than enforcement would produce. The counter and the ban must be read and written together or a concurrent check can miss the threshold crossing.

Returns counter TTL, ban TTL, where a ban TTL below zero means not banned.

Labkit::Redis::Script.new(<<~LUA)
  local rule_counter_key = KEYS[1]
  local ban_key = KEYS[2]

  local ttl = ARGV[1]
  local cost = tonumber(ARGV[2])
  local limit = tonumber(ARGV[3])
  local ban_for = tonumber(ARGV[4])

  -- TTL answers both questions at once: >= 0 means the ban key exists
  -- and tells us how long is left, so no separate EXISTS is needed.
  local ban_ttl = redis.call('TTL', ban_key)
  if ban_ttl >= 0 then
    local existing = redis.call('GET', rule_counter_key)
    return {existing or '0', redis.call('TTL', rule_counter_key), ban_ttl}
  end

  local count = redis.call('INCRBYFLOAT', rule_counter_key, cost)
  local ttl_after = redis.call('TTL', rule_counter_key)
  if ttl_after < 0 then
    redis.call('EXPIRE', rule_counter_key, ttl)
    ttl_after = tonumber(ttl)
  end

  if tonumber(count) > limit then
    redis.call('SET', ban_key, '1', 'EX', ban_for)
    return {count, ttl_after, ban_for}
  end

  return {count, ttl_after, -1}
LUA

Instance Method Summary collapse

Constructor Details

#initialize(name:, rules:, redis:, logger:) ⇒ Evaluator

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Returns a new instance of Evaluator.



114
115
116
117
118
119
# File 'lib/labkit/rate_limit/evaluator.rb', line 114

def initialize(name:, rules:, redis:, logger:)
  @name   = name
  @rules  = rules
  @redis  = redis
  @logger = logger
end

Instance Method Details

#check(identifier, cost: 1, rule_context: nil) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



121
122
123
124
125
126
127
128
129
130
131
132
133
134
# File 'lib/labkit/rate_limit/evaluator.rb', line 121

def check(identifier, cost: 1, rule_context: nil)
  cursor = RuleCursor.new
  result = check_rules(identifier, cost, rule_context, cursor)
rescue StandardError => e
  # Intentionally broad: fail-open applies to any unexpected error (network,
  # timeout, OOM) not only Redis protocol errors.
  report_error_metrics
  log_error(e, identifier, cursor.rule)
  result = Result.error
ensure
  # StandardError-safe emission, so it cannot mask a propagating error.
  # result is nil when a non-StandardError unwinds: emit nothing then.
  report_check_metrics(result) if result
end

#clear(identifier) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Deletes this limiter's counters, and any bans, for one identifier.

Every rule is cleared, matched or not: a caller clearing state after a success knows the identifier, not which rules happened to match on the way in. Rules whose keys do not exist are a no-op.

Fails open like #check: an unreachable Redis leaves the state to expire on its own rather than raising into the caller.



156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
# File 'lib/labkit/rate_limit/evaluator.rb', line 156

def clear(identifier)
  # Counted as we go, so a connection lost mid-loop still reports the keys
  # that did go rather than claiming nothing was cleared.
  removed = 0

  key_groups = @rules.filter_map do |rule|
    next if rule.action == :skip

    [build_redis_key(rule, identifier), build_redis_key(rule, identifier, BAN_KEY_SUFFIX)]
  end

  return 0 if key_groups.empty?

  # One DEL per rule. The hash tag holds the rule name, so a rule's own
  # keys share a slot but two rules do not, and Redis Cluster rejects a
  # DEL spanning slots.
  @redis.with do |conn|
    key_groups.each { |group| removed += conn.del(*group) }
  end

  removed
rescue StandardError => e
  report_error_metrics
  log_error(e, identifier, nil)
  removed
end

#peek(identifier, rule_context: nil) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Read-without-increment counterpart to #check. Same matching and Result shape; the underlying Redis counter is not mutated and the TTL is not extended. A missing Redis key is treated as count=0 (matched, not exceeded).



139
140
141
142
143
144
145
146
# File 'lib/labkit/rate_limit/evaluator.rb', line 139

def peek(identifier, rule_context: nil)
  cursor = RuleCursor.new
  peek_rules(identifier, rule_context, cursor)
rescue StandardError => e
  report_error_metrics
  log_error(e, identifier, cursor.rule)
  Result.error
end