Skip to content

Commit 99b532b

Browse files
committed
add error handling and enable graceful shutdown
1 parent a144b4b commit 99b532b

File tree

2 files changed

+68
-30
lines changed

2 files changed

+68
-30
lines changed

channels/generic/http.py

+27-17
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,21 @@
1+
import logging
2+
import traceback
3+
14
from channels.consumer import AsyncConsumer
25

36
from ..db import aclose_old_connections
47
from ..exceptions import StopConsumer
58

9+
logger = logging.getLogger("channels.consumer")
10+
if not logger.hasHandlers():
11+
handler = logging.StreamHandler()
12+
formatter = logging.Formatter(
13+
"%(asctime)s - %(name)s - %(levelname)s - %(message)s"
14+
)
15+
handler.setFormatter(formatter)
16+
logger.addHandler(handler)
17+
logger.setLevel(logging.DEBUG)
18+
619

720
class AsyncHttpConsumer(AsyncConsumer):
821
"""
@@ -17,10 +30,6 @@ async def send_headers(self, *, status=200, headers=None):
1730
"""
1831
Sets the HTTP response status and headers. Headers may be provided as
1932
a list of tuples or as a dictionary.
20-
21-
Note that the ASGI spec requires that the protocol server only starts
22-
sending the response to the client after ``self.send_body`` has been
23-
called the first time.
2433
"""
2534
if headers is None:
2635
headers = []
@@ -34,10 +43,6 @@ async def send_headers(self, *, status=200, headers=None):
3443
async def send_body(self, body, *, more_body=False):
3544
"""
3645
Sends a response body to the client. The method expects a bytestring.
37-
38-
Set ``more_body=True`` if you want to send more body content later.
39-
The default behavior closes the response, and further messages on
40-
the channel will be ignored.
4146
"""
4247
assert isinstance(body, bytes), "Body is not bytes"
4348
await self.send(
@@ -46,18 +51,14 @@ async def send_body(self, body, *, more_body=False):
4651

4752
async def send_response(self, status, body, **kwargs):
4853
"""
49-
Sends a response to the client. This is a thin wrapper over
50-
``self.send_headers`` and ``self.send_body``, and everything said
51-
above applies here as well. This method may only be called once.
54+
Sends a response to the client.
5255
"""
5356
await self.send_headers(status=status, **kwargs)
5457
await self.send_body(body)
5558

5659
async def handle(self, body):
5760
"""
58-
Receives the request body as a bytestring. Response may be composed
59-
using the ``self.send*`` methods; the return value of this method is
60-
thrown away.
61+
Receives the request body as a bytestring.
6162
"""
6263
raise NotImplementedError(
6364
"Subclasses of AsyncHttpConsumer must provide a handle() method."
@@ -77,9 +78,14 @@ async def http_request(self, message):
7778
"""
7879
if "body" in message:
7980
self.body.append(message["body"])
81+
8082
if not message.get("more_body"):
8183
try:
8284
await self.handle(b"".join(self.body))
85+
except Exception:
86+
logger.error(f"Error in handle(): {traceback.format_exc()}")
87+
await self.send_response(500, b"Internal Server Error")
88+
raise
8389
finally:
8490
await self.disconnect()
8591
raise StopConsumer()
@@ -88,6 +94,10 @@ async def http_disconnect(self, message):
8894
"""
8995
Let the user do their cleanup and close the consumer.
9096
"""
91-
await self.disconnect()
92-
await aclose_old_connections()
93-
raise StopConsumer()
97+
try:
98+
await self.disconnect()
99+
await aclose_old_connections()
100+
except Exception as e:
101+
logger.error(f"Error during disconnect: {str(e)}")
102+
finally:
103+
raise StopConsumer()

tests/test_generic_http.py

+41-13
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import asyncio
22
import json
33
import time
4+
from unittest.mock import patch
45

56
import pytest
67

@@ -57,8 +58,7 @@ async def handle(self, body):
5758
@pytest.mark.asyncio
5859
async def test_per_scope_consumers():
5960
"""
60-
Tests that a distinct consumer is used per scope, with AsyncHttpConsumer as
61-
the example consumer class.
61+
Tests that a distinct consumer is used per scope.
6262
"""
6363

6464
class TestConsumer(AsyncHttpConsumer):
@@ -68,7 +68,6 @@ def __init__(self):
6868

6969
async def handle(self, body):
7070
body = f"{self.__class__.__name__} {id(self)} {self.time}"
71-
7271
await self.send_response(
7372
200,
7473
body.encode("utf-8"),
@@ -77,27 +76,21 @@ async def handle(self, body):
7776

7877
app = TestConsumer.as_asgi()
7978

80-
# Open a connection
8179
communicator = HttpCommunicator(app, method="GET", path="/test/")
8280
response = await communicator.get_response()
8381
assert response["status"] == 200
8482

85-
# And another one.
8683
communicator = HttpCommunicator(app, method="GET", path="/test2/")
8784
second_response = await communicator.get_response()
8885
assert second_response["status"] == 200
89-
9086
assert response["body"] != second_response["body"]
9187

9288

9389
@pytest.mark.django_db(transaction=True)
9490
@pytest.mark.asyncio
9591
async def test_async_http_consumer_future():
9692
"""
97-
Regression test for channels accepting only coroutines. The ASGI specification
98-
states that the `receive` and `send` arguments to an ASGI application should be
99-
"awaitable callable" objects. That includes non-coroutine functions that return
100-
Futures.
93+
Regression test for channels accepting only coroutines.
10194
"""
10295

10396
class TestConsumer(AsyncHttpConsumer):
@@ -110,7 +103,6 @@ async def handle(self, body):
110103

111104
app = TestConsumer()
112105

113-
# Ensure the passed functions are specifically coroutines.
114106
async def coroutine_app(scope, receive, send):
115107
async def receive_coroutine():
116108
return await asyncio.ensure_future(receive())
@@ -126,7 +118,6 @@ async def send_coroutine(*args, **kwargs):
126118
assert response["status"] == 200
127119
assert response["headers"] == [(b"Content-Type", b"text/plain")]
128120

129-
# Ensure the passed functions are "Awaitable Callables" and NOT coroutines.
130121
async def awaitable_callable_app(scope, receive, send):
131122
def receive_awaitable_callable():
132123
return asyncio.ensure_future(receive())
@@ -136,9 +127,46 @@ def send_awaitable_callable(*args, **kwargs):
136127

137128
await app(scope, receive_awaitable_callable, send_awaitable_callable)
138129

139-
# Open a connection
140130
communicator = HttpCommunicator(awaitable_callable_app, method="GET", path="/")
141131
response = await communicator.get_response()
142132
assert response["body"] == b"42"
143133
assert response["status"] == 200
144134
assert response["headers"] == [(b"Content-Type", b"text/plain")]
135+
136+
137+
@pytest.mark.django_db(transaction=True)
138+
@pytest.mark.asyncio
139+
async def test_error_logging():
140+
"""Regression test for error logging."""
141+
142+
class TestConsumer(AsyncHttpConsumer):
143+
async def handle(self, body):
144+
raise AssertionError("Error correctly raised")
145+
146+
communicator = HttpCommunicator(TestConsumer(), "GET", "/")
147+
with patch("channels.generic.http.logger.error") as mock_logger_error:
148+
try:
149+
await communicator.get_response(timeout=0.05)
150+
except AssertionError:
151+
pass
152+
args, _ = mock_logger_error.call_args
153+
assert "Error in handle()" in args[0]
154+
assert "AssertionError: Error correctly raised" in args[0]
155+
156+
157+
@pytest.mark.django_db(transaction=True)
158+
@pytest.mark.asyncio
159+
async def test_error_handling_and_send_response():
160+
"""Regression test to check error handling."""
161+
162+
class TestConsumer(AsyncHttpConsumer):
163+
async def handle(self, body):
164+
raise AssertionError("Error correctly raised")
165+
166+
communicator = HttpCommunicator(TestConsumer(), "GET", "/")
167+
with patch.object(AsyncHttpConsumer, "send_response") as mock_send_response:
168+
try:
169+
await communicator.get_response(timeout=0.05)
170+
except AssertionError:
171+
pass
172+
mock_send_response.assert_called_once_with(500, b"Internal Server Error")

0 commit comments

Comments
 (0)