Core resources

Rate Limit (Advanced)

A rate_limit rule has a Simple mode and an Advanced mode. Simple picks a built-in dimension (IP, User-Agent, Consumer, API key) and is enough for most uses. Advanced lets you write the counter key and the apply-condition in Lua, in the same sandbox as Functions, when you need a dimension the built-ins do not cover — per-tenant, per-model, per-header combinations, time-of-day gating, etc.

Concepts#

Every rate_limit rule maintains an integer counter that increments on each matching request. When the counter exceeds threshold within timespan seconds, the firewall returns 429 Too Many Requests (or just logs the event when dryrun is on). The counter resets when its window expires.

Two pieces define how the counter behaves for a request:

  • Counter Key — a string. Two requests producing the same key share the same counter. Different keys count independently. This is what defines the dimension of the rule (per IP, per tenant, per IP+model, …).
  • Conditions Matched — a boolean. When false, the rule is skipped for the current request (no increment, no 429). Use this to gate the rule on something other than the dimension itself — e.g. only rate-limit free-tier callers.

In Simple mode AIronClaw computes both for you. In Advanced mode you write each as a Lua snippet.

Counter key shape#

The full counter key is built server-side. Your snippet only owns the trailing dimension segment:

aifw:ratelimit:<service_id>:<scope>:<rule_name>:<your return value>
└─────────── server-controlled (immutable) ──────┘ └────────────────┘
                                                    you control this
  • <service_id> — the proxy UUID. You cannot cross-write between proxies.
  • <scope>all when the rule applies to every tool / the whole proxy, or tool:<name> when the rule is scoped to a specific MCP tool.
  • <rule_name> — the name field of the rule. Different rules on the same proxy keep independent counters even if their snippets return the same string.
  • <your return value> — what your Counter Key snippet returns, after sanitization (see below). This is the only segment you can influence; the others are pinned server-side.
Why you only control the last segment

The fixed prefix is the security boundary: an Advanced rule can only ever address counters that live under its own (proxy, scope, rule) tuple. A misbehaving or malicious snippet cannot collide with another rule's counter, escape its proxy, or overwrite unrelated state.

The two snippets#

Counter Key

Returns a string. Required in Advanced mode. Two requests that produce the same string share a counter; different strings are independent counters.

-- Per caller (API key, consumer, or IP — best available).
return aifw.helpers.caller_id()

If your snippet returns a non-string, nil, or the empty string, the firewall falls back to the literal default for the dimension segment so the counter is still well-formed.

Conditions Matched (optional)

Returns a boolean. When false, the rule is skipped for this request: no counter increment, no 429. When the editor is empty the rule always applies.

-- Rate-limit only callers tagged with the "free_tier" custom field.
return aifw.helpers.has_custom_field("free_tier")

Anything other than true (including nil, false, 0, "") is treated as "skip the rule for this request".

aifw.* surface#

Both snippets run in the same sandbox as Functions. The aifw.* global is the only entry point — no os.execute, no network, no filesystem. Useful keys for rate-limit logic:

KeyTypeNotes
aifw.request.client_ip()stringForwarded client IP, accounting for the trust chain configured on the proxy.
aifw.request.method()stringHTTP method of the inbound request, e.g. POST.
aifw.request.path()stringInbound request path.
aifw.request.headers()tableAll inbound request headers as a table. Use aifw.request.get_header("x-…") for a single value.
aifw.context.tool_namestring | nilSet on MCP tools/call requests; nil for non-tool calls and on LLM proxies.
aifw.context.auth.api_keytable{ name = "…", permissions = {"tier:free", "tenant:acme", …} } when the caller authenticated with an AIronClaw API key.
aifw.context.auth.jwttable{ claims = {…}, header = {…}, raw = "…" } in JWT mode.
aifw.context.mcp_uuidstring | nilThe proxy id. Same for every request to this proxy.

Utilities like aifw.json, aifw.re, aifw.hash, aifw.hmac, aifw.base64, aifw.uuid and aifw.time are available too — see the Functions reference for the full surface. Note that mutating helpers (set_header, set_body, aifw.response.exit) are not appropriate inside a rate-limit snippet: the snippet is meant to observe the request, not change it.

aifw.helpers — ergonomic shortcuts#

A small set of helper functions covers the patterns that would otherwise require boilerplate loops or chained and checks. They are available in every snippet — Counter Key, Conditions Matched, and Functions — without import.

HelperReturnsWhat it does
aifw.helpers.has_custom_field(value)booleanTrue when the API key has a Custom Field with this exact value. Pass the value as you typed it in the dashboard (the internal custom: namespace is handled for you). False in JWT mode (custom fields belong to API keys).
aifw.helpers.has_any_custom_field({...})booleanTrue if the API key carries at least one of the listed Custom Fields. Useful for "callers in any of these groups" without writing the loop yourself.
aifw.helpers.custom_fields()arrayEvery Custom Field on the current API key, prefix stripped. Handy when you want to fold all of them into the counter dimension.
aifw.helpers.caller_id()stringBest-available stable identifier for the caller, in order: API-key name → JWT subject claim → Kong consumer id → forwarded client IP. Use this as the natural default in Counter Key.
aifw.helpers.is_tool(name_or_list)booleanTrue if the current MCP tool matches. Accepts either a single name ("get_logs") or a list ({ "get_logs", "tail_logs" }). Always false for non-MCP requests.

The Custom Fields you reference here are the same ones you set per API key from the Keys page. Add or remove a field there and your gating logic picks it up on the next request — no rule edit needed.

is_tool inside a rate-limit rule

is_tool reads aifw.context.tool_name, which is resolved during MCP body parsing. A rate-limit rule with scope * on an MCP proxy fires before that parsing step, so is_tool always returns falsethere. For tool-specific gating, set the rule's Apply to scope to the target tool instead of using is_tool in Conditions Matched — the rule then runs only when that tool is invoked, with tool_name already populated. Inside Functions is_toolworks as you'd expect because lambdas run post-parse.

Sanitization & limits#

Before the dimension segment is appended to the counter key, the firewall normalizes your return value:

  • Control characters (\r, \n, \0, …) are folded to _.
  • Anything outside [A-Za-z0-9._:-] is folded to _. Spaces, slashes, parentheses, etc. all collapse.
  • The result is truncated to 256 bytes.
  • nil, non-string, or empty result becomes default.
Designing for sanitization

Build keys from already-clean components. If you key on User-Agent, hash it first (aifw.hash.sha256(ua):sub(1, 16)) — UA strings contain spaces and parentheses that would otherwise collapse to _and reduce the dimension's resolution.

Validation & errors#

When you save a rule with Advanced fields, the firewall compiles each snippet in the sandbox before persisting the rule. Snippets that do not parse are rejected with a 400 and a message naming the offending field — the rule is not saved. This is the only way a snippet ever reaches the runtime path: if it parsed at save time, it is the same chunk that runs at request time.

A snippet that parses but errors at request time (e.g. it indexes a nil value) is logged and the rule behaves as a no-op for that request: no counter increment, no 429. Other rules on the request are unaffected. Watch your proxy logs for [aifw-rl-lua:rl:<rule>:key] and [aifw-rl-lua:rl:<rule>:cond] warnings when something is off.

Examples#

Free tier only, per caller

Rate-limit only the API keys tagged with the free_tier Custom Field; paid tiers bypass the rule. Counter is per caller so two free-tier users have independent buckets.

-- Conditions Matched
return aifw.helpers.has_custom_field("free_tier")

-- Counter Key
return aifw.helpers.caller_id()

Per caller + per model (LLM)

Useful when an expensive model needs a tighter cap than the rest. One counter per (caller, model) pair.

-- Counter Key
local body  = aifw.context.body or {}
local model = (type(body.model) == "string" and body.model) or "unknown"
return aifw.helpers.caller_id() .. ":" .. model

Limit only on a specific MCP tool

-- Conditions Matched — apply only when the caller hits expensive tools
return aifw.helpers.is_tool({"export_logs", "rebuild_index"})

-- Counter Key
return aifw.helpers.caller_id()

Per request header

-- Counter Key — limit per X-Org-Id header value
local org = aifw.request.get_header("x-org-id")
if not org or org == "" then
  return "no-org"   -- groups all unauthenticated callers under one bucket
end
return "org:" .. org

Time-of-day gating

-- Conditions Matched — only enforce the rule outside business hours
local hour = tonumber(os.date("%H"))
if hour and hour >= 9 and hour < 18 then
  return false   -- skip the rule during 09:00–18:00 server-local
end
return true