Commit e934aa6a authored by Max Woolf's avatar Max Woolf
Browse files

docs(rate_limit): add mermaid diagrams for architecture, evaluation, and Redis flow

Adds three diagrams to the module README:
- architecture overview (Limiter -> Evaluator -> Redis/Metrics)
- rule evaluation flowchart highlighting the :log non-terminating
  behavior, the unmatched fallback, and the fail-open path
- Redis sequence for INCR + TTL pipelining with conditional EXPIRE on
  first write of a window

Co-Authored-By: default avatarClaude Opus 4.7 (1M context) <noreply@anthropic.com>
parent 0ae4a8b5
Loading
Loading
Loading
Loading
+67 −0
Original line number Diff line number Diff line
@@ -8,6 +8,24 @@ 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.

## Architecture

```mermaid
flowchart LR
    App[Application code] -->|"check(identifier)"| Limiter
    Limiter -->|delegates| Evaluator
    Evaluator -->|iterates ordered| Rules[Rule list]
    Evaluator <-->|INCR / TTL / EXPIRE| Redis[(Redis)]
    Evaluator -->|emits| Metrics[Prometheus metrics]
    Evaluator -->|returns| Result
    Result --> App
```

A `Limiter` is configured once per call site and holds an `Evaluator` plus the
compiled `Rule` list. Every `check` call delegates to the same `Evaluator`,
which iterates the rules in declaration order, talks to Redis, emits metrics,
and builds a `Result`.

## Configuration

`Labkit::RateLimit.configure` sets a global Redis connection pool and logger
@@ -159,6 +177,30 @@ used against non-String values (e.g. matching a 503 status against `{ re: '^5' }

Glob and prefix matchers are intentionally out of scope.

### Evaluation flow

`check` walks the rule list in order. The first **terminating** rule wins;
`:log` rules count but do not terminate, so they cannot disable a following
`:block` rule. A pure `:log`-only path still emits one `rule="unmatched"`
metric increment because no terminating rule fired.

```mermaid
flowchart TD
    Start([check identifier]) --> Iter{Next rule?}
    Iter -->|yes| Match{rule.match<br/>all satisfied?}
    Match -->|no| Iter
    Match -->|yes| Eval["INCR Redis counter<br/>(see Redis sequence below)"]
    Eval --> Build[Build Result<br/>resolve limit/period]
    Build --> Emit[Emit calls_total + limit/period gauges]
    Emit --> Act{rule.action}
    Act -->|":log<br/>(non-terminating)"| Iter
    Act -->|:block or :allow| Return([Return Result])
    Iter -->|no more rules| Unmatched[Emit calls_total<br/>rule=unmatched, action=allow]
    Unmatched --> ReturnUnmatched([Return matched=false<br/>action=:allow])
    Eval -. StandardError .-> Error[Emit errors_total<br/>log warn]
    Error --> ReturnErr([Return error=true<br/>action=:allow])
```

### Actions

The rule's `action` controls how the `Result` reports an over-limit hit. The
@@ -191,6 +233,31 @@ 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.

```mermaid
sequenceDiagram
    autonumber
    participant E as Evaluator
    participant P as Connection pool
    participant R as Redis

    E->>P: pool.with { |conn| ... }
    P-->>E: conn
    E->>R: PIPELINE { INCR key, TTL key }
    R-->>E: [count, ttl]
    alt count == 1 (first write of window)
        E->>R: EXPIRE key period
        R-->>E: 1
        Note over E: ttl returned is -1 here;<br/>build_result falls back to<br/>resolved_period for reset_at.
    else count > 1
        Note over E: TTL is not extended:<br/>fixed window from first write.
    end
    E-->>P: release conn
```

`peek` follows the same shape but uses `GET` instead of `INCR` and never
issues `EXPIRE`. A missing key (`GET → nil`, `TTL → -2`) is reported as
`count = 0` and the window is treated as not-yet-started.

## Result

`Result` carries the decision back to the caller: