Skip to content

Commit

Permalink
refactor: update request-id recipe to use contextvars (#2404)
Browse files Browse the repository at this point in the history
* refactor: request-id to use contextvars

Migrate the request-id handling from threading.local() to contextvars to improve compatibility
with async coroutines and avoid issues with threading. This change ensures that request-id
is properly tracked in asynchronous environments, providing more robust handling in both
sync and async contexts.
Previously, threading.local() was used, which does not handle coroutines effectively.
By using contextvars, we ensure that the request-id remains consistent across async calls.

Closes #2260

* adding test to test_recipes

* test(request_id): add tests for request id middleware context handling

Add tests to verify that request_id is unique per request and correctly set in response headers. Tests include checks for isolation in async calls and persistence in synchronous requests.

Related to issue #2260

* test(request_id): add tests for request id middleware context handling

Add tests to verify that request_id is unique per request and correctly set in response headers. Tests include checks for isolation in async calls and persistence in synchronous requests.

Related to issue #2260

* minor changes in tests

* fix(recipes): fix request-id recipe tests

---------

Co-authored-by: Vytautas Liuolia <[email protected]>
  • Loading branch information
EricGoulart and vytas7 authored Dec 27, 2024
1 parent 11076b8 commit 77d5e63
Show file tree
Hide file tree
Showing 5 changed files with 60 additions and 10 deletions.
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()
4 changes: 3 additions & 1 deletion examples/recipes/request_id_middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@

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
8 changes: 6 additions & 2 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import json
import os
import pathlib
import sys
import urllib.parse

import pytest
Expand Down Expand Up @@ -96,7 +97,7 @@ def disable_asgi_non_coroutine_wrapping():
os.environ['FALCON_ASGI_WRAP_NON_COROUTINES'] = 'Y'

@staticmethod
def load_module(filename, parent_dir=None, suffix=None):
def load_module(filename, parent_dir=None, suffix=None, module_name=None):
if parent_dir:
filename = pathlib.Path(parent_dir) / filename
else:
Expand All @@ -105,11 +106,14 @@ def load_module(filename, parent_dir=None, suffix=None):
if suffix is not None:
path = path.with_name(f'{path.stem}_{suffix}.py')
prefix = '.'.join(filename.parent.parts)
module_name = f'{prefix}.{path.stem}'
sys_module_name = module_name
module_name = module_name or f'{prefix}.{path.stem}'

spec = importlib.util.spec_from_file_location(module_name, path)
assert spec is not None, f'could not load module from {path}'
module = importlib.util.module_from_spec(spec)
if sys_module_name:
sys.modules[sys_module_name] = module
spec.loader.exec_module(module)
return module

Expand Down
44 changes: 44 additions & 0 deletions tests/test_recipes.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,3 +135,47 @@ def test_raw_path(self, asgi, app_kind, util):

scope2 = falcon.testing.create_scope(url2)
assert scope2['raw_path'] == url2.encode()


class TestRequestIDContext:
@pytest.fixture
def app(self, util):
# NOTE(vytas): Inject `context` into the importable system modules
# as it is referenced from other recipes.
util.load_module(
'examples/recipes/request_id_context.py', module_name='context'
)
recipe = util.load_module('examples/recipes/request_id_middleware.py')

app = falcon.App(middleware=[recipe.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(self, app):
client = falcon.testing.TestClient(app)
request_id1 = client.simulate_get('/test').json['request_id']
request_id2 = client.simulate_get('/test').json['request_id']

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']

0 comments on commit 77d5e63

Please sign in to comment.