Commit 0ae4a8b5 authored by Max Woolf's avatar Max Woolf
Browse files

docs(rate_limit): add module README and link from root README

Adds lib/labkit/rate_limit/README.md covering configuration, Limiter
construction, the Rule/Identifier/Matcher/Result model, action
semantics, Redis key shape, fail-open behavior, and emitted Prometheus
metrics. Links the new README from the root README's Functionality list.

Also corrects an inaccurate inline comment in rule.rb that described
:allow as a bypass with no Redis writes; the evaluator always
increments the counter when a rule matches and :allow only changes the
reported action.

Closes #68

Co-Authored-By: default avatarClaude Opus 4.7 (1M context) <noreply@anthropic.com>
parent 14940bbe
Loading
Loading
Loading
Loading
Loading
+1 −0
Original line number Diff line number Diff line
@@ -24,6 +24,7 @@ LabKit-Ruby provides functionality in a number of areas:
1. `Labkit::FIPS` for checking for FIPS mode and using FIPS-compliant algorithms.
1. `Labkit::Logging` for sanitizing log messages.
1. `Labkit::Metrics` for metrics. More on the [README](./lib/labkit/metrics/README.md).
1. `Labkit::RateLimit` for rules-based, Redis-backed rate limiting. More on the [README](./lib/labkit/rate_limit/README.md).
1. `Labkit::RSpec` for RSpec matchers to test Labkit functionality (requires selective loading). More on the [README](./lib/labkit/rspec/README.md).
1. `Labkit::Tracing` for handling and propagating distributed traces.

+262 −0
Original line number Diff line number Diff line
# Labkit::RateLimit

`Labkit::RateLimit` is a rules-based rate limiter backed by Redis counters. It
maintains a fixed-window counter per `(call-site, rule, characteristics)` tuple
and decides whether each request is within the configured limit.

The module is intentionally small: a `Limiter` is configured at boot with an
ordered list of `Rule`s, and every request calls `Limiter#check(identifier)` to
get back a `Result` describing what the caller should do.

## Configuration

`Labkit::RateLimit.configure` sets a global Redis connection pool and logger
that are reused across all `Limiter` instances unless a per-Limiter override is
supplied:

```ruby
Labkit::RateLimit.configure do |c|
  c.redis  = ConnectionPool.new(size: 5) { Redis.new(url: ENV["REDIS_URL"]) }
  c.logger = Labkit::Logging::JsonLogger.new($stdout)
end
```

The `redis` value must respond to `.with { |conn| ... }` and yield a connection
that supports `incr`, `ttl`, `get`, `expire`, and `pipelined`. A
`ConnectionPool` of `Redis` clients is the typical choice.

The logger is used only for warnings (invalid rule names, fail-open errors,
duplicate rule names in production). It defaults to a JSON logger writing to
`$stdout`.

## Defining a Limiter

A `Limiter` is the unit of configuration for one call site (e.g. "rack
requests", "graphql mutations", "ai actions"). Construct it once and reuse it:

```ruby
RACK_LIMITER = Labkit::RateLimit::Limiter.new(
  name: "rack_request",
  rules: [
    Labkit::RateLimit::Rule.new(
      name: "api_user",
      limit: 600,
      period: 60,
      characteristics: [:user],
      match: { endpoint: { re: '\A/api/' } }
    ),
    Labkit::RateLimit::Rule.new(
      name: "unauthenticated",
      limit: 60,
      period: 60,
      characteristics: [:ip],
      match: { user: nil }
    )
  ]
)
```

- `name` must match `/\A[a-z0-9_]+\z/`. It is used as the first segment of
  every Redis counter key for this limiter, so renaming a `Limiter` abandons
  any in-flight counters.
- `rules` is an ordered array of `Rule` objects. The first rule whose `match`
  hash is satisfied wins (with the exception of `:log` rules — see [Actions](#actions)).
- `redis` and `logger` are optional; they fall back to the global
  `Labkit::RateLimit.config` values.

A `Labkit::RateLimit.check(name:, identifier:, rules:, ...)` convenience method
exists for one-off cases that cannot cache a `Limiter` instance, but it
allocates a fresh `Limiter` on every call and is not the recommended path.

## Checking a request

`Limiter#check(identifier)` increments the counter for the matched rule and
returns a `Result`. `identifier` is either an `Identifier` or a plain `Hash`
of caller attributes:

```ruby
result = RACK_LIMITER.check(
  user:     current_user&.id,
  ip:       request.ip,
  endpoint: request.path
)

if result.exceeded? && result.action == :block
  response.headers.merge!(result.to_response_headers)
  render plain: "Too Many Requests", status: 429
  return
end
```

The `endpoint` key is treated specially: the query string is stripped at
`Identifier` construction time so URLs that vary only by query parameter share
the same counter.

### Peeking without incrementing

`Limiter#peek(identifier)` returns the same `Result` shape but does not
mutate Redis. It is useful when one code path should account for the request
(`check`) and another should gate a side-effect on whether the caller is
already over-limit. `peek` skips `:log` rules — their state is unobservable
without incrementing.

## Identifier

`Identifier` is a small value object wrapping a hash of caller attributes.
You can pass a `Hash` to `check`/`peek` and `Limiter` will wrap it for you, or
construct one explicitly:

```ruby
id = Labkit::RateLimit::Identifier.new(
  user:     42,
  ip:       "1.2.3.4",
  endpoint: "/api/v4/projects/1?per_page=20"  # becomes "/api/v4/projects/1"
)
```

Keys can be symbols or strings — they are normalised to symbols on the way
in.

## Rule

A `Rule` is a `Data.define` value object with the following fields:

| field             | meaning                                                                                                                                                              |
|-------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `name`            | Stable identifier used in Redis keys and metric labels. Must match `/\A[a-z0-9_]+\z/`, max 64 chars. Renaming a rule abandons its in-flight counters.                |
| `match`           | Hash of identifier key/value predicates that must **all** be satisfied for the rule to apply. Empty hash matches anything. See [Matchers](#matchers).                |
| `limit`           | Integer request threshold per `period`. May be a callable resolved on every check.                                                                                   |
| `period`          | Window length in seconds. May be a callable resolved on every check.                                                                                                 |
| `action`          | What the result reports when the limit is exceeded. One of `:block`, `:log`, `:allow`. Default `:block`. See [Actions](#actions).                                    |
| `characteristics` | Array of identifier keys whose values are folded into the Redis counter key. Each unique combination gets its own counter.                                           |

Making `limit` or `period` callable is the supported pattern for
runtime-tunable thresholds (e.g. feature flags or database-backed settings):

```ruby
Labkit::RateLimit::Rule.new(
  name: "api_user",
  limit:  -> { Settings.rate_limit_api_user_per_minute },
  period: 60,
  characteristics: [:user]
)
```

### Matchers

A `match` hash gates whether a rule applies. Each value is normalised through
`Matcher.build`:

| input shape       | matcher  | example                                                |
|-------------------|----------|--------------------------------------------------------|
| plain value       | `eq`     | `match: { user: nil }`, `match: { method: "POST" }`    |
| `Regexp`          | `re`     | `match: { endpoint: %r{\A/api/} }`                     |
| `{ eq: <value> }` | `eq`     | `match: { method: { eq: "POST" } }` (YAML-friendly)    |
| `{ re: <source> }`| `re`     | `match: { endpoint: { re: '\A/api/' } }` (YAML-friendly) |

`re` coerces the identifier value via `#to_s` before matching, so it can be
used against non-String values (e.g. matching a 503 status against `{ re: '^5' }`).

Glob and prefix matchers are intentionally out of scope.

### Actions

The rule's `action` controls how the `Result` reports an over-limit hit. The
counter is always incremented when a rule matches, regardless of `action`:

- `:block` — when exceeded, `Result#action` is `:block`. Caller should reject
  the request (e.g. with HTTP 429). When under the limit, action is `:allow`.
- `:log`**non-terminating**. The rule counts the request and records
  metrics, but evaluation continues to the next rule. This is the mechanism
  for shadow rules during rollout: stack a `:log` rule and a `:block` rule
  together and the `:log` rule cannot disable the `:block` rule. Note that a
  pure `:log`-only check still emits one `rule="unmatched"` metric entry
  because no terminating rule fired.
- `:allow` — when exceeded, `Result#action` is `:allow` (rather than
  `:block`). Useful for "always allow this caller even if they're over the
  limit" cases while still observing them via metrics. Evaluation terminates
  on the first match.

### Redis keys

Each matched check writes a key shaped:

```
labkit:rl:<limiter_name>:<rule_name>:<char>:<value>[:<char>:<value>...]
```

Characteristic values longer than 200 bytes are replaced with a SHA-256
hexdigest to bound key length. Missing or empty characteristic values are
encoded as `_unknown_`. The TTL is set on the first write of each window
(`count == 1`) and is not extended on subsequent INCRs, so the window is a
true fixed window starting at the first request, not a sliding window.

## Result

`Result` carries the decision back to the caller:

```ruby
result.matched?           # => true if some rule matched
result.exceeded?          # => true if the matched rule's counter > limit
result.action             # => :block | :log | :allow
result.rule               # => the matched Rule, or nil
result.error?             # => true if Redis failed (see Fail-open)
result.info               # => Result::Info or nil
result.to_response_headers
# => { "RateLimit-Limit" => "...", "RateLimit-Remaining" => "...", "RateLimit-Reset" => "<unix-ts>" }
```

`Result::Info` holds the per-window counter snapshot:

| field             | meaning                                                          |
|-------------------|------------------------------------------------------------------|
| `resolved_limit`  | The evaluated `Integer` limit for this check.                    |
| `resolved_period` | The evaluated `Integer` period in seconds for this check.        |
| `count`           | Raw INCR value; useful for utilization-ratio metrics.            |
| `remaining`       | `[resolved_limit - count, 0].max`.                               |
| `reset_at`        | Best-effort UTC `Time` when the window resets (advisory only).   |

`to_response_headers` returns `{}` for an unmatched or error result, so it is
safe to merge unconditionally.

## Fail-open

The evaluator wraps `check` and `peek` in a broad rescue. Any `StandardError`
(Redis connection failure, timeout, OOM in user-supplied callables, …) is
logged at WARN with `message: "rate_limit_error"` and returned as a
`Result(matched: false, error: true, action: :allow)`. The
`gitlab_labkit_rate_limiter_errors_total` counter is incremented. The caller
should treat the request as allowed.

## Metrics

`Labkit::RateLimit::Metrics` emits the following Prometheus metrics through
`Labkit::Metrics::Client`:

| metric                                          | type    | labels                              | meaning                                                              |
|-------------------------------------------------|---------|-------------------------------------|----------------------------------------------------------------------|
| `gitlab_labkit_rate_limiter_calls_total`        | counter | `rate_limiter`, `rule`, `action`    | One increment per terminating decision; also incremented per matched `:log` rule. `action` is one of `"allow"`, `"block"`, `"log"`. `rule="unmatched", action="allow"` when no rule terminated. |
| `gitlab_labkit_rate_limiter_errors_total`       | counter | `rate_limiter`                      | Fail-open events (any `StandardError` in the labkit path).            |
| `gitlab_labkit_rate_limiter_limit`              | gauge   | `rate_limiter`, `rule`              | Resolved limit at the last check (useful when `limit:` is callable). |
| `gitlab_labkit_rate_limiter_period_seconds`     | gauge   | `rate_limiter`, `rule`              | Resolved period at the last check.                                   |

Because `:log` rules do not terminate, a single `check` call can emit
**multiple** `calls_total` increments: one per `:log` rule that matched, plus
one for the terminating decision (or `rule="unmatched"` if no terminating
rule fired).

## Dev/test vs production guards

`Limiter.new` and `Rule.new` validate names and configuration. In
`Labkit.dev_or_test?` mode (`RAILS_ENV` set to `development` or `test`), they
raise `ArgumentError` on:

- invalid limiter or rule names
- duplicate rule names within a single `Limiter`
- unknown `action` values
- rule names longer than 64 characters

In production, the same conditions are downgraded: invalid names are
sanitised (and the original/sanitised pair is logged), duplicate rule names
are dropped (first occurrence wins), and a warning is logged. This keeps a
misconfiguration from taking the application down at boot.
+2 −1
Original line number Diff line number Diff line
@@ -14,7 +14,8 @@ module Labkit
    # period          - window in seconds; may be a callable (resolved per check)
    # action          - :block (enforce), :log (count and log only, do not block,
    #                   evaluation continues to subsequent rules), or :allow
    #                   (bypass: short-circuit evaluation with no Redis writes)
    #                   (count but always permit; terminates evaluation on match
    #                   regardless of whether the limit was exceeded)
    # characteristics - identifier keys used to build the compound Redis counter key
    #
    # +name+ must be a lowercase alphanumeric-and-underscore string of at most 64