Skip to content

Commit f181585

Browse files
Fix issue in ASGI header consumption (#1578)
* Correct code for Sanic instrumentation * Correct handling of headers in ASGIWebTransaction * Correct handling of headers in ASGIBrowserMiddleware * Add regression test for ASGI headers issues --------- Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
1 parent f59f52c commit f181585

File tree

5 files changed

+61
-4
lines changed

5 files changed

+61
-4
lines changed

newrelic/api/asgi_application.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -132,10 +132,20 @@ async def send_inject_browser_agent(self, message):
132132

133133
message_type = message["type"]
134134
if message_type == "http.response.start" and not self.initial_message:
135-
headers = list(message.get("headers", ()))
135+
# message["headers"] may be a generator, and consuming it via process_response will leave the original
136+
# application with no headers. Fix this by preserving them in a list before consuming them.
137+
if "headers" in message:
138+
message["headers"] = headers = list(message["headers"])
139+
else:
140+
headers = []
141+
142+
# Check if we should insert the HTML snippet based on the headers.
143+
# Currently if there are no headers this will always be False, but call the function
144+
# anyway in case this logic changes in the future.
136145
if not self.should_insert_html(headers):
137146
await self.abort()
138147
return
148+
139149
message["headers"] = headers
140150
self.initial_message = message
141151
elif message_type == "http.response.body" and self.initial_message:
@@ -232,7 +242,13 @@ async def send(self, event):
232242
finally:
233243
self.__exit__(*sys.exc_info())
234244
elif event["type"] == "http.response.start":
235-
self.process_response(event["status"], event.get("headers", ()))
245+
# event["headers"] may be a generator, and consuming it via process_response will leave the original
246+
# ASGI application with no headers. Fix this by preserving them in a list before consuming them.
247+
if "headers" in event:
248+
event["headers"] = headers = list(event["headers"])
249+
else:
250+
headers = []
251+
self.process_response(event["status"], headers)
236252
return await self._send(event)
237253

238254

newrelic/hooks/framework_sanic.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@ async def _nr_sanic_response_send(wrapped, instance, args, kwargs):
183183
transaction = current_transaction()
184184
result = wrapped(*args, **kwargs)
185185
if isawaitable(result):
186-
await result
186+
result = await result
187187

188188
if transaction is None:
189189
return result

tests/agent_features/test_asgi_transaction.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from testing_support.fixtures import override_application_settings
2020
from testing_support.sample_asgi_applications import (
2121
AppWithDescriptor,
22+
asgi_application_generator_headers,
2223
simple_app_v2,
2324
simple_app_v2_init_exc,
2425
simple_app_v2_raw,
@@ -37,6 +38,7 @@
3738
simple_app_v3_wrapped = AsgiTest(simple_app_v3)
3839
simple_app_v2_wrapped = AsgiTest(simple_app_v2)
3940
simple_app_v2_init_exc = AsgiTest(simple_app_v2_init_exc)
41+
asgi_application_generator_headers = AsgiTest(asgi_application_generator_headers)
4042

4143

4244
# Test naming scheme logic and ASGIApplicationWrapper for a single callable
@@ -85,6 +87,28 @@ def test_double_callable_raw():
8587
assert response.body == b""
8688

8789

90+
# Ensure headers object is preserved
91+
@pytest.mark.parametrize("browser_monitoring", [True, False])
92+
@validate_transaction_metrics(name="", group="Uri")
93+
def test_generator_headers(browser_monitoring):
94+
"""
95+
Both ASGIApplicationWrapper and ASGIBrowserMiddleware can cause headers to be lost if generators are
96+
not handled properly.
97+
98+
Ensure neither destroys headers by testing with and without the ASGIBrowserMiddleware, to make sure whichever
99+
receives headers first properly preserves them in a list.
100+
"""
101+
102+
@override_application_settings({"browser_monitoring.enabled": browser_monitoring})
103+
def _test():
104+
response = asgi_application_generator_headers.make_request("GET", "/")
105+
assert response.status == 200
106+
assert response.headers == {"x-my-header": "myvalue"}
107+
assert response.body == b""
108+
109+
_test()
110+
111+
88112
# Test asgi_application decorator with parameters passed in on a single callable
89113
@pytest.mark.parametrize("name, group", ((None, "group"), ("name", "group"), ("", "group")))
90114
def test_asgi_application_decorator_single_callable(name, group):

tests/testing_support/asgi_testing.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ def process_output(self):
106106
if self.response_state is ResponseState.NOT_STARTED:
107107
assert message["type"] == "http.response.start"
108108
response_status = message["status"]
109-
response_headers = message.get("headers", response_headers)
109+
response_headers = list(message.get("headers", response_headers))
110110
self.response_state = ResponseState.BODY
111111
elif self.response_state is ResponseState.BODY:
112112
assert message["type"] == "http.response.body"

tests/testing_support/sample_asgi_applications.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,23 @@ async def normal_asgi_application(scope, receive, send):
114114
await send({"type": "http.response.body", "body": output})
115115

116116

117+
@ASGIApplicationWrapper
118+
async def asgi_application_generator_headers(scope, receive, send):
119+
if scope["type"] == "lifespan":
120+
return await handle_lifespan(scope, receive, send)
121+
122+
if scope["type"] != "http":
123+
raise ValueError("unsupported")
124+
125+
def headers():
126+
yield (b"x-my-header", b"myvalue")
127+
128+
await send({"type": "http.response.start", "status": 200, "headers": headers()})
129+
await send({"type": "http.response.body"})
130+
131+
assert current_transaction() is None
132+
133+
117134
async def handle_lifespan(scope, receive, send):
118135
"""Handle lifespan protocol with no-ops to allow more compatibility."""
119136
while True:

0 commit comments

Comments
 (0)