Skip to content

Commit 99a90aa

Browse files
committed
Document operational responses in OpenAPI
1 parent f6cd6e1 commit 99a90aa

7 files changed

Lines changed: 268 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,12 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
4040
- `max_request_size` now defaults to 100 MiB instead of unlimited; pass
4141
`API(max_request_size=None)` for the previous behavior. Oversized bodies
4242
get a `413` as before.
43+
- Generated OpenAPI operations now document Responder's operational responses:
44+
CSRF-protected unsafe routes get `403`, rate-limited routes get `429` (and
45+
fail-closed backend `503`), and request-size `413` is emitted only when a
46+
body cap is active. Rate limiter `429`/`503` responses now honor
47+
`API(problem_details=True)` and keep the legacy `{"error": ...}` JSON shape
48+
when `problem_details=False`.
4349
- Because multipart parsing streams, the raw body is consumed by the parse:
4450
`await req.content` after `media("form"/"files")` raises a clear
4551
`RuntimeError` (await `req.content` first to keep the buffered, replayable

docs/source/tour.rst

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -859,6 +859,12 @@ endpoints are excluded for you::
859859
def internal(req, resp):
860860
resp.text = "private"
861861

862+
Operational responses are documented too. CSRF-protected unsafe routes show
863+
``403``, capped request bodies show ``413``, rate-limited routes show
864+
``429`` (and fail-closed backend ``503``), and configured request timeouts
865+
show ``504``. With the default Problem Details contract, those responses
866+
reference the reusable ``ProblemDetails`` schema.
867+
862868
Beyond that baseline, three tools let you enrich and override the generated
863869
operations.
864870

@@ -1419,6 +1425,10 @@ When the limit is exceeded, clients receive a ``429 Too Many Requests``
14191425
response with a ``Retry-After`` header. Every response includes
14201426
``X-RateLimit-Limit``, ``X-RateLimit-Remaining``, and ``X-RateLimit-Reset``
14211427
(seconds until the window resets) headers so clients can pace themselves.
1428+
With the default ``problem_details=True`` setting, over-limit and fail-closed
1429+
backend responses use the same ``application/problem+json`` envelope as
1430+
framework-generated errors; pass ``problem_details=False`` on the API to keep
1431+
the legacy ``{"error": ...}`` JSON body.
14221432

14231433
The rate limiter is per-client, keyed by IP address by default. To key by
14241434
something else — an API key, an authenticated user id — pass ``key=``, a

responder/ext/openapi/__init__.py

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,10 @@
2525
"400": "Bad Request",
2626
"404": "Not Found",
2727
"405": "Method Not Allowed",
28-
"413": "Content Too Large",
2928
"500": "Internal Server Error",
3029
}
3130
_AUTH_INJECTION_NAMES = frozenset({"auth", "principal", "user"})
31+
_CSRF_SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS", "TRACE"})
3232

3333

3434
def _problem_details_schema() -> dict:
@@ -560,21 +560,47 @@ def _apply_problem_responses(
560560
*,
561561
has_validation: bool,
562562
secured: bool,
563+
csrf_protected: bool,
564+
body_limited: bool,
565+
rate_limited: bool,
563566
timed: bool,
564567
problem_details: bool,
565568
) -> None:
566569
response = _problem_response if problem_details else _legacy_error_response
567570
for status, description in _COMMON_PROBLEM_STATUSES.items():
568571
op["responses"].setdefault(status, response(description))
572+
if body_limited:
573+
op["responses"].setdefault("413", response("Content Too Large"))
569574
if has_validation:
570575
op["responses"]["422"] = response("Validation Error", validation=True)
571576
if secured:
572577
op["responses"].setdefault("401", response("Not Authenticated"))
573578
op["responses"].setdefault("403", response("Forbidden"))
579+
elif csrf_protected:
580+
op["responses"].setdefault("403", response("Forbidden"))
581+
if rate_limited:
582+
op["responses"].setdefault("429", response("Too Many Requests"))
583+
op["responses"].setdefault("503", response("Service Unavailable"))
574584
if timed:
575585
op["responses"].setdefault("504", response("Gateway Timeout"))
576586

577587

588+
def _csrf_protected(app: Any, endpoint: Any, op_endpoint: Any, method: str) -> bool:
589+
if method.upper() in _CSRF_SAFE_METHODS:
590+
return False
591+
route_csrf = _operation_attr(endpoint, op_endpoint, "_csrf")
592+
if route_csrf is None:
593+
return bool(getattr(getattr(app, "router", None), "csrf", False))
594+
return bool(route_csrf)
595+
596+
597+
def _rate_limited(app: Any, endpoint: Any, op_endpoint: Any) -> bool:
598+
return bool(
599+
getattr(app, "_openapi_rate_limited", False)
600+
or _operation_attr(endpoint, op_endpoint, "_rate_limited", False)
601+
)
602+
603+
578604
def _doc_methods(route: Any, has_body: bool = False) -> list[str]:
579605
"""Lowercased HTTP methods to document for a route (no HEAD/OPTIONS)."""
580606
methods = getattr(route, "methods", None)
@@ -867,6 +893,9 @@ def _spec_cache_key(self):
867893
getattr(router, "_generation", None),
868894
len(getattr(router, "routes", ()) or ()),
869895
len(getattr(router, "dependencies", {}) or {}),
896+
getattr(router, "max_request_size", None),
897+
getattr(router, "csrf", False),
898+
getattr(self.app, "_openapi_rate_limited", False),
870899
len(self.schemas),
871900
len(self.pydantic_schemas),
872901
len(self.security_schemes),
@@ -1100,6 +1129,14 @@ def remember_model(model):
11001129
op,
11011130
has_validation=has_body or has_param_validation,
11021131
secured=secured,
1132+
csrf_protected=_csrf_protected(
1133+
self.app, endpoint, op_endpoint, method
1134+
),
1135+
body_limited=getattr(
1136+
self.app.router, "max_request_size", None
1137+
)
1138+
is not None,
1139+
rate_limited=_rate_limited(self.app, endpoint, op_endpoint),
11031140
timed=getattr(self.app.router, "request_timeout", None) is not None,
11041141
problem_details=getattr(self.app, "problem_details", True),
11051142
)

responder/ext/ratelimit.py

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -294,14 +294,30 @@ def _apply(self, allowed, remaining, reset_after, resp):
294294
if reset_after is not None:
295295
resp.headers["X-RateLimit-Reset"] = str(math.ceil(reset_after))
296296
if not allowed:
297-
resp.status_code = 429
298-
resp.media = {"error": "rate limit exceeded"}
299-
resp.headers["Retry-After"] = str(self.period)
297+
headers = {"Retry-After": str(self.period)}
298+
if self._use_problem_details(resp):
299+
resp.problem(
300+
429,
301+
"Rate limit exceeded.",
302+
title="Too Many Requests",
303+
headers=headers,
304+
)
305+
else:
306+
resp.status_code = 429
307+
resp.media = {"error": "rate limit exceeded"}
308+
resp.headers.update(headers)
300309
return False
301310
resp.headers["X-RateLimit-Limit"] = str(self.max_requests)
302311
resp.headers["X-RateLimit-Remaining"] = str(remaining)
303312
return True
304313

314+
@staticmethod
315+
def _use_problem_details(resp):
316+
request = getattr(resp, "req", None)
317+
starlette_req = getattr(request, "_starlette", None)
318+
scope = getattr(starlette_req, "scope", None)
319+
return bool(scope and scope.get("problem_details"))
320+
305321
def _apply_failure(self, exc, resp):
306322
if self.fail_open:
307323
logger.warning(
@@ -319,9 +335,18 @@ def _apply_failure(self, exc, resp):
319335
type(exc).__name__,
320336
exc,
321337
)
322-
resp.status_code = 503
323-
resp.media = {"error": "rate limit backend unavailable"}
324-
resp.headers["Retry-After"] = str(self.period)
338+
headers = {"Retry-After": str(self.period)}
339+
if self._use_problem_details(resp):
340+
resp.problem(
341+
503,
342+
"Rate limit backend unavailable.",
343+
title="Service Unavailable",
344+
headers=headers,
345+
)
346+
else:
347+
resp.status_code = 503
348+
resp.media = {"error": "rate limit backend unavailable"}
349+
resp.headers.update(headers)
325350
return False
326351

327352
def check(self, req, resp):
@@ -380,11 +405,18 @@ def wrapper(req, resp, *args, **kwargs):
380405
return f(req, resp, *args, **kwargs)
381406
return None
382407

408+
wrapper._rate_limited = True # type: ignore[attr-defined]
409+
wrapper._rate_limiter = self # type: ignore[attr-defined]
383410
return wrapper
384411

385412
def install(self, api):
386413
"""Install as a before_request hook on the API (async, any backend)."""
387414

415+
api._openapi_rate_limited = True
416+
api._openapi_rate_limiter = self
417+
if hasattr(api, "openapi"):
418+
api.openapi._spec_cache = None
419+
388420
@api.route(before_request=True)
389421
async def _rate_limit(req, resp):
390422
await self.acheck(req, resp)
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
"""v9.0: generated OpenAPI documents framework operational responses."""
2+
3+
import yaml
4+
from openapi_spec_validator import validate
5+
from starlette.testclient import TestClient
6+
7+
import responder
8+
from responder.ext.ratelimit import RateLimiter, RedisBackend
9+
10+
11+
def _api(**kwargs):
12+
kwargs.setdefault("openapi", "3.1.0")
13+
kwargs.setdefault("secret_key", "x" * 32)
14+
kwargs.setdefault("allowed_hosts", [";"])
15+
kwargs.setdefault("session_https_only", False)
16+
return responder.API(**kwargs)
17+
18+
19+
def _schema(api):
20+
response = TestClient(api, base_url="http://;").get("/schema.yml")
21+
assert response.status_code == 200
22+
return yaml.safe_load(response.content)
23+
24+
25+
def test_openapi_documents_csrf_only_on_protected_unsafe_routes():
26+
api = _api(csrf=True)
27+
28+
@api.get("/form")
29+
def form(req, resp):
30+
resp.text = str(req.csrf_input)
31+
32+
@api.post("/submit")
33+
def submit(req, resp):
34+
resp.media = {"ok": True}
35+
36+
@api.post("/webhook", csrf=False)
37+
def webhook(req, resp):
38+
resp.media = {"ok": True}
39+
40+
spec = _schema(api)
41+
42+
form_responses = spec["paths"]["/form"]["get"]["responses"]
43+
submit_responses = spec["paths"]["/submit"]["post"]["responses"]
44+
webhook_responses = spec["paths"]["/webhook"]["post"]["responses"]
45+
46+
assert "403" not in form_responses
47+
assert "403" in submit_responses
48+
assert "401" not in submit_responses
49+
assert "403" not in webhook_responses
50+
assert "application/problem+json" in submit_responses["403"]["content"]
51+
validate(spec)
52+
53+
54+
def test_openapi_documents_route_level_rate_limits():
55+
api = _api()
56+
limiter = RateLimiter(requests=1, period=60)
57+
58+
@api.get("/limited")
59+
@limiter.limit
60+
def limited(req, resp):
61+
resp.media = {"ok": True}
62+
63+
@api.get("/open")
64+
def open_route(req, resp):
65+
resp.media = {"ok": True}
66+
67+
spec = _schema(api)
68+
limited_responses = spec["paths"]["/limited"]["get"]["responses"]
69+
open_responses = spec["paths"]["/open"]["get"]["responses"]
70+
71+
assert "429" in limited_responses
72+
assert "503" in limited_responses
73+
assert "application/problem+json" in limited_responses["429"]["content"]
74+
assert "429" not in open_responses
75+
assert "503" not in open_responses
76+
validate(spec)
77+
78+
79+
def test_openapi_documents_installed_rate_limiter_on_all_routes():
80+
api = _api()
81+
RateLimiter(requests=1, period=60).install(api)
82+
83+
@api.get("/one")
84+
def one(req, resp):
85+
resp.media = {"ok": True}
86+
87+
@api.post("/two")
88+
def two(req, resp):
89+
resp.media = {"ok": True}
90+
91+
spec = _schema(api)
92+
93+
assert "429" in spec["paths"]["/one"]["get"]["responses"]
94+
assert "429" in spec["paths"]["/two"]["post"]["responses"]
95+
validate(spec)
96+
97+
98+
def test_openapi_omits_413_when_request_body_limit_disabled():
99+
api = _api(max_request_size=None)
100+
101+
@api.post("/items")
102+
def items(req, resp):
103+
resp.media = {"ok": True}
104+
105+
spec = _schema(api)
106+
107+
assert "413" not in spec["paths"]["/items"]["post"]["responses"]
108+
validate(spec)
109+
110+
111+
def test_rate_limiter_uses_problem_details_by_default():
112+
api = _api()
113+
RateLimiter(requests=1, period=60).install(api)
114+
115+
@api.get("/")
116+
def index(req, resp):
117+
resp.text = "ok"
118+
119+
client = TestClient(api, base_url="http://;")
120+
assert client.get("/").status_code == 200
121+
122+
response = client.get("/")
123+
124+
assert response.status_code == 429
125+
assert response.headers["content-type"].startswith("application/problem+json")
126+
assert response.headers["Retry-After"] == "60"
127+
assert response.json()["title"] == "Too Many Requests"
128+
129+
130+
def test_rate_limiter_keeps_legacy_error_shape_when_problem_details_disabled():
131+
api = _api(problem_details=False)
132+
RateLimiter(requests=1, period=60).install(api)
133+
134+
@api.get("/")
135+
def index(req, resp):
136+
resp.text = "ok"
137+
138+
client = TestClient(api, base_url="http://;")
139+
assert client.get("/").status_code == 200
140+
141+
response = client.get("/")
142+
143+
assert response.status_code == 429
144+
assert response.headers["content-type"].startswith("application/json")
145+
assert response.json() == {"error": "rate limit exceeded"}
146+
147+
148+
def test_rate_limiter_backend_failure_uses_problem_details():
149+
class DownRedis:
150+
def eval(self, *args, **kwargs):
151+
raise RuntimeError("down")
152+
153+
api = _api()
154+
RateLimiter(
155+
requests=1, period=60, backend=RedisBackend(client=DownRedis())
156+
).install(api)
157+
158+
@api.get("/")
159+
def index(req, resp):
160+
resp.text = "never"
161+
162+
response = TestClient(api, base_url="http://;").get("/")
163+
164+
assert response.status_code == 503
165+
assert response.headers["content-type"].startswith("application/problem+json")
166+
assert response.headers["Retry-After"] == "60"
167+
assert response.json()["title"] == "Service Unavailable"

tests/test_redis_backends.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -454,7 +454,9 @@ async def index(req, resp):
454454
assert r.status_code == 429
455455
assert r.headers["Retry-After"] == "60"
456456
assert 0 < int(r.headers["X-RateLimit-Reset"]) <= 60
457-
assert r.json() == {"error": "rate limit exceeded"}
457+
body = r.json()
458+
assert body["title"] == "Too Many Requests"
459+
assert body["detail"] == "Rate limit exceeded."
458460

459461

460462
def test_redis_ratelimit_connection_error_propagates():
@@ -481,7 +483,9 @@ async def index(req, resp):
481483
r = api.requests.get("/")
482484
assert r.status_code == 503
483485
assert r.headers["Retry-After"] == "60"
484-
assert r.json() == {"error": "rate limit backend unavailable"}
486+
body = r.json()
487+
assert body["title"] == "Service Unavailable"
488+
assert body["detail"] == "Rate limit backend unavailable."
485489

486490

487491
def test_redis_ratelimit_outage_fail_open_allows_requests():

tests/test_round4.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -332,7 +332,9 @@ def view(req, resp):
332332
assert api.requests.get("/r").status_code == 200
333333
over = api.requests.get("/r")
334334
assert over.status_code == 429
335-
assert over.json() == {"error": "rate limit exceeded"}
335+
body = over.json()
336+
assert body["title"] == "Too Many Requests"
337+
assert body["detail"] == "Rate limit exceeded."
336338
assert over.headers["Retry-After"] == "60"
337339
# The handler is not invoked once the limit is exceeded.
338340
assert calls["n"] == 2

0 commit comments

Comments
 (0)