Class: Labkit::RateLimit::Evaluator Private
- Inherits:
-
Object
- Object
- Labkit::RateLimit::Evaluator
- 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
- #check(identifier, cost: 1, rule_context: nil) ⇒ Object private
-
#clear(identifier) ⇒ Object
private
Deletes this limiter's counters, and any bans, for one identifier.
-
#initialize(name:, rules:, redis:, logger:) ⇒ Evaluator
constructor
private
A new instance of Evaluator.
-
#peek(identifier, rule_context: nil) ⇒ Object
private
Read-without-increment counterpart to #check.
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 |