Compare commits

..

17 Commits

Author SHA1 Message Date
kennethreitz b678542192 Bump version to 3.6.2 and update changelog
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 18:11:52 -04:00
kennethreitz 079e7ae3e5 Simplify status code category check
Replace all([(x >= y), (x < z)]) with chained comparison x <= y < z.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 18:10:09 -04:00
kennethreitz 9d4a9d5083 Fix potential XSS in GraphiQL template
The endpoint URL was inserted into a JS string literal with single
quotes. A crafted endpoint containing a single quote could break out.
Now uses Jinja2's tojson filter for proper escaping.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 18:09:45 -04:00
kennethreitz 79c32fcef4 Remove dead or-empty-string in format detection
The \`or \"\"\` in \`\"yaml\" in self.mimetype or \"\"\` was a no-op that
made the logic harder to read. The \`in\` check on mimetype already
returns a bool — \`or \"\"\` just evaluates to that bool.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 18:09:21 -04:00
kennethreitz 52896c6040 Remove unused Node.js setup from CI workflow
The test workflow installed Node 22 but never used it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 18:08:57 -04:00
kennethreitz def071fc71 Add missing methods to CaseInsensitiveDict
Added __delitem__, pop, and setdefault overrides so all dict operations
go through case-insensitive key normalization.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 18:08:45 -04:00
kennethreitz f194efecae Replace assert with proper exceptions in OpenAPI extension
assert statements are stripped with python -O. Use ValueError for
duplicate schema registration and RuntimeError for missing static
route configuration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 18:08:16 -04:00
kennethreitz 868f84fc8b Remove unused method parameter from load_target()
The parameter was accepted but never used — the function always returns
the entrypoint directly without calling any method on it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 18:07:46 -04:00
kennethreitz ea3b6a6e4a Fix test assertions that could never fail
- test_background_task_exception: `or True` made assert always pass
- test_form_uploads_without_multipart: `or r.status_code < 500` masked
  wrong expected value (dict() of QueryDict returns lists, not scalars)
- test_graphql_text_query: `< 500` replaced with exact `== 200`

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 18:07:11 -04:00
kennethreitz 0b16558b04 Fix Pydantic model validation assuming dict body
Request validation would crash with **body if the body was a list.
Response validation would crash the same way. Now checks isinstance
before unpacking. Also added DELETE to request validation methods.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 18:06:17 -04:00
kennethreitz 2440d4caed Narrow WSGI fallback to only catch call signature TypeErrors
The bare except TypeError when calling mounted apps would silently
swallow any TypeError raised inside an ASGI app and incorrectly
convert it to WSGI mode. Now only falls back when the error is
about call arguments.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 18:05:46 -04:00
kennethreitz e653a9f1fd Fix race condition in rate limiter
Cleanup, length check, and append were separate non-atomic steps.
Under concurrent requests a client could exceed the limit. Added a
threading lock around the critical section.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 18:05:18 -04:00
kennethreitz c1fe6e11bd Fix memory leak in rate limiter
Empty bucket keys were never removed after their timestamps expired.
Over time this accumulated an entry for every unique client IP that
ever made a request.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 18:04:33 -04:00
kennethreitz 06b7bae7c0 Fix blocking file I/O in Response.stream_file()
The async generator was using synchronous open() and read() which
blocks the event loop. Switched to anyio.open_file() for proper
async file I/O.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 18:04:10 -04:00
kennethreitz 2494034111 Fix before_requests default type mismatch in Route and WebSocketRoute
The default value for before_requests was [] (list) but the code calls
.get() on it which is a dict method. Changed default to match the
expected dict structure {"http": [], "ws": []}.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 18:03:45 -04:00
kennethreitz 07cfa66e5c Fix OpenAPI docs UI using hardcoded schema URL
The docs template always fetched from /schema.yml regardless of the
user's openapi_route setting. Now uses self.openapi_route so custom
schema paths (e.g. /openapi.json) work correctly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 18:03:15 -04:00
kennethreitz a5d38cf9c3 Fix GraphQL status code never being applied to response
graphql_response() computed status_code (200 or 400) but only returned
it in an unused tuple instead of setting resp.status_code. All GraphQL
error responses were returning 200.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 18:02:53 -04:00
12 changed files with 95 additions and 40 deletions
-3
View File
@@ -31,9 +31,6 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Install uv
uses: astral-sh/setup-uv@v7
+29
View File
@@ -5,6 +5,34 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and
this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [v3.6.2] - 2026-04-12
### Fixed
- GraphQL error responses now correctly return 400 status instead of always 200
- OpenAPI docs UI now respects custom `openapi_route` instead of hardcoding `/schema.yml`
- `before_requests` default type mismatch that could crash routes called outside the router
- Blocking synchronous file I/O in `Response.stream_file()` — now uses async I/O via anyio
- Memory leak in rate limiter (empty bucket keys never cleaned up)
- Race condition in rate limiter `check()` — added thread-safe locking
- WSGI fallback catching all `TypeError`s instead of just call-signature mismatches
- Pydantic request/response model validation crashing on non-dict bodies
- Test assertions that could never fail (`or True`, `< 500` patterns)
- `CaseInsensitiveDict` missing `__delitem__`, `pop`, and `setdefault` overrides
- `assert` used for input validation in OpenAPI extension (stripped by `python -O`)
- Potential XSS in GraphiQL template endpoint injection
- Dead `or ""` in media format detection logic
### Changed
- `DELETE` requests now participate in Pydantic request body validation
- Simplified status code category check to use chained comparison
### Removed
- Unused `method` parameter from `load_target()`
- Unused Node.js setup step from CI test workflow
## [v3.6.1] - 2026-04-12
### Added
@@ -497,6 +525,7 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
- Conception!
[v3.6.2]: https://github.com/kennethreitz/responder/compare/v3.6.1..v3.6.2
[v3.6.1]: https://github.com/kennethreitz/responder/compare/v3.6.0..v3.6.1
[v3.6.0]: https://github.com/kennethreitz/responder/compare/v3.5.0..v3.6.0
[v3.5.0]: https://github.com/kennethreitz/responder/compare/v3.4.0..v3.5.0
+1 -1
View File
@@ -1 +1 @@
__version__ = "3.6.1"
__version__ = "3.6.2"
+1 -2
View File
@@ -101,8 +101,7 @@ class GraphQLView:
response_data["data"] = result.data
resp.media = response_data
status_code = 200 if not result.errors else 400
return (query, json.dumps(response_data), status_code)
resp.status_code = 200 if not result.errors else 400
async def on_request(self, req, resp):
await self.graphql_response(req, resp)
+1 -1
View File
@@ -25,7 +25,7 @@ GRAPHIQL = """
<script crossorigin src="//cdn.jsdelivr.net/npm/react-dom@{{ REACT_VERSION }}/umd/react-dom.production.min.js"></script>
<script src="//cdn.jsdelivr.net/npm/graphiql@{{ GRAPHIQL_VERSION }}/graphiql.min.js"></script>
<script>
const fetcher = GraphiQL.createFetcher({ url: '{{ endpoint }}' });
const fetcher = GraphiQL.createFetcher({ url: {{ endpoint | tojson }} });
const root = ReactDOM.createRoot(document.getElementById('graphiql'));
root.render(React.createElement(GraphiQL, { fetcher: fetcher }));
</script>
+5 -4
View File
@@ -174,8 +174,8 @@ class OpenAPISchema:
def add_schema(self, name, schema, check_existing=True):
"""Adds a marshmallow or Pydantic schema to the API specification."""
if check_existing:
assert name not in self.schemas
assert name not in self.pydantic_schemas
if name in self.schemas or name in self.pydantic_schemas:
raise ValueError(f"Schema '{name}' is already registered")
if _is_pydantic_model(schema):
self.pydantic_schemas[name] = schema
@@ -216,12 +216,13 @@ class OpenAPISchema:
f"{self.docs_theme}.html",
title=self.title,
version=self.version,
schema_url="/schema.yml",
schema_url=self.openapi_route,
)
def static_url(self, asset):
"""Given a static asset, return its URL path."""
assert self.static_route is not None
if self.static_route is None:
raise RuntimeError("Cannot generate static URL: static_route is disabled")
return f"{self.static_route}/{str(asset)}"
def docs_response(self, req, resp):
+15 -8
View File
@@ -1,5 +1,6 @@
"""Simple in-memory rate limiter for Responder."""
import threading
import time
from collections import defaultdict
@@ -28,6 +29,7 @@ class RateLimiter:
self.max_requests = requests
self.period = period
self._buckets: dict[str, list[float]] = defaultdict(list)
self._lock = threading.Lock()
def _client_key(self, req):
client = req.client
@@ -39,20 +41,25 @@ class RateLimiter:
now = time.time()
cutoff = now - self.period
self._buckets[key] = [t for t in self._buckets[key] if t > cutoff]
if not self._buckets[key]:
del self._buckets[key]
def check(self, req, resp):
"""Check rate limit. Sets 429 status if exceeded."""
key = self._client_key(req)
self._cleanup(key)
if len(self._buckets[key]) >= self.max_requests:
resp.status_code = 429
resp.media = {"error": "rate limit exceeded"}
resp.headers["Retry-After"] = str(self.period)
return False
with self._lock:
self._cleanup(key)
if len(self._buckets[key]) >= self.max_requests:
resp.status_code = 429
resp.media = {"error": "rate limit exceeded"}
resp.headers["Retry-After"] = str(self.period)
return False
self._buckets[key].append(time.time())
remaining = self.max_requests - len(self._buckets[key])
self._buckets[key].append(time.time())
remaining = self.max_requests - len(self._buckets[key])
resp.headers["X-RateLimit-Limit"] = str(self.max_requests)
resp.headers["X-RateLimit-Remaining"] = str(remaining)
return True
+15 -4
View File
@@ -34,12 +34,21 @@ class CaseInsensitiveDict(dict):
def __getitem__(self, key):
return super().__getitem__(key.lower())
def __delitem__(self, key):
super().__delitem__(key.lower())
def __contains__(self, key):
return super().__contains__(key.lower())
def get(self, key, default=None):
return super().get(key.lower(), default)
def pop(self, key, *args):
return super().pop(key.lower(), *args)
def setdefault(self, key, default=None):
return super().setdefault(key.lower(), default)
def update(self, other=None, **kwargs):
if other:
for key, value in other.items():
@@ -299,8 +308,8 @@ class Request:
"""
if format is None:
format = "yaml" if "yaml" in self.mimetype or "" else "json" # noqa: A001
format = "form" if "form" in self.mimetype or "" else format # noqa: A001
format = "yaml" if "yaml" in self.mimetype else "json" # noqa: A001
format = "form" if "form" in self.mimetype else format # noqa: A001
formatter: Callable
if isinstance(format, str):
@@ -464,9 +473,11 @@ class Response:
self.mimetype = guessed or "application/octet-stream"
async def file_generator():
with open(path, "rb") as f:
import anyio
async with await anyio.open_file(path, "rb") as f:
while True:
chunk = f.read(chunk_size)
chunk = await f.read(chunk_size)
if not chunk:
break
yield chunk
+21 -10
View File
@@ -130,7 +130,7 @@ class Route(BaseRoute):
response = Response(req=request, formats=get_formats())
path_params = scope.get("path_params", {})
before_requests = scope.get("before_requests", [])
before_requests = scope.get("before_requests", {"http": [], "ws": []})
for before_request in before_requests.get("http", []):
if inspect.iscoroutinefunction(before_request):
@@ -144,9 +144,11 @@ class Route(BaseRoute):
# Auto-validate request body with Pydantic model
req_model = getattr(self.endpoint, "_request_model", None)
if req_model is not None and request.method in ("post", "put", "patch"):
if req_model is not None and request.method in ("post", "put", "patch", "delete"):
try:
body = await request.media()
if not isinstance(body, dict):
raise TypeError("Request body must be a JSON object")
req_model(**body)
except Exception as exc:
response.status_code = 422
@@ -188,7 +190,7 @@ class Route(BaseRoute):
# Auto-serialize response with Pydantic model
resp_model = getattr(self.endpoint, "_response_model", None)
if resp_model is not None and response.media is not None:
if resp_model is not None and isinstance(response.media, dict):
try:
validated = resp_model(**response.media)
response.media = validated.model_dump()
@@ -266,7 +268,7 @@ class WebSocketRoute(BaseRoute):
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
ws = WebSocket(scope, receive, send)
before_requests = scope.get("before_requests", [])
before_requests = scope.get("before_requests", {"http": [], "ws": []})
for before_request in before_requests.get("ws", []):
await before_request(ws)
@@ -459,13 +461,22 @@ class Router:
if path.startswith(path_prefix):
scope["path"] = path[len(path_prefix) :] or "/"
scope["root_path"] = root_path + path_prefix
try:
await app(scope, receive, send)
return
except TypeError:
from a2wsgi import WSGIMiddleware
app = WSGIMiddleware(app)
if not (inspect.iscoroutinefunction(app) or hasattr(app, "__asgi_app__")):
# Check if it looks like a WSGI app (callable with fewer params)
try:
await app(scope, receive, send)
return
except TypeError as exc:
# Only fall back to WSGI if the error is about call signature
if "argument" not in str(exc) and "positional" not in str(exc):
raise
from a2wsgi import WSGIMiddleware
app = WSGIMiddleware(app)
await app(scope, receive, send)
return
else:
await app(scope, receive, send)
return
+1 -1
View File
@@ -85,7 +85,7 @@ for number in codes:
def _is_category(category, status_code):
return all([(status_code >= category), (status_code < category + 100)])
return category <= status_code < category + 100
def is_100(status_code):
+2 -3
View File
@@ -12,7 +12,7 @@ __all__ = [
logger = logging.getLogger(__name__)
def load_target(target: str, default_property: str = "api", method: str = "run") -> t.Any:
def load_target(target: str, default_property: str = "api") -> t.Any:
"""
Load Python code from a file path or module name.
@@ -24,7 +24,6 @@ def load_target(target: str, default_property: str = "api", method: str = "run")
target: Module address (e.g., 'acme.app:foo'), file path (e.g., '/path/to/acme/app.py'),
or URL.
default_property: Name of the property to load if not specified in target (default: "api")
method: Name of the method to invoke on the API instance (default: "run")
Returns:
The API instance, loaded from the given property.
@@ -32,7 +31,7 @@ def load_target(target: str, default_property: str = "api", method: str = "run")
Raises:
ValueError: If target format is invalid
ImportError: If module cannot be imported
AttributeError: If property or method is not found
AttributeError: If property is not found
Example:
>>> api = load_target("myapp.api:server")
+4 -3
View File
@@ -85,7 +85,7 @@ def test_background_task_exception(capsys):
time.sleep(0.2) # let the done callback fire
captured = capsys.readouterr()
assert "ValueError" in captured.err or True # traceback goes to stderr
assert "ValueError" in captured.err or "ValueError" in captured.out
def test_background_run():
@@ -112,7 +112,8 @@ def test_form_uploads_without_multipart(api):
content="name=hello&value=world",
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
assert r.json() == {"name": "world", "value": "world"} or r.status_code < 500
assert r.status_code == 200
assert r.json() == {"name": ["hello"], "value": ["world"]}
# --- models.py coverage ---
@@ -469,7 +470,7 @@ def test_graphql_text_query(api):
content="{ hello }",
headers={"Content-Type": "text/plain"},
)
assert r.status_code < 500
assert r.status_code == 200
def test_openapi_info_fields():