Skip to content

Set rate limit quotas without locking yourself out of removing them - #88

Merged
sethbergman merged 2 commits into
mainfrom
feat/rate-limit-quotas
Sep 8, 2026
Merged

sethbergman merged 2 commits into
mainfrom
feat/rate-limit-quotas

Conversation

@sethbergman

Copy link
Copy Markdown
Owner

The last item on the after-v1.0 list reachable without a cloud account. scripts/bootstrap-quotas.sh configures quotas; tests/quotas exercises the result against a real cluster — 21 assertions.

The feature is two vault write calls. The script exists because two of the three obvious ways to make those calls look like they worked and did not, and because one of them takes away your ability to undo it.

sys/quotas/config replaces, it does not merge

Vault ships seven exempt paths — sys/health, sys/seal-status and sys/unseal among them. Writing a single unrelated field empties the list:

$ vault read -format=json sys/quotas/config | jq '.data.rate_limit_exempt_paths | length'
7
$ vault write sys/quotas/config enable_rate_limit_audit_logging=true
$ vault read -format=json sys/quotas/config | jq '.data.rate_limit_exempt_paths | length'
0

Nothing warns you. The next global quota then applies to your load balancer's health checks and to sys/unseal, and you find out at the next restart.

The list is a list

rate_limit_exempt_paths takes an array, and k=v does not produce one:

$ vault write sys/quotas/config rate_limit_exempt_paths="sys/health,sys/seal-status"
$ vault read -format=json sys/quotas/config | jq -c '.data.rate_limit_exempt_paths'
["sys/health,sys/seal-status"]

One element containing a comma, matching no path that exists. It writes successfully and protects nothing. The script sends JSON, then reads the list back and counts it — the broken form and the working one differ only in the length of an array nobody looks at.

A quota rate limits the endpoint that removes it

sys/quotas/* isn't exempt by default, so a quota set too low answers 429 to the DELETE that would remove it. Standbys don't help — they forward to the leader, which is where the limiter is.

The script always exempts sys/quotas/config and sys/quotas/rate-limit, turning that outage into an inconvenience. And if it's already too late: send nothing for one full interval, then spend the first request of the new window on the delete. It returns 204. Any request before it — including the read you'd naturally run first to see what's happening — consumes the budget and you wait again.

The suite creates that lockout deliberately, then demonstrates the recovery.

429 does not identify a quota

A healthy standby answers 429 on sys/health — which is why terraform/aws matches 200,429 on the target group. A tripped quota also answers 429, so a node refusing traffic stays in the pool, healthy by every signal the infrastructure collects. That's the quorum-less node answering 200 to standbyok=true, arriving from the other direction.

Response headers are on by default because that's what separates them:

Standby 429 Quota 429
headers off none none
headers on none retry-after, x-ratelimit-*

Testing

Four assertions are about Vault rather than the script, because the script is shaped around those behaviours — if any stops being true, the suite should say so rather than the script quietly guarding nothing.

Three mutations, all watched to fail:

# Mutation Caught by
Q1 Exempt paths written comma-joined the exempt list has nine or more entries (8 in total)
Q2 sys/quotas left unexempted the quota config is still readable; the quota can be deleted while tripped (5)
Q3 Response headers left off a quota's 429 carries x-ratelimit-limit (2)

Q1 is the one worth reading: the mutation isn't a mistake, it's the form the CLI leads you to, and it writes successfully.

Auto-classified slow by the #84 derivation, no Makefile edit. 28 suites, 38 jobs, all wired.

Not covered

Lease count, role- and namespace-scoped quotas are Enterprise. How the limit behaves across three nodes isn't established — what was measured is that a standby returns 429 for a request whose budget the leader had already spent.

🤖 Generated with Claude Code

sethbergman and others added 2 commits September 8, 2026 17:35
Rate limit quotas were the last item on the after-v1.0 list reachable
without a cloud account. scripts/bootstrap-quotas.sh configures them and
tests/quotas exercises the result against a real cluster.

The feature is two `vault write` calls. The script exists because two of
the three obvious ways to make those calls look like they worked and did
not, and because one of them takes away your ability to undo it.

sys/quotas/config REPLACES, IT DOES NOT MERGE

Vault ships seven exempt paths, sys/health, sys/seal-status and
sys/unseal among them. Writing a single unrelated field to that endpoint
empties the list. Nothing warns you, and the next global quota then
applies to your load balancer's health checks and to unseal. So anything
that writes that endpoint has to write all of it, defaults included,
every time.

THE LIST IS A LIST

rate_limit_exempt_paths takes an array, and the CLI's k=v form does not
produce one:

    rate_limit_exempt_paths="sys/health,sys/seal-status"
      -> ["sys/health,sys/seal-status"]

One element, containing a comma, matching no path that exists. It writes
successfully and protects nothing. The script sends JSON on stdin and
then reads the list back and counts it, because the broken form and the
working form differ only in the length of an array nobody looks at.

A QUOTA RATE LIMITS THE ENDPOINT THAT REMOVES IT

sys/quotas/* is not exempt by default, so a global quota set too low
answers 429 to the DELETE that would remove it. Standbys do not help;
they forward to the leader, which is where the limiter is. The script
always exempts sys/quotas/config and sys/quotas/rate-limit, which turns
that outage into an inconvenience.

The way out, if it is already too late: send nothing for one full
interval, then spend the first request of the new window on the delete.
It returns 204. Any request before it — including the read you would
naturally run first to see what is happening — consumes the budget, and
you wait again. The suite creates that lockout deliberately and then
demonstrates the recovery.

429 DOES NOT IDENTIFY A QUOTA

A healthy Vault standby answers 429 on sys/health, which is why
terraform/aws matches 200,429 on the target group. A tripped quota also
answers 429, so a node refusing traffic stays in the pool, healthy by
every signal the infrastructure collects. This is the quorum-less node
answering 200 to standbyok=true, arriving from the other direction.

Response headers are on by default because that is what separates them:
the quota's 429 carries retry-after and x-ratelimit-*, and the standby's
carries neither. --no-headers exists and says what it costs.

Four of the suite's assertions are about Vault rather than the script,
for that reason: the script is shaped around those behaviours, and if any
stops being true the suite should say so rather than the script quietly
guarding nothing.

21 assertions. Three mutations, all watched to fail; the table is in the
suite's README. Q1 is the one worth reading — the mutation is not a
mistake, it is the form the CLI leads you to, and it writes successfully.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Markdown lint failed on the quotas change. MD013 applies to fenced code
here — .markdownlint.yaml exempts tables and nothing else — and four
`vault read`/`vault write` examples ran to 83-89 columns.

Wrapped with backslash continuations, which is what the reader would
type anyway.

My own pre-push check missed them because it skipped fenced blocks, an
exemption the config does not grant. Corrected, and run across every
markdown file in the repository rather than the ones I remembered
touching.

The nine remaining long lines it reports elsewhere are pre-existing and
pass CI, because MD013 does not flag a line with no whitespace past the
limit — an unbreakable path or URL is allowed. Those are left alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@sethbergman
sethbergman merged commit db33cdf into main Sep 8, 2026
38 checks passed
@sethbergman
sethbergman deleted the feat/rate-limit-quotas branch September 11, 2026 17:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant