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.

One declaration, three consumers The x-rate-limit extension in the specification is read by the limiter configuration generator, the documentation renderer and the contract test suite, so all three agree by construction. x-rate-limit in openapi.yaml limiter configuration generated, not hand-typed rendered documentation numbers injected at build contract tests assert the runtime matches no drift possible one number, three uses

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.

What documenting the limit unlocks Without documentation the limit is invisible to the docs site, the mock server, the generated SDK and contract tests. With documentation all four surfaces expose it. Limit only in gateway config Limit declared in the document docs site — no mention of limits mock server — never returns 429 SDK — untyped error, no allowance tests — nothing to assert docs site — quota rendered per operation mock server — can serve a 429 example SDK — typed error + allowance headers tests — assert runtime matches the spec

Documenting the limit is what lets every other tool in the chain behave correctly:

From one declaration to four correct behaviours A single machine-readable limit declaration drives the gateway configuration, the rendered reference, the mock server's 429 example and the contract test that asserts the runtime matches. one declaration, four consumers that cannot disagree x-rate-limit policy, quota, window in the operation gateway config generated, not typed diff is reviewable docs + mock quota rendered, 429example clients meet it indevelopment contract test asserts runtime headers drift fails CI a limit that lives only in gateway configuration is invisible to all four

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.