Throttle
Rate limiting as something you configure rather than something that happens to you: named plans holding a token bucket, checked from your own application against subjects and actions you define, with cost weighting, refundable holds, and idempotent retries. Every example here was executed against a live server.
This is not the platform's own rate limiter. SnugNut already meters
every authenticated request against your tier, returns ratelimit-*
headers, and answers 429 when you run out - automatically, everywhere,
with nothing to configure; that behavior is
Rate limits. Throttle points the other way:
you write the policy, you decide what a subject is, and you call check
from your own code. Nothing here changes what the platform lets you send.
When to reach for it: per-tenant quotas in your own SaaS, "three trades per day" game rules, LLM spend ceilings where a heavy call costs more than a cheap one, per-device message caps, refundable holds for cancellable work.
When not to: cumulative usage against a budget - tokens spent, gigabytes stored - is Meter, which accumulates rather than refills. Holding users in line instead of rejecting them is Waiting Room. Changing what the platform allows a principal to send is the admin surface of the platform rate limiter.
Concepts
- A plan is a named policy: a slug id, a token bucket
{max_requests, window_seconds}, an optional action catalog, and defaults for enforcement and headers. Plans belong to the principal that created them; someone else's plan reads as404 plan_not_found, never403. - Subjects are opaque strings you choose, conventionally
namespace:id(user:42,device:sensor-9). A plan may carry asubject_namespacesallowlist; anything outside it is400 invalid_subject. - One bucket per (plan, subject, action). Actions do not share capacity, and a check with no action uses its own separate default bucket.
- Buckets refill continuously -
max_requeststokens spread evenly overwindow_seconds, not a fixed window that resets on the hour. A 10/60s plan hands back one token every six seconds. - Cost is the tokens an action spends: from the action catalog, or a
per-check
costoverride, defaulting to 1. - Enforcement decides the status of a denial.
softanswers200withallowed: falseso you can branch;hardanswers429withretry-after. Set the plan default, override per call with?enforce=. - Consume tokens come back on every allowed check, each good for exactly one refund inside the plan's refund window.
Creating a plan
snug throttle create-plan --id docs4-agent-calls --max 10 --window 60 \
--action invoke:1 --action invoke-heavy:5 \
--namespace user --tag ai --description "Agent call quota"
snug --output json throttle get-plan --id docs4-agent-calls
snug throttle update-plan --id docs4-agent-calls --max 20 --window 60
snug throttle delete-plan --id docs4-agent-calls -fPlan ids are lowercase slugs - a-z, 0-9, hyphen, starting alphanumeric;
Docs4_Bad is 400 invalid_plan_id and a repeat id is
409 plan_already_exists. update-plan needs --max and --window
together, since limits are replaced as a unit. Deleting a plan also drops
every bucket, consume log, and counter under it.
Checking and consuming
snug throttle check --plan docs4-agent-calls --subject user:42 --action invoke
snug throttle check --plan docs4-agent-calls --subject user:42 --cost 4
snug throttle check --plan docs4-agent-calls --subject user:42 --enforce hard
snug throttle check --plan docs4-agent-calls --subject user:42 --repeat 20Over HTTP the decision comes back in both headers and the envelope:
curl -i -X POST -H "Authorization: Bearer $SNUG_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"subject": "user:42", "action": "invoke"}' \
http://localhost:4000/api/v1/throttle/plans/docs4-agent-calls/checkHTTP/1.1 200 OK
ratelimit-limit: 10
ratelimit-remaining: 9
ratelimit-reset: 6{ "status": 200, "msg": "OK",
"data": { "allowed": true, "limit": 10, "remaining": 9,
"reset_at": 1788007628, "reset_after_seconds": 6,
"consume_token": "30f1fa9b-..." } }The same response also carried the GitHub-style trio, ending
x-ratelimit-reset: 1788007628. The dialects disagree on reset:
ratelimit-reset is seconds from now, x-ratelimit-reset is a unix
timestamp. A plan's headers_mode (standard, legacy, or both, the
default) picks which sets are emitted, so you can forward them to your own
callers verbatim. These are the header names the platform limiter also uses;
on a check response they describe your plan, not your platform budget.
Peeking and refunding
peek reads a bucket without spending from it; refund returns a consume
token's cost to it:
snug --output json throttle peek --plan docs4-agent-calls --subject user:42
snug --output json throttle refund --plan docs4-agent-calls --subject user:42 \
--action invoke --consume-token 30f1fa9b-...A successful refund answers {"refunded": true, "cost_refunded": 1}. It must
repeat the action the original check used; omitting it against an
action-scoped consume is 404 token_not_found_or_expired. Tokens are
single-use and expire with the plan's refund_window_seconds (60 by
default), both of which give that same 404. Refunds never push a bucket
above max_requests.
Idempotent retries
Adding an Idempotency-Key header to a check makes a retry replay the
original decision - same consume_token, same remaining - instead of
spending again. Keys are scoped to the plan and live for 60 seconds by
default. They are not scoped to the subject, so the same key sent for a
different subject replays the first subject's answer and charges nobody;
build the key from the subject and action as well as the operation.
Resetting and counters
snug throttle reset --plan docs4-agent-calls --subject user:42 -f
snug --output json throttle stats --plan docs4-agent-callsstats returns four counters - total_checks, total_allowed,
total_denied, total_refunded - aggregated plan-wide across every subject
and action, never per-subject. reset restores one subject's bucket to full
capacity and needs both a platform admin token (or the throttle:admin
scope) and ownership of the plan; an admin who did not create the plan
gets 404 plan_not_found.
Listing plans
snug --output json throttle list-plans --tag ai
snug --output json throttle list-plans -q "agent"Listing is scoped to your own plans and takes q (full text over the
description), sort_by, sort_order, page, and page_size from the
shared search grammar - but not filter,
which is accepted and silently ignored; tag is the only filter honored
here. Unlike most list endpoints, page=0 is a 400, not clamped.
Behaviors worth knowing
- An action name that is not in the catalog is not an error. It costs 1
token and gets its own fresh bucket, so a typo silently grants a full
quota rather than failing. Only names in
actionscarry a cost. reset_after_secondsmeans two different things. On an allowed check it is the time until the bucket is full; on a denial it is the shorter time until enough tokens exist for the cost you asked for.peekalways reports time-to-full.- Raising a plan's capacity does not top up existing buckets. After
max_requestswent 3 to 50, a subject sitting at 2 tokens stayed at 2 and refilled toward 50 at the new rate, while a first-time subject started at 50.resetgrants the new headroom immediately. - A hard
429body is not enveloped. Denials under hard enforcement return the bare check object -{"allowed": false, ...}with nostatus/msg/datawrapper. Readallowedoff the top level on a429. --repeatspends your own platform budget. It issues that many separate HTTP requests, so a large repeat count stops on a platform429before it drains the plan you are testing.- Costs above 10000 are
400 invalid_cost; subjects containing whitespace or any of* ? [ ]are400 invalid_subject.
Limits and configuration
100 plans per owner, 50 actions per plan, a cost ceiling of 10000 per check,
and a 60-second default refund window - all tunable, along with bucket TTL
and the plan-resolution cache, via the THROTTLE_* variables in the
Throttle CONFIG reference.
Reference
- Throttle API - every endpoint, callable
- Rate limits - the platform's own limiter, which applies to these endpoints too
- Related: Meter for cumulative usage against budgets, Waiting Room for queueing instead of rejecting, Subscription for entitlements