Compare commits

..

23 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
kennethreitz 2f5e46e233 Bump version to 3.6.1 and update changelog
Add configurable GZip compression via `gzip` parameter on API().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 17:52:35 -04:00
Andreas Motl c1d789f279 CI: Use uv exclusively (#606)
## About
That's just maintenance. In this case, streamline the CI configuration
for improved ergonomics and future proofing, see GH-605.

## Details
- `activate-environment: true` of newer `astral-sh/setup-uv@v7` is the
killer feature here, which wasn't available before.
- Configuring `cache-dependency-glob: pyproject.toml` isn't needed,
because the recipe already employs an excellent default configuration
that includes this and other project metadata files.
- The adjustments in `test_cli` were needed because of a different PyPy
version installed by `setup-uv`. This will yield a report to Astral
maintainers, unrelated to this patch.
2026-03-25 19:48:54 -04:00
kennethreitz 691f6b4d5c Bump version to 3.6.0 and update changelog
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:24:03 -04:00
kennethreitz 90a082a0ac Add structured logging with per-request context (#604)
## Summary

New `enable_logging=True` parameter on `responder.API()` that provides
structured, request-scoped logging using stdlib `logging` and
`contextvars`.

### What it does

- **`api.log`** — always available on every API instance. Works as a
plain logger by default; gains per-request context enrichment with
`enable_logging=True`
- **Per-request context** — every log message automatically includes
request ID, HTTP method, path, and client IP
- **Access logging** — logs every request with timing: `GET /path → 200
(1.2ms)`
- **Request ID** — generates or forwards `X-Request-ID` headers
(supersedes `request_id=True` when both are set)
- **stdlib logging** — works with any existing handler, formatter, or
log aggregator

### Usage

```python
# api.log always works — no setup required
api = responder.API()
api.log.info("starting up")  # plain logger, no context

# With enable_logging=True, log messages get request context automatically
api = responder.API(enable_logging=True)

@api.route("/")
def index(req, resp):
    api.log.info("handling request")
    # => 2026-03-24 12:00:00 [INFO] responder.app — handling request [GET /] [req:abc123] [client:127.0.0.1]
```

For additional loggers in helper modules:

```python
from responder.ext.logging import get_logger
logger = get_logger("myapp.db")
```

### Architecture

- `responder/ext/logging.py` — self-contained module with:
- `LoggingMiddleware` — pure ASGI middleware that sets contextvars and
logs access
- `RequestContextFilter` — logging filter that injects context into
records
  - `RequestContext` — read-only access to current request metadata
  - `get_logger()` / `setup_logging()` — convenience functions
- `api.log` — always a valid logger; context-aware when
`enable_logging=True`, plain stdlib logger otherwise
- Wired into `API.__init__` via the `enable_logging` parameter

### Files

- `responder/ext/logging.py` — new module
- `responder/api.py` — added `enable_logging` parameter and `api.log`
- `tests/test_logging.py` — 9 tests
- `docs/source/tour.rst` — new Structured Logging section
- `docs/source/index.rst` — added to feature list

## Test plan
- [x] 9 logging tests pass
- [x] Full suite: 208 passed

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:22:57 -04:00
kennethreitz 5ee0de6458 Replace HTTP/2 server push with dependency injection in backlog
HTTP/2 server push was removed from the spec and dropped by browsers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:02:56 -04:00
kennethreitz 1c729c8542 Docs: second improvement pass (#603)
## Summary

- **Auth tutorial**: fix deprecated `datetime.utcnow()` →
`datetime.now(timezone.utc)`, add role-based access control section, add
auth strategy comparison guide
- **WebSocket tutorial**: use `WebSocketDisconnect` instead of bare
`Exception` in chat example, add connection lifecycle section with close
codes, add rejected connection test example
- **SQLAlchemy tutorial**: modernize from `Column()` to
`mapped_column()` / `Mapped[]` (SQLAlchemy 2.0 style with type checker
support)
- **Deployment**: use `uv` in Docker example instead of pip, fix stale
`uv.lock` reference in production checklist
- **Quickstart**: link to all tutorials in "next steps" (was only
linking to 3 of 9)
- **Sandbox**: rewrite with project layout tree, mypy command, and
pattern-matching test examples

## Test plan
- [x] `make html` builds cleanly
- [x] All 199 tests pass

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:00:56 -04:00
19 changed files with 566 additions and 67 deletions
+4 -12
View File
@@ -19,24 +19,16 @@ jobs:
documentation:
name: "Documentation"
runs-on: ubuntu-latest
env:
UV_SYSTEM_PYTHON: true
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
python-version: "3.13"
- name: Set up uv
uses: astral-sh/setup-uv@v5
with:
version: "latest"
activate-environment: true
enable-cache: true
cache-dependency-glob: |
pyproject.toml
python-version: "3.14"
- name: Install package and documentation dependencies
run: uv pip install '.[docs]'
+4 -15
View File
@@ -28,28 +28,17 @@ jobs:
"3.14t",
"pypy3.11",
]
env:
UV_SYSTEM_PYTHON: true
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Set up Python
uses: actions/setup-python@v5
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
python-version: ${{ matrix.python-version }}
- name: Set up uv
uses: astral-sh/setup-uv@v5
with:
version: "latest"
activate-environment: true
enable-cache: true
cache-suffix: ${{ matrix.python-version }}
cache-dependency-glob: |
pyproject.toml
python-version: ${{ matrix.python-version }}
- name: Install package
run: uv pip install '.[develop,test]'
+69
View File
@@ -5,6 +5,72 @@ 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
- Configurable GZip compression via `gzip` parameter on `API()` (defaults to `True`)
## [v3.6.0] - 2026-03-24
### Added
- Built-in structured logging with per-request context (`enable_logging=True`)
- `api.log` — always-available logger, enriched with request context when logging is enabled
- Automatic access logging with timing: `GET /path → 200 (1.2ms)`
- Request ID generation/forwarding via `X-Request-ID` header
- `contextvars`-based request context (ID, method, path, client IP) on every log record
- `responder.ext.logging` module: `get_logger()`, `RequestContext`, `RequestContextFilter`
- CLAUDE.md project guide and `/release` command
- Version number in docs sidebar
### Changed
- Comprehensive documentation improvements across all pages
- Deployment: health checks, Docker Compose, Caddy, Procfile, production checklist
- API reference: usage examples for every class
- Feature tour: Pydantic validation, content negotiation, structured logging sections
- Tutorials: modernized SQLAlchemy to `mapped_column()`, fixed deprecated `datetime.utcnow()`,
WebSocket `WebSocketDisconnect` handling, role-based auth, auth strategy guide
- Testing: rate limiting and WSGI mount examples
- Middleware: pure ASGI middleware example
- Quickstart: links to all tutorials
- Sandbox: full rewrite with project layout
- Docker example uses `uv` instead of pip
- Backlog updated: removed implemented features, replaced HTTP/2 server push with dependency injection
### Removed
- `uv.lock` — this is a library, not an application
## [v3.5.0] - 2026-03-24
### Added
@@ -459,6 +525,9 @@ 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
[v3.4.0]: https://github.com/kennethreitz/responder/compare/v3.3.0..v3.4.0
[v3.3.0]: https://github.com/kennethreitz/responder/compare/v3.2.0..v3.3.0
+1
View File
@@ -73,6 +73,7 @@ One ``pip install``, batteries included:
- Sync and async views — ``async`` is always optional.
- Class-based views with ``on_get``, ``on_post``, ``on_request``.
- Built-in rate limiting with ``X-RateLimit`` headers.
- Structured logging with per-request context.
- Content negotiation: JSON, YAML, and MessagePack.
- A pleasant API with a single import statement.
- OpenAPI schema generation with Swagger UI.
+54
View File
@@ -596,6 +596,60 @@ can pace themselves.
The rate limiter is per-client, keyed by IP address.
Structured Logging
------------------
Production applications need structured, searchable logs. Responder
includes built-in logging that automatically attaches request context
— request ID, HTTP method, path, and client IP — to every log message
emitted during request handling::
api = responder.API(enable_logging=True)
This gives you:
- **Access logging** with timing for every request::
2026-03-24 12:00:00 [INFO] responder.access — GET /users → 200 (1.2ms)
- **A logger on the API instance** — use ``api.log`` anywhere in
your routes. Request context (ID, method, path, client IP) is
attached automatically::
@api.route("/users/{user_id:int}")
def get_user(req, resp, *, user_id):
api.log.info("fetching user %d", user_id)
# => [INFO] responder.app -- fetching user 42 [GET /users/42] [req:a1b2c3] [client:10.0.0.1]
resp.media = {"id": user_id}
- **Request IDs** generated automatically (or forwarded from the
``X-Request-ID`` header) and included in responses.
The logging uses Python's standard ``logging`` module, so it works with
any handler — files, syslog, JSON formatters, Datadog, Sentry, whatever
you already use.
For additional loggers (e.g. in helper modules), use ``get_logger``::
from responder.ext.logging import get_logger
logger = get_logger("myapp.db")
You can also access the current request context directly::
from responder.ext.logging import RequestContext
@api.route("/debug")
def debug(req, resp):
resp.media = {
"request_id": RequestContext.get_request_id(),
"client_ip": RequestContext.get_client_ip(),
}
When ``enable_logging=True`` is set, it supersedes ``request_id=True``
— the logging middleware handles request IDs itself, so you don't get
duplicate headers.
Pydantic Validation
-------------------
+1 -1
View File
@@ -1 +1 @@
__version__ = "3.5.0"
__version__ = "3.6.2"
+23 -2
View File
@@ -31,6 +31,7 @@ class API:
:param templates_dir: The directory to use for templates. Will be created for you if it doesn't already exist.
:param auto_escape: If ``True``, HTML and XML templates will automatically be escaped.
:param enable_hsts: If ``True``, send all responses to HTTPS URLs.
:param gzip: If ``True`` (the default), compress responses with GZip.
:param openapi_theme: OpenAPI documentation theme, must be one of ``elements``, ``rapidoc``, ``redoc``, ``swagger_ui``
""" # noqa: E501
@@ -60,7 +61,9 @@ class API:
allowed_hosts=None,
openapi_theme=DEFAULT_OPENAPI_THEME,
lifespan=None,
gzip=True,
request_id=False,
enable_logging=False,
):
"""Create a new Responder API instance.
@@ -85,7 +88,9 @@ class API:
:param allowed_hosts: List of allowed hostnames (e.g. ``["example.com"]``). Defaults to ``["*"]``.
:param openapi_theme: Documentation UI theme: ``"swagger_ui"``, ``"redoc"``, ``"rapidoc"``, or ``"elements"``.
:param lifespan: An async context manager for startup/shutdown logic.
:param gzip: If ``True`` (the default), compress responses with GZip.
:param request_id: If ``True``, add ``X-Request-ID`` headers to all responses.
:param enable_logging: If ``True``, enable structured logging with per-request context (request ID, method, path, client IP).
""" # noqa: E501
self.background = BackgroundQueue()
@@ -120,7 +125,9 @@ class API:
self.default_endpoint = None
self.app = ExceptionMiddleware(self.router, debug=debug)
self.add_middleware(GZipMiddleware)
if gzip:
self.add_middleware(GZipMiddleware)
if self.hsts_enabled:
self.add_middleware(HTTPSRedirectMiddleware)
@@ -158,7 +165,7 @@ class API:
self.templates = Templates(directory=templates_dir)
if request_id:
if request_id and not enable_logging:
import uuid as _uuid
def _add_request_id(req, resp):
@@ -167,6 +174,20 @@ class API:
self.router.after_request(_add_request_id)
if enable_logging:
import logging as _logging
from .ext.logging import LoggingMiddleware, get_logger, setup_logging
log_level = _logging.DEBUG if debug else _logging.INFO
setup_logging(level=log_level)
self.add_middleware(LoggingMiddleware)
self.log = get_logger("responder.app")
else:
import logging as _logging
self.log = _logging.getLogger("responder.app")
@property
def requests(self):
"""A test client connected to the ASGI app. Lazily initialized."""
+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>
+181
View File
@@ -0,0 +1,181 @@
"""Structured logging with per-request context.
Provides a logging setup that automatically includes request metadata
(request ID, method, path, client IP) in every log message emitted
during request handling.
Usage::
api = responder.API(enable_logging=True)
# In any route or middleware:
from responder.ext.logging import get_logger
logger = get_logger(__name__)
@api.route("/")
def index(req, resp):
logger.info("handling request")
# => 2026-03-24 12:00:00 [INFO] app — handling request [GET /] [req:abc123] [client:127.0.0.1]
"""
from __future__ import annotations
import logging
import time
import uuid
from contextvars import ContextVar
__all__ = ["get_logger", "RequestContext", "RequestContextFilter", "LoggingMiddleware"]
# Context variables for per-request metadata.
_request_id: ContextVar[str] = ContextVar("request_id", default="-")
_request_method: ContextVar[str] = ContextVar("request_method", default="-")
_request_path: ContextVar[str] = ContextVar("request_path", default="-")
_client_ip: ContextVar[str] = ContextVar("client_ip", default="-")
class RequestContext:
"""Read-only access to the current request's logging context."""
@staticmethod
def get_request_id() -> str:
return _request_id.get()
@staticmethod
def get_method() -> str:
return _request_method.get()
@staticmethod
def get_path() -> str:
return _request_path.get()
@staticmethod
def get_client_ip() -> str:
return _client_ip.get()
class RequestContextFilter(logging.Filter):
"""A logging filter that injects request context into log records.
Adds ``request_id``, ``request_method``, ``request_path``, and
``client_ip`` attributes to every log record, so they can be used
in format strings.
"""
def filter(self, record: logging.LogRecord) -> bool:
record.request_id = _request_id.get() # type: ignore[attr-defined]
record.request_method = _request_method.get() # type: ignore[attr-defined]
record.request_path = _request_path.get() # type: ignore[attr-defined]
record.client_ip = _client_ip.get() # type: ignore[attr-defined]
return True
# Default format that includes request context.
DEFAULT_LOG_FORMAT = (
"%(asctime)s [%(levelname)s] %(name)s -- %(message)s "
"[%(request_method)s %(request_path)s] "
"[req:%(request_id)s] [client:%(client_ip)s]"
)
def get_logger(name: str | None = None) -> logging.Logger:
"""Get a logger with the request context filter attached.
:param name: Logger name (typically ``__name__``).
:returns: A :class:`logging.Logger` with request context available.
"""
logger = logging.getLogger(name)
# Avoid adding duplicate filters.
if not any(isinstance(f, RequestContextFilter) for f in logger.filters):
logger.addFilter(RequestContextFilter())
return logger
def setup_logging(level: int = logging.INFO) -> None:
"""Configure the root logger with request-context-aware formatting.
:param level: The logging level (default ``INFO``).
"""
root = logging.getLogger()
root.setLevel(level)
# Only add our handler if the root logger has no handlers yet,
# or if none of them use our filter.
has_context_handler = any(
any(isinstance(f, RequestContextFilter) for f in h.filters)
for h in root.handlers
)
if not has_context_handler:
handler = logging.StreamHandler()
handler.setLevel(level)
handler.addFilter(RequestContextFilter())
handler.setFormatter(logging.Formatter(DEFAULT_LOG_FORMAT))
root.addHandler(handler)
class LoggingMiddleware:
"""ASGI middleware that sets per-request context variables.
For each HTTP request, this middleware:
1. Extracts or generates a request ID
2. Sets context variables for method, path, and client IP
3. Logs the request and response with timing information
"""
def __init__(self, app, logger_name: str = "responder.access"):
self.app = app
self.logger = get_logger(logger_name)
async def __call__(self, scope, receive, send):
if scope["type"] not in ("http", "websocket"):
await self.app(scope, receive, send)
return
# Extract request metadata.
headers = dict(scope.get("headers", []))
request_id = (
headers.get(b"x-request-id", b"").decode() or uuid.uuid4().hex[:8]
)
method = scope.get("method", "WS")
path = scope.get("path", "/")
client = scope.get("client")
client_ip = client[0] if client else "-"
# Set context variables for the duration of this request.
tok_id = _request_id.set(request_id)
tok_method = _request_method.set(method)
tok_path = _request_path.set(path)
tok_ip = _client_ip.set(client_ip)
# Track response status.
status_code = None
async def send_wrapper(message):
nonlocal status_code
if message["type"] == "http.response.start":
status_code = message.get("status")
# Inject request ID into response headers.
headers_list = list(message.get("headers", []))
headers_list.append((b"x-request-id", request_id.encode()))
message = {**message, "headers": headers_list}
await send(message)
start = time.perf_counter()
try:
await self.app(scope, receive, send_wrapper)
finally:
duration_ms = (time.perf_counter() - start) * 1000
if scope["type"] == "http":
self.logger.info(
"%s %s%s (%.1fms)",
method,
path,
status_code or "?",
duration_ms,
)
# Reset context variables.
_request_id.reset(tok_id)
_request_method.reset(tok_method)
_request_path.reset(tok_path)
_client_ip.reset(tok_ip)
+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")
+6 -1
View File
@@ -60,7 +60,12 @@ def test_cli_version(capfd):
)
stdout = capfd.readouterr().out.strip()
assert stdout == __version__
# TODO: Accommodate PyPy as installed by `uv`, it emits spurious output on stdout before the version number.
# AssertionError: assert '(_common_types_metatype, 9088, 128, 128)\n(cython_function_or_method, 157568, 128, 128)\n3.6.0' == '3.6.0'
lines = [line.strip() for line in stdout.splitlines() if line.strip()]
assert lines, "Expected version output on stdout"
assert lines[-1] == __version__
def responder_build(path: Path, capfd: CaptureFixture) -> t.Tuple[str, str]:
+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():
+158
View File
@@ -0,0 +1,158 @@
"""Tests for structured logging with request context."""
import logging
import responder
from responder.ext.logging import (
LoggingMiddleware,
RequestContext,
RequestContextFilter,
get_logger,
)
def test_logging_middleware_sets_request_id():
"""LoggingMiddleware adds X-Request-ID to responses."""
api = responder.API(allowed_hosts=["localhost"], enable_logging=True)
@api.route("/")
def index(req, resp):
resp.text = "ok"
r = api.requests.get("http://localhost/")
assert r.status_code == 200
assert "x-request-id" in r.headers
assert len(r.headers["x-request-id"]) > 0
def test_logging_middleware_forwards_request_id():
"""LoggingMiddleware forwards client-provided X-Request-ID."""
api = responder.API(allowed_hosts=["localhost"], enable_logging=True)
@api.route("/")
def index(req, resp):
resp.text = "ok"
r = api.requests.get(
"http://localhost/", headers={"X-Request-ID": "custom-id-123"}
)
assert r.headers["x-request-id"] == "custom-id-123"
def test_logging_context_available_in_route():
"""Request context is available inside route handlers."""
api = responder.API(allowed_hosts=["localhost"], enable_logging=True)
captured = {}
@api.route("/ctx")
def index(req, resp):
captured["request_id"] = RequestContext.get_request_id()
captured["method"] = RequestContext.get_method()
captured["path"] = RequestContext.get_path()
captured["client_ip"] = RequestContext.get_client_ip()
resp.text = "ok"
api.requests.get("http://localhost/ctx")
assert captured["method"] == "GET"
assert captured["path"] == "/ctx"
assert captured["request_id"] != "-"
assert captured["client_ip"] != "-"
def test_logging_filter_injects_attributes():
"""RequestContextFilter adds context fields to log records."""
logger = get_logger("test.filter")
records = []
class CaptureHandler(logging.Handler):
def emit(self, record):
records.append(record)
handler = CaptureHandler()
logger.addHandler(handler)
logger.setLevel(logging.DEBUG)
api = responder.API(allowed_hosts=["localhost"], enable_logging=True)
@api.route("/log")
def index(req, resp):
logger.info("test message")
resp.text = "ok"
api.requests.get("http://localhost/log")
logger.removeHandler(handler)
assert len(records) > 0
record = records[0]
assert hasattr(record, "request_id")
assert hasattr(record, "request_method")
assert hasattr(record, "request_path")
assert hasattr(record, "client_ip")
assert record.request_method == "GET"
assert record.request_path == "/log"
def test_get_logger_avoids_duplicate_filters():
"""get_logger doesn't add duplicate filters."""
logger = get_logger("test.dedup")
count_before = sum(1 for f in logger.filters if isinstance(f, RequestContextFilter))
get_logger("test.dedup")
count_after = sum(1 for f in logger.filters if isinstance(f, RequestContextFilter))
assert count_before == count_after == 1
def test_enable_logging_supersedes_request_id():
"""enable_logging handles request IDs itself (no duplicate headers)."""
api = responder.API(
allowed_hosts=["localhost"], request_id=True, enable_logging=True
)
@api.route("/")
def index(req, resp):
resp.text = "ok"
r = api.requests.get("http://localhost/")
# Should have exactly one X-Request-ID header.
assert "x-request-id" in r.headers
def test_api_logger_attribute():
"""api.log is available when enable_logging=True."""
api = responder.API(allowed_hosts=["localhost"], enable_logging=True)
assert api.log is not None
assert api.log.name == "responder.app"
def test_api_log_always_available():
"""api.log works even without enable_logging — just no request context."""
api = responder.API(allowed_hosts=["localhost"])
assert api.log is not None
assert api.log.name == "responder.app"
api.log.info("works without enable_logging")
def test_api_logger_works_in_routes():
"""api.log can be used inside route handlers with context."""
api = responder.API(allowed_hosts=["localhost"], enable_logging=True)
records = []
class CaptureHandler(logging.Handler):
def emit(self, record):
records.append(record)
handler = CaptureHandler()
api.log.addHandler(handler)
@api.route("/")
def index(req, resp):
api.log.info("hello from route")
resp.text = "ok"
api.requests.get("http://localhost/")
api.log.removeHandler(handler)
assert any(r.msg == "hello from route" for r in records)
record = next(r for r in records if r.msg == "hello from route")
assert record.request_method == "GET"
assert record.request_path == "/"