Documenting Rate Limits in OpenAPI
A limit that is not published cannot be respected: clients discover it by being rejected, support explains it by hand, and SDKs hardcode a guess. Putting the numbers in your API description β in structured fields rather than prose β makes them readable by generators, testable by CI, and consistent with the headers described in rate-limit response headers.
The problem in concrete numbers
An API reference says βrate limits apply; see our fair use policy.β The SDK team guesses 100 per minute and paces against it. The real limit is 60 per minute with a burst of 20. Every SDK user sees intermittent 429s that the SDK reports as network errors, support fields the tickets, and the eventual fix is an SDK release that customers must upgrade to.
The same numbers in the API description would have produced a correct SDK on day one, a reference page that renders the numbers automatically, and a CI check that fails when configuration and documentation disagree.
What to declare
| Element | Where | Why |
|---|---|---|
| Header schemas | components.headers |
Lets tools describe and validate the triplet |
The 429 response |
components.responses |
Shared across every operation that can be limited |
Retry-After on the 429 |
Header on that response | The one field clients must obey |
| Per-plan limits | x-rate-limits extension |
Machine-readable numbers for SDKs and CI |
| Per-operation cost | x-rate-limit-cost on the operation |
Where operations are weighted |
| Window semantics | Description on the extension | βper 60 s fixed window, burst 20β |
| Quota separate from rate | Distinct fields | They fail differently β 429 versus 402 |
Keep the numbers in an extension object rather than embedded in a description string. Extensions survive tooling round-trips, can be validated with a schema, and are what a CI check reads.
# openapi.yaml β headers, the 429, and the numbers themselves.
openapi: 3.1.0
info:
title: Example API
version: "2026-07-26"
x-rate-limits:
description: >-
Limits are enforced per API key. The short window bounds burst load; the
monthly quota bounds total usage and is reported separately.
windowSeconds: 60
plans:
free: { requestsPerWindow: 60, burst: 20, monthlyQuota: 50000 }
pro: { requestsPerWindow: 600, burst: 120, monthlyQuota: 5000000 }
enterprise: { requestsPerWindow: 6000, burst: 600, monthlyQuota: null }
components:
headers:
X-RateLimit-Limit:
description: Requests allowed in the current window for this key.
schema: { type: integer, example: 60 }
X-RateLimit-Remaining:
description: Requests still available in the current window.
schema: { type: integer, example: 41 }
X-RateLimit-Reset:
description: Seconds until the window resets (delta-seconds, not epoch).
schema: { type: integer, example: 23 }
Retry-After:
description: Seconds to wait before retrying. Always at least 1 on a 429.
schema: { type: integer, minimum: 1, example: 23 }
responses:
TooManyRequests:
description: The key exceeded its request rate for the current window.
headers:
Retry-After: { $ref: '#/components/headers/Retry-After' }
X-RateLimit-Limit: { $ref: '#/components/headers/X-RateLimit-Limit' }
X-RateLimit-Remaining:{ $ref: '#/components/headers/X-RateLimit-Remaining' }
X-RateLimit-Reset: { $ref: '#/components/headers/X-RateLimit-Reset' }
content:
application/json:
schema:
type: object
required: [error, retry_after]
properties:
error: { type: string, const: rate_limited }
retry_after: { type: integer, minimum: 1 }
QuotaExceeded:
description: The account's monthly quota is spent. Retrying will not help.
content:
application/json:
schema:
type: object
properties:
error: { type: string, const: quota_exceeded }
reset_at: { type: string, format: date-time }
paths:
/v1/search:
get:
summary: Search resources
x-rate-limit-cost: 5 # weighted: five units per call
responses:
"200":
description: Results
headers:
X-RateLimit-Limit: { $ref: '#/components/headers/X-RateLimit-Limit' }
X-RateLimit-Remaining: { $ref: '#/components/headers/X-RateLimit-Remaining' }
X-RateLimit-Reset: { $ref: '#/components/headers/X-RateLimit-Reset' }
"429": { $ref: '#/components/responses/TooManyRequests' }
"402": { $ref: '#/components/responses/QuotaExceeded' }Step-by-step
// scripts/check-openapi-limits.mjs β the extension must match runtime config.
import { readFileSync } from "node:fs";
import YAML from "yaml";
const spec = YAML.parse(readFileSync("docs/openapi.yaml", "utf8"));
const plans = YAML.parse(readFileSync("config/plans.yaml", "utf8")).plans;
const declared = spec["x-rate-limits"];
const failures = [];
if (!declared) failures.push("openapi.yaml has no x-rate-limits block");
for (const [name, plan] of Object.entries(plans)) {
const doc = declared?.plans?.[name];
if (!doc) { failures.push(`${name}: declared in config but missing from the API description`); continue; }
const expectedPerWindow = plan.perSecond * declared.windowSeconds;
if (doc.requestsPerWindow !== expectedPerWindow) {
failures.push(`${name}: docs ${doc.requestsPerWindow}/window != config ${expectedPerWindow}/window`);
}
if (doc.burst !== plan.burst) failures.push(`${name}: docs burst ${doc.burst} != config ${plan.burst}`);
if (doc.monthlyQuota !== (plan.monthlyQuota ?? null)) {
failures.push(`${name}: docs quota ${doc.monthlyQuota} != config ${plan.monthlyQuota ?? null}`);
}
}
// Every operation that can be limited must document the 429.
for (const [path, item] of Object.entries(spec.paths ?? {})) {
for (const [method, op] of Object.entries(item)) {
if (!["get", "post", "put", "patch", "delete"].includes(method)) continue;
if (!op.responses?.["429"]) failures.push(`${method.toUpperCase()} ${path}: no 429 response documented`);
}
}
if (failures.length) { console.error("openapi rate-limit drift:\n " + failures.join("\n ")); process.exit(1); }
console.log(`rate limits documented and consistent for ${Object.keys(plans).length} plans`);Gotchas and edge cases
- Documenting the rate but not the burst. A client that knows only the sustained rate cannot pace a page load correctly. Publish both.
- Omitting the window. β600 requestsβ is meaningless without the period, and readers will assume the most generous interpretation.
- Describing a quota as a rate limit. They fail differently and need different client behaviour: one is retryable, the other is not. Document them as separate responses.
- Custom headers left undeclared. If you emit
X-Quota-Remaining, declare it; undocumented headers get stripped by proxies and ignored by generators. - Undocumented
429on some operations. A generator will not produce a rate-limit code path for an operation whose description lacks it, so the SDK crashes on a status it never expected. - Per-operation cost hidden in prose. If a search call costs five units, put the number in the operation, or clients will budget as if it costs one.
- Documentation released independently of the API. A limit change and its documentation must ship together; otherwise the reference is wrong for exactly as long as the deploy gap.
Verification and testing
# 1. Validate the description and confirm the limit block survives a round trip.
npx @redocly/cli lint docs/openapi.yaml
node -e "const y=require('yaml');const s=y.parse(require('fs').readFileSync('docs/openapi.yaml','utf8'));
if(!s['x-rate-limits']) { console.error('missing x-rate-limits'); process.exit(1) }
console.log('plans documented:', Object.keys(s['x-rate-limits'].plans).join(', '))"
# 2. Fail CI when documentation and configuration disagree.
node scripts/check-openapi-limits.mjs
# 3. Confirm the live API matches what is documented.
LIMIT=$(node -e "console.log(require('yaml').parse(require('fs').readFileSync('docs/openapi.yaml','utf8'))['x-rate-limits'].plans.free.requestsPerWindow)")
ACTUAL=$(curl -sD- -o /dev/null -H "X-API-Key: $FREE_KEY" https://api.example.com/v1/search |
awk 'tolower($1)=="x-ratelimit-limit:"{print $2}' | tr -d '\r')
[ "$LIMIT" = "$ACTUAL" ] && echo "documented $LIMIT matches live $ACTUAL" \
|| { echo "MISMATCH: documented $LIMIT, live $ACTUAL"; exit 1; }Check three closes the loop that the other two cannot: configuration and documentation can agree with each other and both be wrong about what the deployed service does.
Frequently Asked Questions
Is there a standard field for rate limits in OpenAPI?
Not for the numbers themselves. Headers and the 429 response are ordinary components, but the limits are conventionally expressed in a vendor extension such as x-rate-limits. Pick a shape, document it, and validate it with a schema so tooling and CI can rely on it.
Should every operation declare a 429 response?
Every operation that can be limited, which in practice is all of them. Reference a shared response component so it is one line per operation, and add a CI assertion that no operation is missing it β generators build their retry paths from that declaration.
How should weighted operations be documented?
With a per-operation cost field alongside the plan budgets, so a client can compute how many calls of each kind fit in a window. Without it, a client budgeting by request count will exhaust its allowance five times faster than expected on an expensive endpoint.
Should the documented limit be the enforced number or a safe margin below it?
Document the enforced number exactly. Margins belong on the client side, where an SDK paces at about ninety per cent of the published rate. Publishing a lower number than you enforce wastes capacity your customers paid for and makes your own drift checks meaningless.
Related
- Rate-Limit Response Headers β the headers this description declares.
- Verifying Rate Limits in CI Pipelines β where the drift check runs.
- RateLimit Draft vs X-RateLimit β which header family to declare.
- SDK & Client-Side Throttling β the client that consumes these published numbers.