Documenting Rate Limits in OpenAPI
This page sits under Rate Limiting & Quota Contracts in the Error Contracts & Resilience Mapping reference. A limit that exists only in gateway configuration is invisible to every tool a consumer uses: documentation, mock servers, contract tests and generated clients all read the OpenAPI document. This page shows how to declare limits there without duplicating the numbers in three places.
The decision trigger
| Symptom | Cause |
|---|---|
| The published docs do not mention any limits | limiter configured outside the spec |
A mock server never returns 429, so clients never handle it |
no 429 documented |
| Generated client cannot read the remaining allowance | response headers undocumented |
| Docs say 600/min, the gateway allows 300/min | numbers duplicated in prose |
Each operation documents 429 slightly differently |
no shared response component |
| A contract test cannot assert the limit contract | nothing declared to assert against |
Spec snippet: components once, references everywhere
# openapi.yaml (OpenAPI 3.1.0)
openapi: 3.1.0
info:
title: Messaging API
version: "2.3.0"
description: |
## Rate limits
Every endpoint is subject to a per-key quota. Responses carry
`RateLimit-Policy` and `RateLimit`; rejections return `429` with
`Retry-After` and an `application/problem+json` body.
components:
headers:
RateLimitPolicy:
description: Applied policy, e.g. `"per-key";q=600;w=60`.
required: false
schema: { type: string }
example: '"per-key";q=600;w=60'
RateLimitState:
description: Remaining allowance and seconds to reset, e.g. `"per-key";r=417;t=23`.
required: false
schema: { type: string }
example: '"per-key";r=417;t=23'
RetryAfterSeconds:
description: RFC 9110 §10.2.3 delay in seconds before retrying.
required: true
schema: { type: integer, minimum: 0 }
example: 23
responses:
TooManyRequests:
description: The caller exceeded a quota. The request was not applied.
headers:
RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
RateLimit: { $ref: "#/components/headers/RateLimitState" }
Retry-After: { $ref: "#/components/headers/RetryAfterSeconds" }
content:
application/problem+json:
schema: { $ref: "#/components/schemas/RateLimitProblem" }
example:
type: "urn:api:errors:v1:rate-limited"
title: "Rate limit exceeded"
status: 429
policy: per-key
scope: api_key
limit: 600
window_seconds: 60
retry_after_seconds: 23
request_applied: false
paths:
/messages:
post:
operationId: sendMessage
x-rate-limit: # one machine-readable source of truth
policy: per-key
quota: 600
window_seconds: 60
scope: api_key
responses:
"202":
description: Accepted for delivery.
headers:
RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
RateLimit: { $ref: "#/components/headers/RateLimitState" }
"429":
$ref: "#/components/responses/TooManyRequests"
The x-rate-limit extension is the important structural choice. Specification extensions are legal anywhere in an OpenAPI document (§4.9), tools ignore what they do not understand, and a machine-readable declaration can be compared against the limiter’s actual configuration — which a sentence in a description cannot.
Step-by-step
Step 1 — Generate the limiter configuration from the document
# gen_limits.py — emit gateway config from the spec, so the spec cannot lie
import json, sys, yaml
doc = yaml.safe_load(open("openapi.yaml"))
rules = []
for path, item in doc.get("paths", {}).items():
for method, op in item.items():
if method not in {"get", "put", "post", "delete", "patch"}:
continue
limit = op.get("x-rate-limit")
if not limit:
continue
rules.append({
"match": {"path": path, "method": method.upper()},
"policy": limit["policy"],
"quota": limit["quota"],
"window": limit["window_seconds"],
"scope": limit["scope"],
})
json.dump({"rules": rules}, sys.stdout, indent=2)
print(f"\n{len(rules)} rate limit rule(s) generated", file=sys.stderr)
Run this in CI and commit the output alongside the gateway configuration; a diff in the generated file is then a visible, reviewable consequence of a spec change.
Step 2 — Render the numbers into prose from the same source
Never type “600 requests per minute” into a description. Inject it:
// scripts/inject-limit-docs.mjs — rewrite descriptions from x-rate-limit
import fs from "node:fs";
import YAML from "yaml";
const doc = YAML.parse(fs.readFileSync("openapi.yaml", "utf8"));
const MARK = "<!-- rate-limit -->";
for (const [path, item] of Object.entries(doc.paths ?? {})) {
for (const [method, op] of Object.entries(item)) {
const rl = op?.["x-rate-limit"];
if (!rl) continue;
const sentence =
`${MARK}\nRate limited to **${rl.quota} requests per ${rl.window_seconds}s** ` +
`per ${rl.scope.replace("_", " ")} (policy \`${rl.policy}\`).`;
op.description = (op.description ?? "").split(MARK)[0].trimEnd() + "\n\n" + sentence;
}
}
fs.writeFileSync("openapi.yaml", YAML.stringify(doc));
console.log("limit documentation regenerated");
Step 3 — Lint that limited operations declare the response
# .spectral.yaml
rules:
rate-limited-operations-document-429:
description: An operation with x-rate-limit must document the shared 429 response.
severity: error
given: "$.paths[*][?(@['x-rate-limit'])]"
then:
field: "responses.429"
function: truthy
x-rate-limit-shape:
description: x-rate-limit must carry policy, quota, window_seconds and scope.
severity: error
given: "$.paths[*][*].x-rate-limit"
then:
- field: policy
function: truthy
- field: quota
function: truthy
- field: window_seconds
function: truthy
- field: scope
function: truthy
success-responses-advertise-allowance:
description: A rate-limited operation should advertise the allowance on success.
severity: warn
given: "$.paths[*][?(@['x-rate-limit'])].responses['200','201','202'].headers"
then:
field: RateLimit
function: truthy
Step 4 — Test that runtime matches documentation
// documented-limits.test.ts — the spec is a promise; verify it
import { describe, it, expect } from "vitest";
import YAML from "yaml";
import fs from "node:fs";
const doc = YAML.parse(fs.readFileSync("openapi.yaml", "utf8"));
describe("documented rate limits", () => {
const limited = Object.entries(doc.paths).flatMap(([path, item]: any) =>
Object.entries(item)
.filter(([, op]: any) => op?.["x-rate-limit"])
.map(([method, op]: any) => ({ path, method, limit: op["x-rate-limit"] })),
);
for (const { path, method, limit } of limited) {
it(`${method.toUpperCase()} ${path} advertises policy "${limit.policy}"`, async () => {
const res = await call(method, path);
const policy = res.headers.get("RateLimit-Policy") ?? "";
expect(policy).toContain(`"${limit.policy}"`);
expect(policy).toContain(`q=${limit.quota}`);
expect(policy).toContain(`w=${limit.window_seconds}`);
});
}
});
This is the test that catches the failure this whole page exists to prevent: documentation claiming one quota while the limiter enforces another.
Documenting the limit is what lets every other tool in the chain behave correctly:
Standard compliance
| Element | Clause | Note |
|---|---|---|
Response headers object |
OpenAPI 3.1 §4.8.17 | Keyed by field name; Content-Type is ignored here |
Reusable components/headers |
OpenAPI 3.1 §4.8.7 | Referenced with $ref from responses |
Reusable components/responses |
OpenAPI 3.1 §4.8.7 | Whole-response reuse, including headers |
| Specification extensions | OpenAPI 3.1 §4.9 | x- prefixed fields; tools ignore unknown ones |
429 semantics |
RFC 6585 §4 | The status being documented |
Retry-After |
RFC 9110 §10.2.3 | Declared as an integer header |
One trap in the first row: a header named Content-Type inside a response headers map is explicitly ignored by the specification, because the media type is expressed by the content map. Documenting it does nothing.
SDK / codegen downstream effect
// Generated TypeScript, sendMessage()
export interface SendMessageResult {
status: 202;
data: MessageAccepted;
+ rateLimit?: {
+ policy: string; // from the documented RateLimit-Policy header
+ state: string; // from the documented RateLimit header
+ };
}
+ export class TooManyRequestsError extends ApiError<RateLimitProblem> {}
Support for typed response headers varies by generator, but the documentation is a precondition for all of them, and it is unconditionally required for the two tools that benefit most: the mock server, which can now produce a realistic 429 so client error handling is exercised in development, and the documentation site, which stops being wrong. For the generator-specific behaviour, see OpenAPI Generator vs Kiota vs Speakeasy.
Common mistakes
| Mistake | Correct approach |
|---|---|
| Limits documented only in a portal page | Declare them in the document with x-rate-limit |
| Numbers typed into descriptions by hand | Generate the prose from one machine-readable source |
A bespoke 429 per operation |
One components/responses/TooManyRequests, referenced |
Headers documented only on the 429 |
Document them on success responses too |
No example on the 429 response |
Add one so mock servers return something realistic |
Documenting Content-Type in a headers map |
Express media types via content; that key is ignored |
FAQ
Where do the actual limit numbers belong in an OpenAPI document?
In exactly one machine-readable place, with everything else derived from it. A specification extension on the operation — x-rate-limit with policy, quota, window_seconds and scope — is the natural home: it is legal per §4.9, ignored by tools that do not understand it, and readable by the scripts that generate your gateway configuration, your documentation prose and your contract tests. Numbers typed into a description are a copy, and copies drift; within two releases the docs describe a limit nobody enforces.
Do documented response headers appear in generated SDKs?
That varies by generator: some expose a typed headers object on the response, some expose only the raw underlying response, and a few ignore response headers entirely. Document them regardless, because generation is not the only consumer. A documented header shows up in the rendered reference, can be asserted by a contract test, and can be produced by a mock server — so client developers see the allowance while integrating, rather than discovering it in production. If typed access matters to you, make it one of the probes when you choose a generator.
Should every operation document a 429 response?
Every operation that can actually return one. If a global quota covers the whole API, that is all of them, and a shared response component reduces the cost to a single $ref line per operation. The failure to avoid is the opposite one: documenting 429 on operations that are never limited, which teaches client authors to write handling code for a case that cannot occur and obscures which endpoints genuinely need pacing. Drive it from the same x-rate-limit declaration and lint that the two stay consistent.
Related
- Rate Limiting & Quota Contracts — up-link: the section covering limit models, headers and pacing
- Error Contracts & Resilience Mapping — up-link: the parent reference for failure contracts
- Returning 429 with Problem+JSON — the response body this documentation describes
- Writing Custom Spectral Rules — implementing the lint rules above
- Adding Sparse Fieldset Support to OpenAPI Specs — a sibling example of extending a document for a cross-cutting concern