Repository: python-websockets/websockets

Issue:
Websockets 14 does not accept Transfer-Encoding header in error responses
After upgrading to websockets 14, websockets.connect is raising the exception `NotImplementedError: transfer codings aren't supported` in a scenario that previously raised InvalidStatusCode (503). I was expecting it to change to InvalidStatus, which would have been fine, but even if I catch the NotImplementedError, there is no way to extract an error code from it. The issue arises when my server, in response to the websocket upgrade request returns a 503 error, with `transfer-encoding: chunked` and then a short error message in the body. While it could be argued that this particular message could just use Content-Length rather than chunked encoding, this does seem like a regression on the part of websockets.

The entire back and forth looks something like this.
Request (sent with websockets 14.1):
```
GET /path HTTP/1.1
Host: 192.168.0.1:8888
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: bDKBNWhBF+86IyQBcT176A==
Sec-WebSocket-Version: 13
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits
Sec-WebSocket-Protocol: xyz
Authorization: Bearer 97eab12b-2766-46ca-9ec4-f65d8d26d64c
User-Agent: Python/3.12 websockets/14.1
```
Response:
```
HTTP/1.1 503 Service Unavailable
date: Fri, 15 Nov 2024 08:17:57 GMT
server: Cowboy
transfer-encoding: chunked

This did not go well
```

Discussion:
@aaugustin: This is happening because the legacy implementation didn't even attempt to read the HTTP response body. I added that functionality in the standard case but not in the chunked encoding case.

I believe that #1549 will wrap that error in an `InvalidMessage`, which is closer to what you want. However, it still doesn't give you the HTTP error code, because websockets parses the entire request first and only then starts the opening handshake logic.

I could make an argument for skipping the body rather than crashing in this case. Let me think about it.

@aaugustin: I forgot to mention — as a short term fix, you can use `websockets.legacy.client.connect` instead of `websockets.connect` and you buy five years. See https://websockets.readthedocs.io/en/latest/project/changelog.html and https://websockets.readthedocs.io/en/latest/howto/upgrade.html for context.@Jolmberg: Thanks! I guess I'll go with the legacy code for now. For the record, though, I don't really care about the message body in this case, and I don't mind altering my code, I just need some way to figure out the error code.@aaugustin: Yes, it's a legitimate request for improvement. The HTTP code is right there and it's reasonable to expect websockets to give it to you.@aaugustin: I took a stab at fixing it in #1568.

I wanted to test against a real life server rather than just my test suite but I couldn't find one in 15 minutes and I'm not really excited with the idea of installing Erlang and Cowboy locally (-:

Any chance you can test the branch before I merge the PR?@Jolmberg: Thanks for looking into it, I can confirm that the issue-1550 branch fixes the problem on my end!@aaugustin: Great, thank you for reporting this issue and testing the fix!

Solution patch:
diff --git a/docs/howto/upgrade.rst b/docs/howto/upgrade.rst
index 02d4c6f..db6bf11 100644
--- a/docs/howto/upgrade.rst
+++ b/docs/howto/upgrade.rst
@@ -93,8 +93,8 @@ Client APIs
 | ``websockets.client.unix_connect()``                         |br| | :func:`websockets.asyncio.client.unix_connect`      |
 | :func:`websockets.legacy.client.unix_connect`                     |                                                     |
 +-------------------------------------------------------------------+-----------------------------------------------------+
-| ``websockets.WebSocketClientProtocol``                       |br| | :class:`websockets.asyncio.client.ClientConnection` |
-| ``websockets.client.WebSocketClientProtocol``                |br| |                                                     |
+| ``websockets.WebSocketClientProtocol``                       |br| | ``websockets.ClientConnection`` *(since 14.2)* |br| |
+| ``websockets.client.WebSocketClientProtocol``                |br| | :class:`websockets.asyncio.client.ClientConnection` |
 | :class:`websockets.legacy.client.WebSocketClientProtocol`         |                                                     |
 +-------------------------------------------------------------------+-----------------------------------------------------+
 
@@ -112,12 +112,12 @@ Server APIs
 | ``websockets.server.unix_serve()``                           |br| | :func:`websockets.asyncio.server.unix_serve`        |
 | :func:`websockets.legacy.server.unix_serve`                       |                                                     |
 +-------------------------------------------------------------------+-----------------------------------------------------+
-| ``websockets.WebSocketServer``                               |br| | :class:`websockets.asyncio.server.Server`           |
-| ``websockets.server.WebSocketServer``                        |br| |                                                     |
+| ``websockets.WebSocketServer``                               |br| | ``websockets.Server`` *(since 14.2)*           |br| |
+| ``websockets.server.WebSocketServer``                        |br| | :class:`websockets.asyncio.server.Server`           |
 | :class:`websockets.legacy.server.WebSocketServer`                 |                                                     |
 +-------------------------------------------------------------------+-----------------------------------------------------+
-| ``websockets.WebSocketServerProtocol``                       |br| | :class:`websockets.asyncio.server.ServerConnection` |
-| ``websockets.server.WebSocketServerProtocol``                |br| |                                                     |
+| ``websockets.WebSocketServerProtocol``                       |br| | ``websockets.ServerConnection`` *(since 14.2)* |br| |
+| ``websockets.server.WebSocketServerProtocol``                |br| | :class:`websockets.asyncio.server.ServerConnection` |
 | :class:`websockets.legacy.server.WebSocketServerProtocol`         |                                                     |
 +-------------------------------------------------------------------+-----------------------------------------------------+
 | ``websockets.broadcast()`` *(before 14.0)*                   |br| | ``websockets.broadcast()`` *(since 14.0)*      |br| |
diff --git a/src/websockets/__init__.py b/src/websockets/__init__.py
index c278b21..c8df54e 100644
--- a/src/websockets/__init__.py
+++ b/src/websockets/__init__.py
@@ -10,11 +10,14 @@ __all__ = [
     # .asyncio.client
     "connect",
     "unix_connect",
+    "ClientConnection",
     # .asyncio.server
     "basic_auth",
     "broadcast",
     "serve",
     "unix_serve",
+    "ServerConnection",
+    "Server",
     # .client
     "ClientProtocol",
     # .datastructures
@@ -44,6 +47,18 @@ __all__ = [
     "ProtocolError",
     "SecurityError",
     "WebSocketException",
+    # .frames
+    "Close",
+    "CloseCode",
+    "Frame",
+    "Opcode",
+    # .http11
+    "Request",
+    "Response",
+    # .protocol
+    "Protocol",
+    "Side",
+    "State",
     # .server
     "ServerProtocol",
     # .typing
@@ -58,8 +73,15 @@ __all__ = [
 
 # When type checking, import non-deprecated aliases eagerly. Else, import on demand.
 if typing.TYPE_CHECKING:
-    from .asyncio.client import connect, unix_connect
-    from .asyncio.server import basic_auth, broadcast, serve, unix_serve
+    from .asyncio.client import ClientConnection, connect, unix_connect
+    from .asyncio.server import (
+        Server,
+        ServerConnection,
+        basic_auth,
+        broadcast,
+        serve,
+        unix_serve,
+    )
     from .client import ClientProtocol
     from .datastructures import Headers, HeadersLike, MultipleValuesError
     from .exceptions import (
@@ -86,6 +108,9 @@ if typing.TYPE_CHECKING:
         SecurityError,
         WebSocketException,
     )
+    from .frames import Close, CloseCode, Frame, Opcode
+    from .http11 import Request, Response
+    from .protocol import Protocol, Side, State
     from .server import ServerProtocol
     from .typing import (
         Data,
@@ -103,11 +128,14 @@ else:
             # .asyncio.client
             "connect": ".asyncio.client",
             "unix_connect": ".asyncio.client",
+            "ClientConnection": ".asyncio.client",
             # .asyncio.server
             "basic_auth": ".asyncio.server",
             "broadcast": ".asyncio.server",
             "serve": ".asyncio.server",
             "unix_serve": ".asyncio.server",
+            "ServerConnection": ".asyncio.server",
+            "Server": ".asyncio.server",
             # .client
             "ClientProtocol": ".client",
             # .datastructures
@@ -137,6 +165,18 @@ else:
             "ProtocolError": ".exceptions",
             "SecurityError": ".exceptions",
             "WebSocketException": ".exceptions",
+            # .frames
+            "Close": ".frames",
+            "CloseCode": ".frames",
+            "Frame": ".frames",
+            "Opcode": ".frames",
+            # .http11
+            "Request": ".http11",
+            "Response": ".http11",
+            # .protocol
+            "Protocol": ".protocol",
+            "Side": ".protocol",
+            "State": ".protocol",
             # .server
             "ServerProtocol": ".server",
             # .typing
diff --git a/src/websockets/client.py b/src/websockets/client.py
index 5ced05c..37e2a8b 100644
--- a/src/websockets/client.py
+++ b/src/websockets/client.py
@@ -333,7 +333,7 @@ class ClientProtocol(Protocol):
                 self.logger.debug("< HTTP/1.1 %d %s", code, phrase)
                 for key, value in response.headers.raw_items():
                     self.logger.debug("< %s: %s", key, value)
-                if response.body is not None:
+                if response.body:
                     self.logger.debug("< [body] (%d bytes)", len(response.body))
 
             try:
diff --git a/src/websockets/datastructures.py b/src/websockets/datastructures.py
index 77b6f86..3c5dcbe 100644
--- a/src/websockets/datastructures.py
+++ b/src/websockets/datastructures.py
@@ -4,7 +4,11 @@ from collections.abc import Iterable, Iterator, Mapping, MutableMapping
 from typing import Any, Protocol, Union
 
 
-__all__ = ["Headers", "HeadersLike", "MultipleValuesError"]
+__all__ = [
+    "Headers",
+    "HeadersLike",
+    "MultipleValuesError",
+]
 
 
 class MultipleValuesError(LookupError):
diff --git a/src/websockets/frames.py b/src/websockets/frames.py
index 7898c8a..ab0869d 100644
--- a/src/websockets/frames.py
+++ b/src/websockets/frames.py
@@ -28,6 +28,7 @@ __all__ = [
     "OP_PONG",
     "DATA_OPCODES",
     "CTRL_OPCODES",
+    "CloseCode",
     "Frame",
     "Close",
 ]
diff --git a/src/websockets/http11.py b/src/websockets/http11.py
index af542c7..396a43f 100644
--- a/src/websockets/http11.py
+++ b/src/websockets/http11.py
@@ -13,7 +13,12 @@ from .exceptions import SecurityError
 from .version import version as websockets_version
 
 
-__all__ = ["SERVER", "USER_AGENT", "Request", "Response"]
+__all__ = [
+    "SERVER",
+    "USER_AGENT",
+    "Request",
+    "Response",
+]
 
 
 PYTHON_VERSION = "{}.{}".format(*sys.version_info)
@@ -180,14 +185,14 @@ class Response:
         status_code: Response code.
         reason_phrase: Response reason.
         headers: Response headers.
-        body: Response body, if any.
+        body: Response body.
 
     """
 
     status_code: int
     reason_phrase: str
     headers: Headers
-    body: bytes | None = None
+    body: bytes = b""
 
     _exception: Exception | None = None
 
@@ -261,36 +266,9 @@ class Response:
 
         headers = yield from parse_headers(read_line)
 
-        # https://datatracker.ietf.org/doc/html/rfc7230#section-3.3.3
-
-        if "Transfer-Encoding" in headers:
-            raise NotImplementedError("transfer codings aren't supported")
-
-        # Since websockets only does GET requests (no HEAD, no CONNECT), all
-        # responses except 1xx, 204, and 304 include a message body.
-        if 100 <= status_code < 200 or status_code == 204 or status_code == 304:
-            body = None
-        else:
-            content_length: int | None
-            try:
-                # MultipleValuesError is sufficiently unlikely that we don't
-                # attempt to handle it. Instead we document that its parent
-                # class, LookupError, may be raised.
-                raw_content_length = headers["Content-Length"]
-            except KeyError:
-                content_length = None
-            else:
-                content_length = int(raw_content_length)
-
-            if content_length is None:
-                try:
-                    body = yield from read_to_eof(MAX_BODY_SIZE)
-                except RuntimeError:
-                    raise SecurityError(f"body too large: over {MAX_BODY_SIZE} bytes")
-            elif content_length > MAX_BODY_SIZE:
-                raise SecurityError(f"body too large: {content_length} bytes")
-            else:
-                body = yield from read_exact(content_length)
+        body = yield from read_body(
+            status_code, headers, read_line, read_exact, read_to_eof
+        )
 
         return cls(status_code, reason, headers, body)
 
@@ -303,11 +281,37 @@ class Response:
         # we can keep this simple.
         response = f"HTTP/1.1 {self.status_code} {self.reason_phrase}\r\n".encode()
         response += self.headers.serialize()
-        if self.body is not None:
-            response += self.body
+        response += self.body
         return response
 
 
+def parse_line(
+    read_line: Callable[[int], Generator[None, None, bytes]],
+) -> Generator[None, None, bytes]:
+    """
+    Parse a single line.
+
+    CRLF is stripped from the return value.
+
+    Args:
+        read_line: Generator-based coroutine that reads a LF-terminated line
+            or raises an exception if there isn't enough data.
+
+    Raises:
+        EOFError: If the connection is closed without a CRLF.
+        SecurityError: If the response exceeds a security limit.
+
+    """
+    try:
+        line = yield from read_line(MAX_LINE_LENGTH)
+    except RuntimeError:
+        raise SecurityError("line too long")
+    # Not mandatory but safe - https://datatracker.ietf.org/doc/html/rfc7230#section-3.5
+    if not line.endswith(b"\r\n"):
+        raise EOFError("line without CRLF")
+    return line[:-2]
+
+
 def parse_headers(
     read_line: Callable[[int], Generator[None, None, bytes]],
 ) -> Generator[None, None, Headers]:
@@ -359,28 +363,62 @@ def parse_headers(
     return headers
 
 
-def parse_line(
+def read_body(
+    status_code: int,
+    headers: Headers,
     read_line: Callable[[int], Generator[None, None, bytes]],
+    read_exact: Callable[[int], Generator[None, None, bytes]],
+    read_to_eof: Callable[[int], Generator[None, None, bytes]],
 ) -> Generator[None, None, bytes]:
-    """
-    Parse a single line.
-
-    CRLF is stripped from the return value.
-
-    Args:
-        read_line: Generator-based coroutine that reads a LF-terminated line
-            or raises an exception if there isn't enough data.
-
-    Raises:
-        EOFError: If the connection is closed without a CRLF.
-        SecurityError: If the response exceeds a security limit.
+    # https://datatracker.ietf.org/doc/html/rfc7230#section-3.3.3
+
+    # Since websockets only does GET requests (no HEAD, no CONNECT), all
+    # responses except 1xx, 204, and 304 include a message body.
+    if 100 <= status_code < 200 or status_code == 204 or status_code == 304:
+        return b""
+
+    # MultipleValuesError is sufficiently unlikely that we don't attempt to
+    # handle it when accessing headers. Instead we document that its parent
+    # class, LookupError, may be raised.
+    # Conversions from str to int are protected by sys.set_int_max_str_digits..
+
+    elif (coding := headers.get("Transfer-Encoding")) is not None:
+        if coding != "chunked":
+            raise NotImplementedError(f"transfer coding {coding} isn't supported")
+
+        body = b""
+        while True:
+            chunk_size_line = yield from parse_line(read_line)
+            raw_chunk_size = chunk_size_line.split(b";", 1)[0]
+            # Set a lower limit than default_max_str_digits; 1 EB is plenty.
+            if len(raw_chunk_size) > 15:
+                str_chunk_size = raw_chunk_size.decode(errors="backslashreplace")
+                raise SecurityError(f"chunk too large: 0x{str_chunk_size} bytes")
+            chunk_size = int(raw_chunk_size, 16)
+            if chunk_size == 0:
+                break
+            if len(body) + chunk_size > MAX_BODY_SIZE:
+                raise SecurityError(
+                    f"chunk too large: {chunk_size} bytes after {len(body)} bytes"
+                )
+            body += yield from read_exact(chunk_size)
+            if (yield from read_exact(2)) != b"\r\n":
+                raise ValueError("chunk without CRLF")
+        # Read the trailer.
+        yield from parse_headers(read_line)
+        return body
+
+    elif (raw_content_length := headers.get("Content-Length")) is not None:
+        # Set a lower limit than default_max_str_digits; 1 EiB is plenty.
+        if len(raw_content_length) > 18:
+            raise SecurityError(f"body too large: {raw_content_length} bytes")
+        content_length = int(raw_content_length)
+        if content_length > MAX_BODY_SIZE:
+            raise SecurityError(f"body too large: {content_length} bytes")
+        return (yield from read_exact(content_length))
 
-    """
-    try:
-        line = yield from read_line(MAX_LINE_LENGTH)
-    except RuntimeError:
-        raise SecurityError("line too long")
-    # Not mandatory but safe - https://datatracker.ietf.org/doc/html/rfc7230#section-3.5
-    if not line.endswith(b"\r\n"):
-        raise EOFError("line without CRLF")
-    return line[:-2]
+    else:
+        try:
+            return (yield from read_to_eof(MAX_BODY_SIZE))
+        except RuntimeError:
+            raise SecurityError(f"body too large: over {MAX_BODY_SIZE} bytes")
diff --git a/src/websockets/server.py b/src/websockets/server.py
index 1b663a1..fe3c65a 100644
--- a/src/websockets/server.py
+++ b/src/websockets/server.py
@@ -525,7 +525,7 @@ class ServerProtocol(Protocol):
             self.logger.debug("> HTTP/1.1 %d %s", code, phrase)
             for key, value in response.headers.raw_items():
                 self.logger.debug("> %s: %s", key, value)
-            if response.body is not None:
+            if response.body:
                 self.logger.debug("> [body] (%d bytes)", len(response.body))
 
         self.writes.append(response.serialize())


Test patch:
diff --git a/tests/test_client.py b/tests/test_client.py
index 1edbae5..9f3ab09 100644
--- a/tests/test_client.py
+++ b/tests/test_client.py
@@ -204,7 +204,7 @@ class ResponseTests(unittest.TestCase):
                 }
             ),
         )
-        self.assertIsNone(response.body)
+        self.assertEqual(response.body, b"")
         self.assertIsNone(client.handshake_exc)
 
     def test_receive_failed_response(self, _generate_key):
diff --git a/tests/test_exports.py b/tests/test_exports.py
index 93b0684..88e27e6 100644
--- a/tests/test_exports.py
+++ b/tests/test_exports.py
@@ -11,24 +11,22 @@ import websockets.typing
 import websockets.uri
 
 
-combined_exports = (
-    []
-    + websockets.asyncio.client.__all__
-    + websockets.asyncio.server.__all__
-    + websockets.client.__all__
-    + websockets.datastructures.__all__
-    + websockets.exceptions.__all__
-    + websockets.server.__all__
-    + websockets.typing.__all__
-)
-
-# These API are intentionally not re-exported by the top-level module.
-missing_reexports = [
-    # websockets.asyncio.client
-    "ClientConnection",
-    # websockets.asyncio.server
-    "ServerConnection",
-    "Server",
+combined_exports = [
+    name
+    for name in (
+        []
+        + websockets.asyncio.client.__all__
+        + websockets.asyncio.server.__all__
+        + websockets.client.__all__
+        + websockets.datastructures.__all__
+        + websockets.exceptions.__all__
+        + websockets.frames.__all__
+        + websockets.http11.__all__
+        + websockets.protocol.__all__
+        + websockets.server.__all__
+        + websockets.typing.__all__
+    )
+    if not name.isupper()  # filter out constants
 ]
 
 
@@ -36,7 +34,7 @@ class ExportsTests(unittest.TestCase):
     def test_top_level_module_reexports_submodule_exports(self):
         self.assertEqual(
             set(combined_exports),
-            set(websockets.__all__ + missing_reexports),
+            set(websockets.__all__),
         )
 
     def test_submodule_exports_are_globally_unique(self):
diff --git a/tests/test_http11.py b/tests/test_http11.py
index 1fbcb3b..bb0d27b 100644
--- a/tests/test_http11.py
+++ b/tests/test_http11.py
@@ -87,7 +87,7 @@ class RequestTests(GeneratorTestCase):
         )
 
     def test_parse_body_with_transfer_encoding(self):
-        self.reader.feed_data(b"GET / HTTP/1.1\r\nTransfer-Encoding: chunked\r\n\r\n")
+        self.reader.feed_data(b"GET / HTTP/1.1\r\nTransfer-Encoding: compress\r\n\r\n")
         with self.assertRaises(NotImplementedError) as raised:
             next(self.parse())
         self.assertEqual(
@@ -151,7 +151,7 @@ class ResponseTests(GeneratorTestCase):
         self.assertEqual(response.status_code, 101)
         self.assertEqual(response.reason_phrase, "Switching Protocols")
         self.assertEqual(response.headers["Upgrade"], "websocket")
-        self.assertIsNone(response.body)
+        self.assertEqual(response.body, b"")
 
     def test_parse_empty(self):
         self.reader.feed_eof()
@@ -215,14 +215,7 @@ class ResponseTests(GeneratorTestCase):
             "invalid HTTP header line: Oops",
         )
 
-    def test_parse_body_with_content_length(self):
-        self.reader.feed_data(
-            b"HTTP/1.1 200 OK\r\nContent-Length: 13\r\n\r\nHello world!\n"
-        )
-        response = self.assertGeneratorReturns(self.parse())
-        self.assertEqual(response.body, b"Hello world!\n")
-
-    def test_parse_body_without_content_length(self):
+    def test_parse_body(self):
         self.reader.feed_data(b"HTTP/1.1 200 OK\r\n\r\nHello world!\n")
         gen = self.parse()
         self.assertGeneratorRunning(gen)
@@ -230,7 +223,23 @@ class ResponseTests(GeneratorTestCase):
         response = self.assertGeneratorReturns(gen)
         self.assertEqual(response.body, b"Hello world!\n")
 
-    def test_parse_body_with_content_length_too_long(self):
+    def test_parse_body_too_large(self):
+        self.reader.feed_data(b"HTTP/1.1 200 OK\r\n\r\n" + b"a" * 1048577)
+        with self.assertRaises(SecurityError) as raised:
+            next(self.parse())
+        self.assertEqual(
+            str(raised.exception),
+            "body too large: over 1048576 bytes",
+        )
+
+    def test_parse_body_with_content_length(self):
+        self.reader.feed_data(
+            b"HTTP/1.1 200 OK\r\nContent-Length: 13\r\n\r\nHello world!\n"
+        )
+        response = self.assertGeneratorReturns(self.parse())
+        self.assertEqual(response.body, b"Hello world!\n")
+
+    def test_parse_body_with_content_length_and_body_too_large(self):
         self.reader.feed_data(b"HTTP/1.1 200 OK\r\nContent-Length: 1048577\r\n\r\n")
         with self.assertRaises(SecurityError) as raised:
             next(self.parse())
@@ -239,33 +248,79 @@ class ResponseTests(GeneratorTestCase):
             "body too large: 1048577 bytes",
         )
 
-    def test_parse_body_without_content_length_too_long(self):
-        self.reader.feed_data(b"HTTP/1.1 200 OK\r\n\r\n" + b"a" * 1048577)
+    def test_parse_body_with_content_length_and_body_way_too_large(self):
+        self.reader.feed_data(
+            b"HTTP/1.1 200 OK\r\nContent-Length: 1234567890123456789\r\n\r\n"
+        )
         with self.assertRaises(SecurityError) as raised:
             next(self.parse())
         self.assertEqual(
             str(raised.exception),
-            "body too large: over 1048576 bytes",
+            "body too large: 1234567890123456789 bytes",
         )
 
-    def test_parse_body_with_transfer_encoding(self):
-        self.reader.feed_data(b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n")
+    def test_parse_body_with_chunked_transfer_encoding(self):
+        self.reader.feed_data(
+            b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n"
+            b"6\r\nHello \r\n7\r\nworld!\n\r\n0\r\n\r\n"
+        )
+        response = self.assertGeneratorReturns(self.parse())
+        self.assertEqual(response.body, b"Hello world!\n")
+
+    def test_parse_body_with_chunked_transfer_encoding_and_chunk_without_crlf(self):
+        self.reader.feed_data(
+            b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n"
+            b"6\r\nHello 7\r\nworld!\n0\r\n"
+        )
+        with self.assertRaises(ValueError) as raised:
+            next(self.parse())
+        self.assertEqual(
+            str(raised.exception),
+            "chunk without CRLF",
+        )
+
+    def test_parse_body_with_chunked_transfer_encoding_and_chunk_too_large(self):
+        self.reader.feed_data(
+            b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n"
+            b"100000\r\n" + b"a" * 1048576 + b"\r\n1\r\na\r\n0\r\n\r\n"
+        )
+        with self.assertRaises(SecurityError) as raised:
+            next(self.parse())
+        self.assertEqual(
+            str(raised.exception),
+            "chunk too large: 1 bytes after 1048576 bytes",
+        )
+
+    def test_parse_body_with_chunked_transfer_encoding_and_chunk_way_too_large(self):
+        self.reader.feed_data(
+            b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n"
+            b"1234567890ABCDEF\r\n\r\n"
+        )
+        with self.assertRaises(SecurityError) as raised:
+            next(self.parse())
+        self.assertEqual(
+            str(raised.exception),
+            "chunk too large: 0x1234567890ABCDEF bytes",
+        )
+
+    def test_parse_body_with_unsupported_transfer_encoding(self):
+        self.reader.feed_data(b"HTTP/1.1 200 OK\r\nTransfer-Encoding: compress\r\n\r\n")
         with self.assertRaises(NotImplementedError) as raised:
             next(self.parse())
         self.assertEqual(
             str(raised.exception),
-            "transfer codings aren't supported",
+            "transfer coding compress isn't supported",
         )
 
     def test_parse_body_no_content(self):
         self.reader.feed_data(b"HTTP/1.1 204 No Content\r\n\r\n")
         response = self.assertGeneratorReturns(self.parse())
-        self.assertIsNone(response.body)
+        self.assertEqual(response.body, b"")
 
     def test_parse_body_not_modified(self):
         self.reader.feed_data(b"HTTP/1.1 304 Not Modified\r\n\r\n")
         response = self.assertGeneratorReturns(self.parse())
-        self.assertIsNone(response.body)
+        self.assertEqual(response.body, b"")
 
     def test_serialize(self):
         # Example from the protocol overview in RFC 6455
diff --git a/tests/test_server.py b/tests/test_server.py
index 5efeca2..69f5556 100644
--- a/tests/test_server.py
+++ b/tests/test_server.py
@@ -281,7 +281,7 @@ class ResponseTests(unittest.TestCase):
                 }
             ),
         )
-        self.assertIsNone(response.body)
+        self.assertEqual(response.body, b"")
 
     @patch("email.utils.formatdate", return_value=DATE)
     def test_reject_response(self, _formatdate):