Repository: django/channels (python)

Issue:
HttpCommunicator does not raise exception from consumer
If `WebsocketCommunicator` encounters an error, it shows the exception raised by the underlying consumer. In contrast, `HttpCommunicator` just shows a `TimeoutError`, which is not useful for debugging.

Example tests:

```py
from channels.generic.http import AsyncHttpConsumer
from channels.generic.websocket import AsyncWebsocketConsumer
from channels.testing import HttpCommunicator
from channels.testing import WebsocketCommunicator
from django.test import TestCase


class HttpConsumer(AsyncHttpConsumer):
    async def handle(self, body):
        1 / 0


class WebsocketConsumer(AsyncWebsocketConsumer):
    async def connect(self):
        1 / 0


class ConsumerTests(TestCase):
    async def test_http(self):
        communicator = HttpCommunicator(HttpConsumer.as_asgi(), "GET", "/")
        await communicator.get_response()

    async def test_websocket(self):
        communicator = WebsocketCommunicator(WebsocketConsumer.as_asgi(), "/")
        connected, subprotocol = await communicator.connect()
```

Output:

```
$ python manage.py test
Found 2 test(s).
Creating test database for alias 'default'...
Destroying old test database for alias 'default'...
System check identified no issues (0 silenced).
<Task finished name='Task-2' coro=<HttpConsumer() done, defined at /Users/chainz/tmp/channelstest/venv/lib/python3.10/site-packages/channels/consumer.py:92> result=None>
E<Task finished name='Task-7' coro=<WebsocketConsumer() done, defined at /Users/chainz/tmp/channelstest/venv/lib/python3.10/site-packages/channels/consumer.py:92> exception=ZeroDivisionError('division by zero')>
E
======================================================================
ERROR: test_http (example.tests.ConsumerTests)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/chainz/tmp/channelstest/venv/lib/python3.10/site-packages/asgiref/testing.py", line 74, in receive_output
    return await self.output_queue.get()
  File "/Users/chainz/.pyenv/versions/3.10.8/lib/python3.10/asyncio/queues.py", line 159, in get
    await getter
asyncio.exceptions.CancelledError

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/Users/chainz/tmp/channelstest/venv/lib/python3.10/site-packages/asgiref/sync.py", line 218, in __call__
    return call_result.result()
  File "/Users/chainz/.pyenv/versions/3.10.8/lib/python3.10/concurrent/futures/_base.py", line 451, in result
    return self.__get_result()
  File "/Users/chainz/.pyenv/versions/3.10.8/lib/python3.10/concurrent/futures/_base.py", line 403, in __get_result
    raise self._exception
  File "/Users/chainz/tmp/channelstest/venv/lib/python3.10/site-packages/asgiref/sync.py", line 284, in main_wrap
    result = await self.awaitable(*args, **kwargs)
  File "/Users/chainz/tmp/channelstest/example/tests.py", line 21, in test_http
    await communicator.get_response()
  File "/Users/chainz/tmp/channelstest/venv/lib/python3.10/site-packages/channels/testing/http.py", line 42, in get_response
    response_start = await self.receive_output(timeout)
  File "/Users/chainz/tmp/channelstest/venv/lib/python3.10/site-packages/asgiref/testing.py", line 86, in receive_output
    raise e
  File "/Users/chainz/tmp/channelstest/venv/lib/python3.10/site-packages/asgiref/testing.py", line 73, in receive_output
    async with async_timeout(timeout):
  File "/Users/chainz/tmp/channelstest/venv/lib/python3.10/site-packages/asgiref/timeout.py", line 65, in __aexit__
    self._do_exit(exc_type)
  File "/Users/chainz/tmp/channelstest/venv/lib/python3.10/site-packages/asgiref/timeout.py", line 102, in _do_exit
    raise asyncio.TimeoutError
asyncio.exceptions.TimeoutError

======================================================================
ERROR: test_websocket (example.tests.ConsumerTests)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/chainz/tmp/channelstest/venv/lib/python3.10/site-packages/asgiref/testing.py", line 74, in receive_output
    return await self.output_queue.get()
  File "/Users/chainz/.pyenv/versions/3.10.8/lib/python3.10/asyncio/queues.py", line 159, in get
    await getter
asyncio.exceptions.CancelledError

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/Users/chainz/tmp/channelstest/venv/lib/python3.10/site-packages/asgiref/testing.py", line 73, in receive_output
    async with async_timeout(timeout):
  File "/Users/chainz/tmp/channelstest/venv/lib/python3.10/site-packages/asgiref/timeout.py", line 65, in __aexit__
    self._do_exit(exc_type)
  File "/Users/chainz/tmp/channelstest/venv/lib/python3.10/site-packages/asgiref/timeout.py", line 102, in _do_exit
    raise asyncio.TimeoutError
asyncio.exceptions.TimeoutError

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/Users/chainz/tmp/channelstest/venv/lib/python3.10/site-packages/asgiref/sync.py", line 218, in __call__
    return call_result.result()
  File "/Users/chainz/.pyenv/versions/3.10.8/lib/python3.10/concurrent/futures/_base.py", line 451, in result
    return self.__get_result()
  File "/Users/chainz/.pyenv/versions/3.10.8/lib/python3.10/concurrent/futures/_base.py", line 403, in __get_result
    raise self._exception
  File "/Users/chainz/tmp/channelstest/venv/lib/python3.10/site-packages/asgiref/sync.py", line 284, in main_wrap
    result = await self.awaitable(*args, **kwargs)
  File "/Users/chainz/tmp/channelstest/example/tests.py", line 25, in test_websocket
    connected, subprotocol = await communicator.connect()
  File "/Users/chainz/tmp/channelstest/venv/lib/python3.10/site-packages/channels/testing/websocket.py", line 36, in connect
    response = await self.receive_output(timeout)
  File "/Users/chainz/tmp/channelstest/venv/lib/python3.10/site-packages/asgiref/testing.py", line 79, in receive_output
    self.future.result()
  File "/Users/chainz/tmp/channelstest/venv/lib/python3.10/site-packages/channels/consumer.py", line 94, in app
    return await consumer(scope, receive, send)
  File "/Users/chainz/tmp/channelstest/venv/lib/python3.10/site-packages/channels/consumer.py", line 62, in __call__
    await await_many_dispatch([receive], self.dispatch)
  File "/Users/chainz/tmp/channelstest/venv/lib/python3.10/site-packages/channels/utils.py", line 50, in await_many_dispatch
    await dispatch(result)
  File "/Users/chainz/tmp/channelstest/venv/lib/python3.10/site-packages/channels/consumer.py", line 73, in dispatch
    await handler(message)
  File "/Users/chainz/tmp/channelstest/venv/lib/python3.10/site-packages/channels/generic/websocket.py", line 173, in websocket_connect
    await self.connect()
  File "/Users/chainz/tmp/channelstest/example/tests.py", line 15, in connect
    1 / 0
ZeroDivisionError: division by zero

----------------------------------------------------------------------
Ran 2 tests in 2.016s

FAILED (errors=2)
Destroying test database for alias 'default'...
```

Please also try and include, if you can:

(Channels 4.0.0, with Django 4.1.3, on Python 3.10.8)


Pull request:
Prevent HttpConsumer from hiding exceptions
Fixes #1950.

The issue didn’t only affect tests—exceptions were always swallowed by converting them into `StopConsumer()`. So there may be failing HTTP consumers in production apps where the exceptions aren’t being logged.

Patch:
diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst
index 19faea4..fc35f88 100644
--- a/CONTRIBUTING.rst
+++ b/CONTRIBUTING.rst
@@ -25,14 +25,21 @@ Make sure the tests pass:
 
 .. code-block:: sh
 
-    python -m pip install -e .[tests]
+    python -m pip install -e .[tests,daphne]
     pytest
 
+.. note::
+   If you're using ``zsh`` for your shell, the above command will fail with a
+   ``zsh: no matches found: .[tests]`` error.
+   To fix this use ``noglob``::
+
+       noglob python -m pip install -e .[tests]
+
 Make your change. Add tests for your change. Make the tests pass:
 
 .. code-block:: sh
 
-    pytest
+    tox
 
 Make sure your code conforms to the coding style:
 
diff --git a/README.rst b/README.rst
index 6b71b70..f1cd617 100644
--- a/README.rst
+++ b/README.rst
@@ -35,8 +35,8 @@ and `tutorial <https://channels.readthedocs.io/en/latest/tutorial/index.html>`_
 Dependencies
 ------------
 
-All Channels projects currently support Python 3.7 and up. ``channels`` is
-compatible with Django 2.2, 3.2, 4.0 and 4.1.
+All Channels projects currently support Python 3.8 and up. ``channels`` is
+compatible with Django 3.2, 4.0, 4.1, 4.2 and 5.0.
 
 
 Contributing
diff --git a/channels/apps.py b/channels/apps.py
index 088dde3..3350215 100644
--- a/channels/apps.py
+++ b/channels/apps.py
@@ -2,6 +2,5 @@ from django.apps import AppConfig
 
 
 class ChannelsConfig(AppConfig):
-
     name = "channels"
     verbose_name = "Channels"
diff --git a/channels/generic/http.py b/channels/generic/http.py
index 8bbf352..909e857 100644
--- a/channels/generic/http.py
+++ b/channels/generic/http.py
@@ -81,7 +81,7 @@ class AsyncHttpConsumer(AsyncConsumer):
                 await self.handle(b"".join(self.body))
             finally:
                 await self.disconnect()
-                raise StopConsumer()
+            raise StopConsumer()
 
     async def http_disconnect(self, message):
         """
diff --git a/channels/management/commands/runworker.py b/channels/management/commands/runworker.py
index 1dee9fc..edb7ccc 100644
--- a/channels/management/commands/runworker.py
+++ b/channels/management/commands/runworker.py
@@ -11,7 +11,6 @@ logger = logging.getLogger("django.channels.worker")
 
 
 class Command(BaseCommand):
-
     leave_locale_alone = True
     worker_class = Worker
 
diff --git a/channels/sessions.py b/channels/sessions.py
index d54b432..fa16ec9 100644
--- a/channels/sessions.py
+++ b/channels/sessions.py
@@ -111,7 +111,7 @@ class CookieMiddleware:
         # Write out the cookies to the response
         for c in cookies.values():
             message.setdefault("headers", []).append(
-                (b"Set-Cookie", bytes(c.output(header=""), encoding="utf-8"))
+                (b"Set-Cookie", bytes(c.output(header="").strip(), encoding="utf-8"))
             )
 
     @classmethod
diff --git a/docs/community.rst b/docs/community.rst
index 3c9721d..c79ff27 100644
--- a/docs/community.rst
+++ b/docs/community.rst
@@ -12,6 +12,9 @@ These projects from the community are developed on top of Channels:
 * DjangoChannelsJsonRpc_, a wrapper for the JSON-RPC protocol.
 * channels-demultiplexer_, a (de)multiplexer for ``AsyncJsonWebsocketConsumer`` consumers.
 * channels_postgres_, a Django Channels channel layer that uses PostgreSQL as its backing store.
+* channels-auth-token-middlewares_, Django REST framework token authentication middleware and
+  SimpleJWT_ middleware, such as QueryStringSimpleJWTAuthTokenMiddleware_ for WebSocket
+  authentication.
 
 If you'd like to add your project, please submit a PR with a link and brief description.
 
@@ -25,3 +28,6 @@ If you'd like to add your project, please submit a PR with a link and brief desc
 .. _channels-demultiplexer: https://github.com/csdenboer/channels-demultiplexer
 .. _kafka-integration: https://gist.github.com/aryan340/da071d027050cfe0a03df3b500f2f44b
 .. _channels_postgres: https://github.com/danidee10/channels_postgres
+.. _channels-auth-token-middlewares: https://github.com/YegorDB/django-channels-auth-token-middlewares
+.. _SimpleJWT: https://github.com/jazzband/djangorestframework-simplejwt
+.. _QueryStringSimpleJWTAuthTokenMiddleware: https://github.com/YegorDB/django-channels-auth-token-middlewares/tree/master/tutorial/drf#querystringsimplejwtauthtokenmiddleware
diff --git a/docs/installation.rst b/docs/installation.rst
index db48ab8..102795b 100644
--- a/docs/installation.rst
+++ b/docs/installation.rst
@@ -5,7 +5,7 @@ Channels is available on PyPI - to install it run:
 
 .. code-block:: sh
 
-    python -m pip install -U channels["daphne"]
+    python -m pip install -U 'channels[daphne]'
 
 This will install Channels together with the Daphne ASGI application server. If
 you wish to use a different application server you can ``pip install channels``,
diff --git a/docs/introduction.rst b/docs/introduction.rst
index b6b57a7..fbf3ab2 100644
--- a/docs/introduction.rst
+++ b/docs/introduction.rst
@@ -265,7 +265,7 @@ point-to-point and broadcast messaging.
 
 .. code-block:: python
 
-    #In a consumer
+    # In a consumer
     self.channel_layer.send(
         'event', 
         {
diff --git a/docs/tutorial/part_1.rst b/docs/tutorial/part_1.rst
index ca1cdc1..327ae68 100644
--- a/docs/tutorial/part_1.rst
+++ b/docs/tutorial/part_1.rst
@@ -175,7 +175,7 @@ Put the following code in ``chat/templates/chat/index.html``:
         <script>
             document.querySelector('#room-name-input').focus();
             document.querySelector('#room-name-input').onkeyup = function(e) {
-                if (e.keyCode === 13) {  // enter, return
+                if (e.key === 'Enter') {  // enter, return
                     document.querySelector('#room-name-submit').click();
                 }
             };
diff --git a/docs/tutorial/part_2.rst b/docs/tutorial/part_2.rst
index e4a2b8d..1cc04bb 100644
--- a/docs/tutorial/part_2.rst
+++ b/docs/tutorial/part_2.rst
@@ -63,7 +63,7 @@ Create the view template for the room view in ``chat/templates/chat/room.html``:
 
             document.querySelector('#chat-message-input').focus();
             document.querySelector('#chat-message-input').onkeyup = function(e) {
-                if (e.keyCode === 13) {  // enter, return
+                if (e.key === 'Enter') {  // enter, return
                     document.querySelector('#chat-message-submit').click();
                 }
             };
@@ -263,6 +263,8 @@ The next step is to point the main ASGI configuration at the
     from channels.security.websocket import AllowedHostsOriginValidator
     from django.core.asgi import get_asgi_application
 
+    from chat.routing import websocket_urlpatterns
+
     os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mysite.settings")
     # Initialize Django ASGI application early to ensure the AppRegistry
     # is populated before importing code that may import ORM models.
@@ -274,7 +276,7 @@ The next step is to point the main ASGI configuration at the
         {
             "http": django_asgi_app,
             "websocket": AllowedHostsOriginValidator(
-                AuthMiddlewareStack(URLRouter(chat.routing.websocket_urlpatterns))
+                AuthMiddlewareStack(URLRouter(websocket_urlpatterns))
             ),
         }
     )
@@ -365,11 +367,12 @@ That will allow ChatConsumers to transmit messages to all other ChatConsumers in
 the same room.
 
 We will use a channel layer that uses Redis as its backing store. To start a
-Redis server on port 6379, run the following command:
+Redis server on port 6379, run the following command (press Control-C to stop
+it):
 
 .. code-block:: sh
 
-    $ docker run -p 6379:6379 -d redis:5
+    $ docker run --rm -p 6379:6379 redis:7
 
 We need to install channels_redis so that Channels knows how to interface with
 Redis. Run the following command:
@@ -430,7 +433,7 @@ following code in ``chat/consumers.py``, replacing the old code:
     class ChatConsumer(WebsocketConsumer):
         def connect(self):
             self.room_name = self.scope["url_route"]["kwargs"]["room_name"]
-            self.room_group_name = "chat_%s" % self.room_name
+            self.room_group_name = f"chat_{self.room_name}"
 
             # Join room group
             async_to_sync(self.channel_layer.group_add)(
@@ -452,7 +455,7 @@ following code in ``chat/consumers.py``, replacing the old code:
 
             # Send message to room group
             async_to_sync(self.channel_layer.group_send)(
-                self.room_group_name, {"type": "chat_message", "message": message}
+                self.room_group_name, {"type": "chat.message", "message": message}
             )
 
         # Receive message from room group
@@ -478,7 +481,7 @@ Several parts of the new ``ChatConsumer`` code deserve further explanation:
       including in particular any positional or keyword arguments from the URL
       route and the currently authenticated user if any.
 
-* ``self.room_group_name = "chat_%s" % self.room_name``
+* ``self.room_group_name = f"chat_{self.room_name}"``
     * Constructs a Channels group name directly from the user-specified room
       name, without any quoting or escaping.
     * Group names may only contain alphanumerics, hyphens, underscores, or
@@ -511,7 +514,9 @@ Several parts of the new ``ChatConsumer`` code deserve further explanation:
 * ``async_to_sync(self.channel_layer.group_send)``
     * Sends an event to a group.
     * An event has a special ``'type'`` key corresponding to the name of the method
-      that should be invoked on consumers that receive the event.
+      that should be invoked on consumers that receive the event. This translation
+      is done by replacing ``.`` with ``_``, thus in this example, ``chat.message``
+      calls the ``chat_message`` method.
 
 Let's verify that the new consumer for the ``/ws/chat/ROOM_NAME/`` path works.
 To start the Channels development server, run the following command:
diff --git a/docs/tutorial/part_3.rst b/docs/tutorial/part_3.rst
index 5d0ac84..9918236 100644
--- a/docs/tutorial/part_3.rst
+++ b/docs/tutorial/part_3.rst
@@ -40,7 +40,7 @@ Put the following code in ``chat/consumers.py``:
     class ChatConsumer(AsyncWebsocketConsumer):
         async def connect(self):
             self.room_name = self.scope["url_route"]["kwargs"]["room_name"]
-            self.room_group_name = "chat_%s" % self.room_name
+            self.room_group_name = f"chat_{self.room_name}"
 
             # Join room group
             await self.channel_layer.group_add(self.room_group_name, self.channel_name)
@@ -58,7 +58,7 @@ Put the following code in ``chat/consumers.py``:
 
             # Send message to room group
             await self.channel_layer.group_send(
-                self.room_group_name, {"type": "chat_message", "message": message}
+                self.room_group_name, {"type": "chat.message", "message": message}
             )
 
         # Receive message from room group
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..fed528d
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,3 @@
+[build-system]
+requires = ["setuptools"]
+build-backend = "setuptools.build_meta"
diff --git a/setup.cfg b/setup.cfg
index cf138c5..b956947 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,3 +1,58 @@
+[metadata]
+name = channels
+version = attr: channels.__version__
+url = http://github.com/django/channels
+author = Django Software Foundation
+author_email = foundation@djangoproject.com
+description = Brings async, event-driven capabilities to Django 3.2 and up.
+long_description = file: README.rst
+long_description_content_type = text/x-rst
+license = BSD
+classifiers =
+    Development Status :: 5 - Production/Stable
+    Environment :: Web Environment
+    Intended Audience :: Developers
+    License :: OSI Approved :: BSD License
+    Operating System :: OS Independent
+    Programming Language :: Python
+    Programming Language :: Python :: 3
+    Programming Language :: Python :: 3.8
+    Programming Language :: Python :: 3.9
+    Programming Language :: Python :: 3.10
+    Programming Language :: Python :: 3.11
+    Programming Language :: Python :: 3.12
+    Framework :: Django
+    Framework :: Django :: 3
+    Framework :: Django :: 3.2
+    Framework :: Django :: 4
+    Framework :: Django :: 4.0
+    Framework :: Django :: 4.1
+    Framework :: Django :: 4.2
+    Framework :: Django :: 5.0
+    Topic :: Internet :: WWW/HTTP
+
+[options]
+packages = find:
+include_package_data = True
+install_requires =
+    Django>=3.2
+    asgiref>=3.5.0,<4
+python_requires = >=3.8
+
+[options.extras_require]
+tests =
+    async-timeout
+    coverage~=4.5
+    pytest
+    pytest-django
+    pytest-asyncio
+daphne =
+    daphne>=4.0.0
+
+[options.packages.find]
+exclude =
+    tests
+
 [flake8]
 exclude = venv/*,tox/*,docs/*,testproject/*,build/*
 max-line-length = 88
diff --git a/setup.py b/setup.py
deleted file mode 100644
index e95edff..0000000
--- a/setup.py
+++ /dev/null
@@ -1,53 +0,0 @@
-from setuptools import find_packages, setup
-
-from channels import __version__
-
-setup(
-    name="channels",
-    version=__version__,
-    url="http://github.com/django/channels",
-    author="Django Software Foundation",
-    author_email="foundation@djangoproject.com",
-    description="Brings async, event-driven capabilities to Django 3.2 and up.",
-    license="BSD",
-    packages=find_packages(exclude=["tests"]),
-    include_package_data=True,
-    python_requires=">=3.7",
-    install_requires=[
-        "Django>=3.2",
-        "asgiref>=3.5.0,<4",
-    ],
-    extras_require={
-        "tests": [
-            "pytest",
-            "pytest-django",
-            "pytest-asyncio",
-            "async-timeout",
-            "coverage~=4.5",
-        ],
-        "daphne": [
-            "daphne>=4.0.0",
-        ]
-    },
-    classifiers=[
-        "Development Status :: 5 - Production/Stable",
-        "Environment :: Web Environment",
-        "Intended Audience :: Developers",
-        "License :: OSI Approved :: BSD License",
-        "Operating System :: OS Independent",
-        "Programming Language :: Python",
-        "Programming Language :: Python :: 3",
-        "Programming Language :: Python :: 3.7",
-        "Programming Language :: Python :: 3.8",
-        "Programming Language :: Python :: 3.9",
-        "Programming Language :: Python :: 3.10",
-        "Programming Language :: Python :: 3.11",
-        "Framework :: Django",
-        "Framework :: Django :: 3",
-        "Framework :: Django :: 3.2",
-        "Framework :: Django :: 4",
-        "Framework :: Django :: 4.0",
-        "Framework :: Django :: 4.1",
-        "Topic :: Internet :: WWW/HTTP",
-    ],
-)
diff --git a/tox.ini b/tox.ini
index ac6399c..4aa9b80 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,12 +1,14 @@
 [tox]
 envlist =
-    py{37,38,39,310}-dj32
+    py{38,39,310}-dj32
     py{38,39,310}-dj40
-    py{38,39,310,311}-dj{41,main}
+    py{38,39,310,311}-dj41
+    py{38,39,310,311}-dj42
+    py{310,311,312}-dj50
+    py{310,311,312}-djmain
     qa
 
 [testenv]
-usedevelop = true
 extras = tests, daphne
 commands =
     pytest -v {posargs}
@@ -14,6 +16,8 @@ deps =
     dj32: Django>=3.2.9,<4.0
     dj40: Django>=4.0,<4.1
     dj41: Django>=4.1.2,<4.2
+    dj42: Django>=4.2,<5.0
+    dj50: Django>=5.0rc1,<5.1
     djmain: https://github.com/django/django/archive/main.tar.gz
 
 [testenv:qa]


Test patch:
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index c5f27b2..235485c 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -14,14 +14,14 @@ jobs:
       fail-fast: false
       matrix:
         python-version:
-        - "3.7"
         - "3.8"
         - "3.9"
         - "3.10"
         - "3.11"
+        - "3.12"
 
     steps:
-      - uses: actions/checkout@v3
+      - uses: actions/checkout@v4
 
       - name: Set up Python ${{ matrix.python-version }}
         uses: actions/setup-python@v4
@@ -31,16 +31,16 @@ jobs:
       - name: Install dependencies
         run: |
           python -m pip install --upgrade pip wheel setuptools
-          python -m pip install --upgrade tox tox-py
+          python -m pip install --upgrade tox
 
       - name: Run tox targets for ${{ matrix.python-version }}
-        run: tox --py current
+        run: tox run -f py$(echo ${{ matrix.python-version }} | tr -d .)
 
   lint:
     name: Lint
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v3
+      - uses: actions/checkout@v4
 
       - name: Set up Python
         uses: actions/setup-python@v4
diff --git a/docs/topics/testing.rst b/docs/topics/testing.rst
index b115386..a3c14a0 100644
--- a/docs/topics/testing.rst
+++ b/docs/topics/testing.rst
@@ -11,7 +11,8 @@ event loop and ask it questions.
 `You can test asynchronous code
 <https://docs.djangoproject.com/en/stable/topics/testing/tools/#testing-
 asynchronous-code>`_ using Django's ``TestCase``. Alternately, you can use
-``pytest`` with its ``asyncio`` plugin.
+``pytest`` with its
+`pytest-asyncio <https://github.com/pytest-dev/pytest-asyncio>`_ plugin.
 
 
 Setting Up Async Tests
diff --git a/tests/test_generic_http.py b/tests/test_generic_http.py
index 85ecdd0..bfb889c 100644
--- a/tests/test_generic_http.py
+++ b/tests/test_generic_http.py
@@ -38,6 +38,19 @@ async def test_async_http_consumer():
     assert response["headers"] == [(b"Content-Type", b"application/json")]
 
 
+@pytest.mark.asyncio
+async def test_error():
+    class TestConsumer(AsyncHttpConsumer):
+        async def handle(self, body):
+            raise AssertionError("Error correctly raised")
+
+    communicator = HttpCommunicator(TestConsumer(), "GET", "/")
+    with pytest.raises(AssertionError) as excinfo:
+        await communicator.get_response(timeout=0.05)
+
+    assert str(excinfo.value) == "Error correctly raised"
+
+
 @pytest.mark.asyncio
 async def test_per_scope_consumers():
     """
diff --git a/tests/test_http.py b/tests/test_http.py
index dd4a003..aa1fbe5 100644
--- a/tests/test_http.py
+++ b/tests/test_http.py
@@ -4,7 +4,7 @@ import pytest
 
 from channels.consumer import AsyncConsumer
 from channels.db import database_sync_to_async
-from channels.sessions import SessionMiddlewareStack
+from channels.sessions import CookieMiddleware, SessionMiddlewareStack
 from channels.testing import HttpCommunicator
 
 
@@ -21,6 +21,15 @@ class SimpleHttpApp(AsyncConsumer):
         await self.send({"type": "http.response.body", "body": b"test response"})
 
 
+@pytest.mark.asyncio
+async def test_set_cookie():
+    message = {}
+    CookieMiddleware.set_cookie(message, "Testing-Key", "testing-value")
+    assert message == {
+        "headers": [(b"Set-Cookie", b"Testing-Key=testing-value; Path=/; SameSite=lax")]
+    }
+
+
 @pytest.mark.django_db(transaction=True)
 @pytest.mark.asyncio
 async def test_sessions():
diff --git a/tests/test_routing.py b/tests/test_routing.py
index 616c20e..b41f652 100644
--- a/tests/test_routing.py
+++ b/tests/test_routing.py
@@ -6,7 +6,6 @@ from channels.routing import ChannelNameRouter, ProtocolTypeRouter, URLRouter
 
 
 class MockApplication:
-
     call_args = None
 
     def __init__(self, return_value):