Rate limiting
Any route with Laravel’s throttle middleware is documented with a 429 Too Many Requests response
and the accompanying Retry-After and X-RateLimit-* headers. This is on by default — no package or
configuration required.
What it documents
Section titled “What it documents”Every throttle form Laravel accepts is recognized, and every one of them produces the same 429:
| Middleware | Recognized |
|---|---|
throttle:60,1, throttle:60, throttle:60,0.5 |
✓ |
throttle (bare, no arguments) |
✓ |
throttle:10|60 (guest|authenticated) |
✓ |
throttle:api (a named limiter) |
✓ — and its RateLimiter::for registration is checked, see Named limiters |
ThrottleRequests::using('api') / ::with(...), including the FQCN strings they render to |
✓ |
ThrottleRequestsWithRedis (the Redis variant) |
✓ |
When a route stacks several throttle middlewares, a single 429 is documented from the first, with
a rate-limit.multiple-throttles info diagnostic noting the others are enforced independently but not
separately represented.
Route::middleware('throttle:60,1')->get('/invoices', [InvoiceController::class, 'index']);"429": { "description": "Too Many Requests — the rate limit for this endpoint has been exceeded.", "headers": { "Retry-After": { "description": "Seconds to wait before making another request.", "required": true, "schema": { "type": "integer" } }, "X-RateLimit-Limit": { "description": "The maximum number of requests permitted in the current window.", "required": true, "schema": { "type": "integer" } }, "X-RateLimit-Remaining": { "description": "The number of requests remaining in the current window.", "required": true, "schema": { "type": "integer" } }, "X-RateLimit-Reset": { "description": "Unix timestamp (seconds, UTC) at which the current rate limit window resets.", "required": true, "schema": { "type": "integer" } } }, "content": { "application/json": { "schema": { "type": "object", "properties": { "message": { "type": "string" } } } } }}Headers describe meaning, not numbers
Section titled “Headers describe meaning, not numbers”Every header is documented by what it means — no example, no allowance baked into the description,
whatever the route throttles at. That’s deliberate:
- A number in the spec goes stale silently. Nothing fails when somebody edits
throttle:60,1tothrottle:120,1and the published spec keeps saying 60. - It duplicates your own prose. Your API’s docs are where a reader looks for “100 requests a minute
on the free plan” — with the tiers and the exceptions that go with it, which a single
examplecan never carry. - It churns the document. Baked-in numbers make a rate-limit tweak rewrite bytes across every
operation that route touches, so
docuccino:diffreports a change on operations whose contract did not move. - The live headers are authoritative anyway. A client reads the real limit, the real remaining count and the real reset time from its very first response, and a generated SDK reads them from the same place.
The payoff is a smaller, calmer document. Because every 429 is byte-identical, all of your throttled
routes share one TooManyRequests component
— baking the numbers in would split it into a separate component per distinct limit, and a generated
client into a type per throttle setting.
If your published spec genuinely needs a documented number, state it yourself with an Overlay — a deliberate claim you maintain and review, rather than a build artifact that drifts.
All four are required
Section titled “All four are required”Laravel’s ThrottleRequests sets Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining and
X-RateLimit-Reset on every 429 it produces — there is no branch in which it sends only some of them
— so all four are published with "required": true. That is what lets a generated client type them
non-optional instead of guarding every read, and it is the strongest true statement the document can
make: publishing them as optional would describe a weaker server than the one you are running.
Every header here is also a claim your test suite can hold the application to.
assertValidResponse() checks the response
headers alongside the body, so a 429 that sends Retry-After: soon fails against the integer the
document publishes, and a 429 that omits Retry-After altogether fails too — in both cases the
failure names the rate-limit integration as the producer that wrote the claim.
If your application replaces the 429 renderer and drops those headers, that test failure is the
point: your responses and your published document disagree, and they disagreed before anything checked.
Either send the headers, or say what you really send —
#[ResponseHeader(name: 'Retry-After', status: 429, required: false)]
on the action states the one member you are correcting and leaves the rest of the header standing, and
an Overlay says it once for a whole
document.
The body follows your error style
Section titled “The body follows your error style”Middleware synthesizes the 429, so no throw announces it — Docuccino asks the
error-response chain about ThrottleRequestsException anyway, and the body
matches the rest of the document:
| The situation | The 429 |
|---|---|
error_responses is default |
Laravel’s { "message": string }, which is what ThrottleRequests returns. |
| Your own handler covers throttling | Whatever that handler builds. |
| Your own handler covers throttling, and its body doesn’t read | No body — the 429 keeps its status, description and rate-limit headers. Publishing Laravel’s shape over a renderer you replaced would name members your server never sends. |
| Nothing in the chain answers | The stock { "message": string }. |
error_responses is none |
No 429 at all. That switch turns off every error the document synthesizes rather than reads, and this is one of them; the rate-limit headers go with it, since a response is the only place a header can be published. |
The body is copied in rather than $ref’d, because the chain’s own shared response carries none of the
X-RateLimit-* headers this 429 needs. The finished response — headers and all — is then
hoisted like any other error response,
which it can be, because every throttled route states it identically.
Named limiters
Section titled “Named limiters”A named limiter (throttle:api) states its rate inside a closure — RateLimiter::for('api', fn () => ...).
Docuccino neither runs that closure nor reads it. It doesn’t need to: the 429 says the same thing
whatever the closure returns, so a limiter that computes a rate per user, branches on the plan, or hands
back Limit::none() documents exactly what Limit::perMinute(60) documents.
What Docuccino does check is that the limiter is there:
Route::middleware('throttle:reports')->get('/reports', ReportController::class);// …and nothing anywhere calls RateLimiter::for('reports', …)That route is broken. Laravel’s named-limiter lookup misses, falls through to reading reports as an
allowance, and casts it to 0 — so every guest request gets a 429, always. Docuccino reports it as a
rate-limit.unregistered-limiter info diagnostic naming the limiter and the route, which turns a
production surprise into a line of build output.
Nothing else about a throttle raises anything. An inline allowance is never mistaken for a limiter
name: throttle:60,1, bare throttle, and the guest|authenticated throttle:10|60 — whose halves may
name an attribute on the user rather than a number — all state their own limit and are left alone.
Configuration
Section titled “Configuration”The integration is always on, and has one switch, in docuccino.yaml:
documents: default: integrations: rate_limit: { enabled: false } # stop documenting 429s in this documentTurning it off for a document emits one integration.disabled info diagnostic per build, so the choice
stays visible in the output rather than looking like a bug. See the
integrations reference.
Two other switches reach the same response.
error_responses => 'none' removes every error the
document synthesizes rather than reads, and the 429 is one of them — so it is the wrong reach if the
429 is the one you wanted to keep. Drop responses one at a time with
#[IgnoreResponse] instead: #[IgnoreResponse(429)]
takes the 429 off one operation, and naming the other statuses leaves the 429 standing.