Skip to content

feat(gate): rich, actionable message when the account quota hits 429#60

Merged
gok03 merged 2 commits into
mainfrom
feat/rate-limit-hint
Jun 11, 2026
Merged

feat(gate): rich, actionable message when the account quota hits 429#60
gok03 merged 2 commits into
mainfrom
feat/rate-limit-hint

Conversation

@gok03

@gok03 gok03 commented Jun 11, 2026

Copy link
Copy Markdown
Member

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

  • `server`: full payload parses; unwraps to `ErrRateLimited`; rejects empty / invalid / no-quota / zero-limit bodies; `Error()` returns `"rate limited"` (no `server: ` prefix because the caller prepends `refuse: `).
  • `gate`: rate-limit message under fail-open + fail-closed, fallback when 429 has no body, guard against "fail-open"/"fail-closed" jargon leaking into the rendered string.

gok03 added 2 commits June 11, 2026 09:44
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".
@gok03 gok03 merged commit 30776bb into main Jun 11, 2026
8 checks passed
@gok03 gok03 deleted the feat/rate-limit-hint branch June 11, 2026 04:20
@gok03 gok03 mentioned this pull request Jun 11, 2026
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