ServicesThrottle

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 as 404 plan_not_found, never 403.
  • Subjects are opaque strings you choose, conventionally namespace:id (user:42, device:sensor-9). A plan may carry a subject_namespaces allowlist; anything outside it is 400 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_requests tokens spread evenly over window_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 cost override, defaulting to 1.
  • Enforcement decides the status of a denial. soft answers 200 with allowed: false so you can branch; hard answers 429 with retry-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 -f

Plan 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 20

Over 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/check
HTTP/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-calls

stats 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 actions carry a cost.
  • reset_after_seconds means 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. peek always reports time-to-full.
  • Raising a plan's capacity does not top up existing buckets. After max_requests went 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. reset grants the new headroom immediately.
  • A hard 429 body is not enveloped. Denials under hard enforcement return the bare check object - {"allowed": false, ...} with no status/msg/data wrapper. Read allowed off the top level on a 429.
  • --repeat spends your own platform budget. It issues that many separate HTTP requests, so a large repeat count stops on a platform 429 before it drains the plan you are testing.
  • Costs above 10000 are 400 invalid_cost; subjects containing whitespace or any of * ? [ ] are 400 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

On this page