Legion.RateLimiter behaviour (Legion v0.5.0)

View Source

Behaviour for enforcing rate limits across Legion agents.

A rate limiter decides whether an agent may proceed under a list of Legion.RateLimiter.Rules. Each rule pairs an identity - the group of agents sharing a limit - with a Legion.RateLimiter.Policy. Legion calls enforce!/2 for you: configure a limiter and rules, and every turn is checked against them.

Legion.start_link(ChatAgent,
  rate_limit: [
    limiter: MyApp.RateLimiter,
    rules: [
      %Legion.RateLimiter.Rule{
        identity: %{"ip" => "203.0.113.42", "tenant" => "acme"},
        policy: %Legion.RateLimiter.Policy{
          window_ms: :timer.minutes(30),
          max_agents: 40,
          max_tokens: 200_000
        }
      },
      %Legion.RateLimiter.Rule{
        identity: %{"email" => "someone@example.com"},
        policy: %Legion.RateLimiter.Policy{window_ms: :timer.hours(24), max_agents: 5}
      }
    ]
  ]
)

A turn runs only if every rule allows it; the first rule that denies it cancels the turn. Rules are evaluated in the order given, so put the rule whose denial you want reported first.

For Postgres users there is a ready-made adapter - see Legion.RateLimiter.Postgres. It extends Legion.Store.Postgres, keeping rate-limit metadata in the store's table and counting the usage persisted there:

defmodule MyApp.RateLimiter do
  use Legion.RateLimiter.Postgres, repo: MyApp.Repo
end

Configuration

The limiter and a default policy can be set globally, leaving only the identities to the call site:

config :legion, :rate_limit,
  limiter: MyApp.RateLimiter,
  default_policy: %Legion.RateLimiter.Policy{window_ms: :timer.minutes(1), max_agents: 10}

Legion.start_link(ChatAgent,
  rate_limit: [rules: [%Legion.RateLimiter.Rule{identity: %{"ip" => "203.0.113.42"}}]]
)

A rule given without a :policy takes the default one. Rate limiting applies only when a limiter and at least one rule resolve; a limiter without rules, or rules without a limiter, disables it. Sub-agents inherit whatever their parent resolved and cannot override it - a parent started without rate limiting runs its whole subtree without it, even when the application configures rules globally. Each sub-agent is a separate agent ID in every group, so it counts towards :max_agents and its usage towards :max_tokens.

Rules must agree on their identities: two rules may share a field only with the same value, since the adapter records one group membership per agent. Two rules with the same identity and different policies are fine - for example a per-minute and a per-day window on one IP. Identity keys must be strings; an empty identity %{} matches every agent and acts as a global cap.

When Legion enforces

Enforcement happens once per turn, before the incoming message is appended and before any LLM request. A denial therefore leaves the conversation untouched and costs nothing.

Denied turns return {:cancel, {:rate_limited, violations}} - the same shape as {:cancel, :reached_max_iterations} - so a denied sub-agent reports back to its caller as a value rather than a crash. Legion also emits [:legion, :rate_limit, :exceeded] with the identity and policy of the rule that denied it; see Legion.Telemetry.

Resumed and recovered runs are not checked again. They are finishing work that was already allowed, so checking it again would discard accepted work instead of shedding new load.

Calling it yourself

resolve!/1 and enforce!/2 are public, so an application can rate-limit its own work - a webhook, a queue worker - under the same configuration as agents. Resolve the configured limiter and rules, which fills in the default policy, then enforce them under a stable id:

%{limiter: limiter, rules: rules} =
  Legion.RateLimiter.resolve!(
    rules: [%Legion.RateLimiter.Rule{identity: %{"ip" => "203.0.113.42"}}]
  )

if limiter, do: :ok = limiter.enforce!(agent_id, rules)

A nil limiter means rate limiting is off. An adapter can also be called directly with complete rules, skipping resolve!/1.

Implementing a rate limiter

Define a module that implements enforce!/2 when rate-limit state lives outside Postgres or needs application-specific coordination:

defmodule MyApp.RateLimiter do
  @behaviour Legion.RateLimiter

  @impl Legion.RateLimiter
  def enforce!(agent_id, rules) do
    # Check and record the rate-limit state for every rule, atomically.
    :ok
  end
end

An adapter receives all of an agent's rules in one call so it can allow or deny the call as a unit. See Legion.RateLimiter.Rule, Legion.RateLimiter.Policy, and Legion.RateLimiter.Postgres.

Summary

Callbacks

Enforces every rule in rules for the agent identified by agent_id.

Functions

Resolves the limiter and rules to enforce.

Types

limit_identity()

@type limit_identity() :: %{required(String.t()) => any()}

Callbacks

enforce!(agent_id, rules)

@callback enforce!(
  agent_id :: Legion.Store.agent_id(),
  rules :: [Legion.RateLimiter.Rule.t()]
) :: :ok | no_return()

Enforces every rule in rules for the agent identified by agent_id.

Returns :ok when every rule allows the call, and the agent counts towards each rule's limits from then on; a denied call counts for nothing. Rules are allowed together or denied together, never one by one. How identities match and where that state lives is up to the adapter.

Raises Legion.RateLimiter.ExceededError for the first rule, in list order, that is exceeded. Legion catches that exception and cancels the turn; any other error propagates, so an adapter that cannot reach its backing store fails the agent rather than silently allowing the call.

Functions

resolve!(overrides)

Resolves the limiter and rules to enforce.

Reads the application's :rate_limit config and applies overrides, a keyword list with :limiter and :rules, on top. Each key given in overrides replaces the configured value wholesale - rules are never merged one by one. When called inside an agent, the rate limit that agent resolved sits between the two, so overrides win over it and it wins over the application config.

Rules given without a :policy take the configured :default_policy. A policy that is present is used as is; missing fields are not filled in.

Returns %{limiter: module | nil, rules: [Legion.RateLimiter.Rule.t()]}. A nil limiter or an empty rule list means rate limiting is off; both keys are then reset, so the result is always a complete map.

Raises ArgumentError when :rules is not a list of Legion.RateLimiter.Rule structs, a rule fails Legion.RateLimiter.Rule.validate!/1, or two rules give one identity key different values.

Examples

# config :legion, :rate_limit, limiter: MyApp.RateLimiter, default_policy: policy
Legion.RateLimiter.resolve!(rules: [%Legion.RateLimiter.Rule{identity: %{"ip" => ip}}])
#=> %{limiter: MyApp.RateLimiter, rules: [%Legion.RateLimiter.Rule{identity: %{"ip" => ip}, policy: policy}]}

Legion.RateLimiter.resolve!(nil)
#=> %{limiter: nil, rules: []}