Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: update request-id recipe to use contextvars #2382

Open
wants to merge 12 commits into
base: master
Choose a base branch
from
6 changes: 3 additions & 3 deletions docs/user/recipes/request-id.rst
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,14 @@ to every log entry.

If you wish to trace each request throughout your application, including
from within components that are deeply nested or otherwise live outside of the
normal request context, you can use a `thread-local`_ context object to store
normal request context, you can use a `contextvars`_ object to store
the request ID:

.. literalinclude:: ../../../examples/recipes/request_id_context.py
:language: python

Then, you can create a :ref:`middleware <middleware>` class to generate a
unique ID for each request, persisting it in the thread local:
unique ID for each request, persisting it in the `contextvars` object:

.. literalinclude:: ../../../examples/recipes/request_id_middleware.py
:language: python
Expand Down Expand Up @@ -48,4 +48,4 @@ In a pinch, you can also output the request ID directly:
.. literalinclude:: ../../../examples/recipes/request_id_log.py
:language: python

.. _thread-local: https://docs.python.org/3/library/threading.html#thread-local-data
.. _contextvars: https://docs.python.org/3/library/contextvars.html
8 changes: 4 additions & 4 deletions examples/recipes/request_id_context.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
# context.py

import threading
import contextvars


class _Context:
def __init__(self):
self._thread_local = threading.local()
self._request_id_var = contextvars.ContextVar('request_id', default=None)

@property
def request_id(self):
return getattr(self._thread_local, 'request_id', None)
return self._request_id_var.get()

@request_id.setter
def request_id(self, value):
self._thread_local.request_id = value
self._request_id_var.set(value)


ctx = _Context()
6 changes: 4 additions & 2 deletions examples/recipes/request_id_middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@

from uuid import uuid4

from context import ctx
from .request_id_context import ctx


class RequestIDMiddleware:
def process_request(self, req, resp):
ctx.request_id = str(uuid4())
request_id = str(uuid4())
ctx.request_id = request_id
req.context.request_id = request_id

# It may also be helpful to include the ID in the response
def process_response(self, req, resp, resource, req_succeeded):
Expand Down
45 changes: 45 additions & 0 deletions tests/test_recipes.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import asyncio

import pytest

from examples.recipes.request_id_middleware import RequestIDMiddleware
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not a good idea to import this way, it may not work correctly when running pytest from another directory, for instance.

Let's import directly from the Python file, as it is done in other tests in this file.

import falcon
import falcon.testing

Expand Down Expand Up @@ -141,3 +144,45 @@ def test_raw_path(self, asgi, app_kind, util):
)
assert result2.status_code == 200
assert result2.json == {'cached': True}


class TestRequestIDContext:
@pytest.fixture
def app(self):
app = falcon.App(middleware=[RequestIDMiddleware()])
app.add_route('/test', self.RequestIDResource())
return app

class RequestIDResource:
def on_get(self, req, resp):
resp.media = {'request_id': req.context.request_id}

def test_request_id_isolated_in_async(self, app):
async def make_request():
client = falcon.testing.TestClient(app)
response = client.simulate_get('/test')
return response.json['request_id']

loop = asyncio.get_event_loop()
request_id1, request_id2 = loop.run_until_complete(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These won't run asynchronously anyway, there is no real benefit vs just running make_request() twice.
(If we were testing an async app, we could achieve this with ASGIConductor.)

asyncio.gather(make_request(), make_request())
)
assert request_id1 != request_id2

def test_request_id_persistence(self, app):
client = falcon.testing.TestClient(app)

response = client.simulate_get('/test')
request_id1 = response.json['request_id']

response = client.simulate_get('/test')
request_id2 = response.json['request_id']

assert request_id1 != request_id2

def test_request_id_in_response_header(self, app):
client = falcon.testing.TestClient(app)

response = client.simulate_get('/test')
assert 'X-Request-ID' in response.headers
assert response.headers['X-Request-ID'] == response.json['request_id']