Rate limiting on BetterSuite's GraphQL API is a partially shipped feature. The data model is in place — every API key carries an rateLimitRpm (requests per minute) and rateLimitRpd (requests per day) — but the HTTP layer that would enforce those numbers is not wired up yet. This article documents what's real, what's coming, and how to write a client that will Just Work once the limiter ships.
What's in the schema today
Every entry in apiKeys exposes two nullable limits:
| Field | Type | Meaning |
|---|---|---|
rateLimitRpm | Int (nullable) | Requests-per-minute cap. null = no per-key cap. |
rateLimitRpd | Int (nullable) | Requests-per-day cap. null = no per-key cap. |
You set them at creation time via the CreateApiKeyInput (fields rateLimitRpm and rateLimitRpd). The values stick across key rotation: rotateApiKey copies them from the old key onto the new one.
query MyKeys {
apiKeys(includeRevoked: false) {
id
name
keyPrefix
rateLimitRpm
rateLimitRpd
}
}
Source: backend/crates/tenant/domain/src/api_key/api_key_entity.rs:75-79, website/schema.graphql:3361 (ApiKeyInfo.rateLimitRpm / rateLimitRpd).
What's actually enforced today
A handful of application-level rate limits exist in specific use cases — they're not a global request budget:
| Use case | Limit | Source |
|---|---|---|
| Resending the tenant-registration verification email | One-minute cooldown on resendVerification (TenantError::ResendRateLimitExceeded) | crates/tenant/application/src/registration/resend_verification_usecase.rs:40 |
| Repeated order cancellations by a customer | Tracked via customer_strikes and converted into a soft block, not a 429 | crates/order/domain/src/customer_strikes/customer_strike_kind.rs |
| Outbound email providers | rate_limit_per_minute / rate_limit_per_hour on email_providers (governs the SDK call out to SendGrid / MailerSend, not your inbound traffic) | email_providers table |
None of these emit 429. They surface as a GraphQL error in the response body.
What's not there (yet)
These are deliberate gaps right now — don't write code that relies on them:
- No
X-RateLimit-Limit/X-RateLimit-Remaining/X-RateLimit-Resetresponse headers. The Axum router doesn't insert them. - No
429 Too Many Requestsresponse. A grep ofbackend/crates/monolithforTOO_MANY_REQUESTSreturns nothing. - No
Retry-Afterheader. Same reason. - No shared bucket between mutations, queries, and subscriptions. When enforcement ships it will almost certainly be per-API-key first; the buckets aren't wired.
- No per-operation budgets (e.g. "admin exports are 1/min regardless of overall RPM"). Not in the schema, not in the middleware.
The earlier version of this page advertised specific numbers (default: 600 RPM / 50000 RPD, high: 3000 / 500000, etc.) and a JSON body shape for the 429 error. None of that is in the code today — the numbers were aspirational, not source-of-truth. They've been removed.
What to do as a client today
- Set the fields when you create a key. Open the Owner Dashboard, create an API key, fill in RPM and RPD to whatever you'd want enforced. They persist immediately.
- Build the retry shape into your client anyway. When enforcement ships it will follow the standard pattern — HTTP 429 +
Retry-After. Today, treat any 5xx the same way (exponential backoff, jitter, circuit breaker if you see sustained failures). - Don't depend on a specific number being honored. Even after enforcement ships, your tenant might run with
null(no cap) until you pick one.
A reasonable retry loop for the current state:
async function callGraphQL(query, variables, attempt = 0) {
const res = await fetch("https://api.bettersuite.io/graphql", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Api-Key": process.env.BETTERSUITE_API_KEY!,
},
body: JSON.stringify({ query, variables }),
});
// When 429 enforcement ships, this branch will start firing. Until then
// it's defensive code.
if (res.status === 429 && attempt < 5) {
const retryAfter = Number(res.headers.get("retry-after") ?? "1");
await new Promise(r => setTimeout(r, (retryAfter * 1000) + Math.random() * 500));
return callGraphQL(query, variables, attempt + 1);
}
if (res.status >= 500 && attempt < 3) {
await new Promise(r => setTimeout(r, 2 ** attempt * 1000 + Math.random() * 500));
return callGraphQL(query, variables, attempt + 1);
}
return res.json();
}
Counting requests
A single HTTP POST to /graphql is one request, regardless of how many operations or aliases are bundled into the query body. Batching is the cleanest way to stay under any future limit — it'll cost you one tick of the bucket, not N.
GraphQL subscriptions use a different transport (/ws, graphql-transport-ws) — they're long-lived and won't share the GraphQL POST bucket when enforcement does ship.
Local development
There's nothing to disable locally: the limiter doesn't exist yet on any deployment. If you're running the monolith against a Docker Postgres, your traffic is unmetered. Don't infer prod headroom from local benchmarks regardless — Postgres + Redis under load look very different on a beefy bare-metal host than on a laptop.
What's next
- API Keys — where you set
rateLimitRpm/rateLimitRpdwhen creating a key. - Quickstart — sanity-check that you can hit
/graphqlbefore worrying about limits. - Operator-facing API Keys guide — who in your tenant can manage keys.