Rate limits
read as.mdGGUI rate limiting is operator-configured: a RateLimiter seam the deployment wires (or doesn’t). This page covers the self-hosted defaults, the two enforcement layers and their wire shapes, and how to layer retry on top of whichever MCP host SDK you’re using.
Self-hosted defaults
Section titled “Self-hosted defaults”- Default (strict)
ggui servewires no generation limiter —ggui_renderis unlimited for paired callers. ggui serve --public-demobinds a per-remote-IP fixed-window limiter toggui_render: 30 generations / 10 minutes / IP (operator-pays posture for public demos).- Library users wire their own
RateLimiterinto the render handler deps — the seam (RateLimiter,RateLimitDecision) lives in@ggui-ai/mcp-server-core. A denial is never thrown: the handler projects it as a refusal described below —app_rate_limitedfor the app’s own cap, orissuer_rate_limitedwhen the limiter reports (RateLimitDecision.scope: 'issuer') that the cap on the issuing identity behind the credential bound.
Two enforcement layers
Section titled “Two enforcement layers”Tool-level: ggui_render refuses in-band
Section titled “Tool-level: ggui_render refuses in-band”When a rate limiter is wired into the render handler, a limited ggui_render call is refused — not a thrown tool error, not an HTTP 429. The tool result is a render-gate refusal like any other — isError: true, structuredContent.outcome: 'refused', refusal.code: 'app_rate_limited' (or issuer_rate_limited when the issuing identity’s cap bound), retry: 'later' — with the wait in milliseconds carried by the refusal’s message and fix. The handshake stays intact, so the retry is the same call with the same handshakeId after the stated wait. Handle it in your agent loop the way you handle every render-gate refusal: read refusal.retry, wait, re-call.
HTTP-level: 429 on auth/pairing endpoints
Section titled “HTTP-level: 429 on auth/pairing endpoints”The pairing/login routes enforce limits at the HTTP transport layer. Every limited request returns:
| Field | Value |
|---|---|
| HTTP status | 429 |
Retry-After header |
Seconds before the next attempt is permitted. Optional — absent means use exponential backoff. |
| Body | JSON { "error": { "code": "rate_limited", "message": "...", "retryAfter": <seconds> } }. |
Retry-After is the authoritative signal. When present, honor it verbatim — the server has already computed the appropriate wait. The retryAfter field in the body mirrors the header for convenience when only the body is observable (e.g. some transport wrappers).
Retry is the host SDK’s job
Section titled “Retry is the host SDK’s job”GGUI has no first-party client SDK to wrap retries — your MCP host owns that loop. The pattern is the same regardless of host: catch the 429, read Retry-After, sleep, retry, cap attempts. For ggui_render, additionally check the tool result for a refusal — isError: true with structuredContent.outcome: 'refused' and a refusal.code of 'app_rate_limited' or 'issuer_rate_limited' — and back off for the wait its message states.
Claude Agent SDK
Section titled “Claude Agent SDK”The Claude Agent SDK’s query() already retries transient transport errors (including 429) using the standard Anthropic SDK retry config. You generally don’t need to do anything — bursts within the retry window never surface to your code. To tune, pass maxRetries through the SDK’s options. See Examples → Claude Agent for a runnable scaffold.
@modelcontextprotocol/sdk (generic MCP)
Section titled “@modelcontextprotocol/sdk (generic MCP)”The official MCP SDK throws on HTTP errors without retrying. Wrap callTool (or whichever method you invoke) yourself:
import { Client } from "@modelcontextprotocol/sdk/client/index.js";import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
const client = new Client({ name: "my-agent", version: "1.0.0" });await client.connect( new StreamableHTTPClientTransport(new URL("http://127.0.0.1:6781/mcp"), { requestInit: { headers: { Authorization: "Bearer dev" } }, }));
async function callWithRetry<T>( fn: () => Promise<T>, { maxRetries = 3, baseDelayMs = 1000, maxDelayMs = 30000 } = {}): Promise<T> { for (let attempt = 0; attempt <= maxRetries; attempt++) { try { return await fn(); } catch (err) { // The MCP SDK surfaces HTTP errors with status + headers attached. const status = (err as { status?: number }).status; if (status !== 429 || attempt === maxRetries) throw err;
const retryAfter = Number((err as { headers?: Record<string, string> }).headers?.["retry-after"]) || undefined; const waitMs = retryAfter != null ? retryAfter * 1000 : Math.min(baseDelayMs * 2 ** attempt, maxDelayMs);
await new Promise((r) => setTimeout(r, waitMs)); } } throw new Error("unreachable");}
const result = await callWithRetry(() => client.callTool({ name: "ggui_handshake", arguments: {/* ... */}, }));Tune maxRetries per workload: lower on interactive (user-blocking) paths so failures bubble up fast; raise on background batch paths where backoff is cheaper than re-queuing. Note that a rate-limited ggui_render on a --public-demo server does NOT surface as an HTTP error — it resolves as a refusal (isError: true, structuredContent.outcome: 'refused', refusal.code: 'app_rate_limited'); check the result before treating the call as a success.
Raw HTTP
Section titled “Raw HTTP”If you’re hitting the server directly without an MCP SDK, implement the same loop against fetch:
- Read the
Retry-Afterheader on every 429 — a hosted 429 is the ingress backstop and carries no body. For aggui_renderrefusal the wait is inside the refusal itself (message/fix, in milliseconds). - If present, sleep that many seconds, then retry.
- If absent, sleep
min(baseDelay * 2^attempt, maxDelay), then retry. - Cap retries (3–5 is reasonable for interactive workloads, more for batch).
- Stop retrying on non-429 4xx (those won’t resolve with backoff).
- Treat a
503whose JSON-RPC body carries-32014as the saturated generation queue — hosted GGUI always sendsRetry-Afteron it; wait exactly that long. The server, not you, is the limit.
The generic MCP example walks through raw-HTTP usage end-to-end.
See also
Section titled “See also”- Examples → Claude Agent — runnable scaffold with the host SDK’s native retry.
- Cookbook → Error handling — retry, surfacing, and dead-letter patterns.
- Troubleshooting — common error patterns.
- MCP Protocol — full JSON-RPC method reference.