feat(gate): rich, actionable message when the account quota hits 429#60
Merged
Conversation
Before this, hitting the hosted backend's quota cap produced:
refuse: server: rate limited — rate limited, allowing install (upgrade plan to raise the limit)
…which is technically right but tells the user nothing about which
account is capped, how many days until the period resets, or where to
upgrade. The server already returns all of that on the 429 body:
{ "error": "Quota exceeded",
"quota": { "used": 100000, "limit": 100000, "plan": "free",
"period": "2026-05-28", "period_end": "2026-06-27T..." },
"upgrade": "https://refuse.dev/pricing" }
We were dropping it on the floor.
Changes:
1. Parse the body in `server.post`. New `RateLimitedError` carries
used / limit / plan / period / period_end / upgrade URL. Wraps
ErrRateLimited via Unwrap() so existing `errors.Is(err, ErrRateLimited)`
checks keep working. Falls back to plain ErrRateLimited on empty,
malformed, or non-quota 429 bodies (upstream proxy 429s, older
self-hosted server responses).
2. In `gate.failOpenOrClosed`, when the error is a *RateLimitedError,
render a multi-line message:
refuse: rate limited — account quota 100,000/100,000 used (resets Jun 27, 16 days)
upgrade: https://refuse.dev/pricing
install allowed — set REFUSE_FAIL_CLOSED=1 to block on rate limit
Fail-closed mode says "install blocked (REFUSE_FAIL_CLOSED=1)"
instead of "install allowed — set REFUSE_FAIL_CLOSED=1 …" so the
knob is always visible and matches the actual behavior.
Avoid the "fail-open" / "fail-closed" jargon in the user-facing
text — accurate for engineers, opaque for a dev seeing it during
an `npm install`.
3. Tests:
- server: parse-full-payload, unwrap-to-sentinel, reject non-quota
bodies (empty / invalid / no quota field / zero limit), Error()
string contract.
- gate: rate-limit message under fail-open + fail-closed, fallback
to plain "rate limited" when the 429 has no body, and a guard
that the rendered message contains neither "fail-open" nor
"fail-closed".
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Before this, hitting the hosted backend's quota cap produced:
```
refuse: server: rate limited — rate limited, allowing install (upgrade plan to raise the limit)
```
Technically right; tells the user nothing about which account is capped, how many days until reset, or where to upgrade. mcp.refuse.dev was returning all of it on the 429 body — we were dropping it on the floor.
What
`server/client.go` — parse the 429 body. New `RateLimitedError` carries used / limit / plan / period / period_end / upgrade. Wraps `ErrRateLimited` via `Unwrap()` so existing `errors.Is(err, server.ErrRateLimited)` keeps working. Falls back to plain `ErrRateLimited` on empty, malformed, or non-quota 429 bodies (upstream proxy 429s, older self-hosted servers).
`gate/decide.go` — render a multi-line message when we have structured data:
```
refuse: rate limited — account quota 100,000/100,000 used (resets Jun 27, 16 days)
upgrade: https://refuse.dev/pricing
install allowed — set REFUSE_FAIL_CLOSED=1 to block on rate limit
```
Fail-closed swaps the last line for `install blocked (REFUSE_FAIL_CLOSED=1)` so the knob is always visible and matches the actual behavior. Drops the "fail-open" / "fail-closed" jargon from the user-facing text.
Tests