Repository: aaugustin/websockets
Pull request #998 (2021-06-16 20:52:47)

Add details to ConnectionClosed exceptions.
Still TODO

- [x] Add API documentation of the new ConnectionClosed attributes


Patch:
diff --git a/docs/howto/django.rst b/docs/howto/django.rst
index 001d1cb..c87c382 100644
--- a/docs/howto/django.rst
+++ b/docs/howto/django.rst
@@ -146,7 +146,7 @@ prompt:
     Connected to ws://localhost:8888/
     > <your token>
     < Hello <your username>!
-    Connection closed: code = 1000 (OK), no reason.
+    Connection closed: 1000 (OK).
 
 It works!
 
@@ -158,7 +158,7 @@ closes the connection:
     $ python -m websockets ws://localhost:8888/
     Connected to ws://localhost:8888.
     > not a token
-    Connection closed: code = 1011 (unexpected error), reason = authentication failed.
+    Connection closed: 1011 (unexpected error) authentication failed.
 
 You can also test from a browser by generating a new token and running the
 following code in the JavaScript console of the browser:
diff --git a/docs/howto/faq.rst b/docs/howto/faq.rst
index ce3704a..a4ad846 100644
--- a/docs/howto/faq.rst
+++ b/docs/howto/faq.rst
@@ -244,8 +244,8 @@ See `issue 867`_.
 Both sides
 ----------
 
-What does ``ConnectionClosedError: code = 1006`` mean?
-......................................................
+What does ``ConnectionClosedError: no close frame received or sent`` mean?
+..........................................................................
 
 If you're seeing this traceback in the logs of a server:
 
@@ -260,7 +260,7 @@ If you're seeing this traceback in the logs of a server:
 
     Traceback (most recent call last):
       ...
-    websockets.exceptions.ConnectionClosedError: code = 1006 (connection closed abnormally [internal]), no reason
+    websockets.exceptions.ConnectionClosedError: no close frame received or sent
 
 or if a client crashes with this traceback:
 
@@ -274,10 +274,11 @@ or if a client crashes with this traceback:
 
     Traceback (most recent call last):
       ...
-    websockets.exceptions.ConnectionClosedError: code = 1006 (connection closed abnormally [internal]), no reason
+    websockets.exceptions.ConnectionClosedError: no close frame received or sent
 
 it means that the TCP connection was lost. As a consequence, the WebSocket
-connection was closed without receiving a close frame, which is abnormal.
+connection was closed without receiving and sending a close frame, which is
+abnormal.
 
 You can catch and handle :exc:`~exceptions.ConnectionClosed` to prevent it
 from being logged.
diff --git a/docs/howto/haproxy.rst b/docs/howto/haproxy.rst
index d520d27..0ecb46a 100644
--- a/docs/howto/haproxy.rst
+++ b/docs/howto/haproxy.rst
@@ -58,4 +58,4 @@ You can confirm that HAProxy proxies connections properly:
     Connected to ws://localhost:8080/.
     > Hello!
     < Hello!
-    Connection closed: code = 1000 (OK), no reason.
+    Connection closed: 1000 (OK).
diff --git a/docs/howto/heroku.rst b/docs/howto/heroku.rst
index 92dee5f..b728106 100644
--- a/docs/howto/heroku.rst
+++ b/docs/howto/heroku.rst
@@ -142,7 +142,7 @@ then press Ctrl-D to terminate the connection:
 
     > Hello!
     < Hello!
-    Connection closed: code = 1000 (OK), no reason.
+    Connection closed: 1000 (OK).
 
 You can also confirm that your application shuts down gracefully. Connect an
 interactive client again — remember to replace ``websockets-echo`` with your app:
@@ -167,7 +167,7 @@ away).
 
     $ python -m websockets wss://websockets-echo.herokuapp.com/
     Connected to wss://websockets-echo.herokuapp.com/.
-    Connection closed: code = 1001 (going away), no reason.
+    Connection closed: 1001 (going away).
 
 If graceful shutdown wasn't working, the server wouldn't perform a closing
 handshake and the connection would be closed with code 1006 (connection closed
diff --git a/docs/howto/kubernetes.rst b/docs/howto/kubernetes.rst
index ef5c963..0e77aea 100644
--- a/docs/howto/kubernetes.rst
+++ b/docs/howto/kubernetes.rst
@@ -73,7 +73,7 @@ shut down gracefully:
     Connected to ws://localhost:32080/.
     > Hey there!
     < Hey there!
-    Connection closed: code = 1001 (going away), no reason.
+    Connection closed: 1001 (going away).
 
 If it didn't, you'd get code 1006 (connection closed abnormally).
 
@@ -119,7 +119,7 @@ You can connect to the service — press Ctrl-D to exit:
 
     $ python -m websockets ws://localhost:32080/
     Connected to ws://localhost:32080/.
-    Connection closed: code = 1000 (OK), no reason.
+    Connection closed: 1000 (OK).
 
 Validate deployment
 -------------------
diff --git a/docs/howto/nginx.rst b/docs/howto/nginx.rst
index cb4dc83..e20f820 100644
--- a/docs/howto/nginx.rst
+++ b/docs/howto/nginx.rst
@@ -81,4 +81,4 @@ You can confirm that nginx proxies connections properly:
     Connected to ws://localhost:8080/.
     > Hello!
     < Hello!
-    Connection closed: code = 1000 (OK), no reason.
+    Connection closed: 1000 (OK).
diff --git a/docs/howto/supervisor.rst b/docs/howto/supervisor.rst
index 6d679ca..0c07aeb 100644
--- a/docs/howto/supervisor.rst
+++ b/docs/howto/supervisor.rst
@@ -68,7 +68,7 @@ press Ctrl-D to exit:
     Connected to ws://localhost:8080/.
     > Hello!
     < Hello!
-    Connection closed: code = 1000 (OK), no reason.
+    Connection closed: 1000 (OK).
 
 Look at the pid of an instance of the app in the logs and terminate it:
 
@@ -117,7 +117,7 @@ And you can see that the connection to the app was closed gracefully:
 
     $ python -m websockets ws://localhost:8080/
     Connected to ws://localhost:8080/.
-    Connection closed: code = 1001 (going away), no reason.
+    Connection closed: 1001 (going away).
 
 In this example, we've been sharing the same virtualenv for supervisor and
 websockets.
diff --git a/docs/project/changelog.rst b/docs/project/changelog.rst
index c8553d1..cf38c61 100644
--- a/docs/project/changelog.rst
+++ b/docs/project/changelog.rst
@@ -49,6 +49,13 @@ They may change at any time.
     This reflects a decision made in Python 3.8. See the release notes of
     Python 3.10 for details.
 
+.. note::
+
+    **Version 10.0 changes parameters of** ``ConnectionClosed.__init__`` **.**
+
+    If you raise :exc:`~exceptions.ConnectionClosed` or a subclass — rather
+    than catch them when websockets raises them — you must change your code.
+
 * Added compatibility with Python 3.10.
 
 * Added :func:`~websockets.broadcast` to send a message to many clients.
@@ -60,6 +67,8 @@ They may change at any time.
 
 * Improved logging.
 
+* Provided additional information in :exc:`ConnectionClosed` exceptions.
+
 * Optimized default compression settings to reduce memory usage.
 
 * Made it easier to customize authentication with
diff --git a/docs/reference/client.rst b/docs/reference/client.rst
index c7c738c..84f66a1 100644
--- a/docs/reference/client.rst
+++ b/docs/reference/client.rst
@@ -55,17 +55,9 @@ Client
 
             Available once the connection is open.
 
-        .. attribute:: close_code
+        .. autoattribute:: close_code
 
-            WebSocket close code.
-
-            Available once the connection is closed.
-
-        .. attribute:: close_reason
-
-            WebSocket close reason.
-
-            Available once the connection is closed.
+        .. autoattribute:: close_reason
 
         .. automethod:: recv
 
diff --git a/docs/reference/server.rst b/docs/reference/server.rst
index 2d54eca..667c0b9 100644
--- a/docs/reference/server.rst
+++ b/docs/reference/server.rst
@@ -65,17 +65,9 @@ Server
 
             Available once the connection is open.
 
-        .. attribute:: close_code
+        .. autoattribute:: close_code
 
-            WebSocket close code.
-
-            Available once the connection is closed.
-
-        .. attribute:: close_reason
-
-            WebSocket close reason.
-
-            Available once the connection is closed.
+        .. autoattribute:: close_reason
 
         .. automethod:: process_request
 
diff --git a/src/websockets/__main__.py b/src/websockets/__main__.py
index dae165c..785d2c3 100644
--- a/src/websockets/__main__.py
+++ b/src/websockets/__main__.py
@@ -9,7 +9,7 @@ import threading
 from typing import Any, Set
 
 from .exceptions import ConnectionClosed
-from .frames import format_close
+from .frames import Close
 from .legacy.client import connect
 
 
@@ -143,7 +143,8 @@ async def run_client(
 
     finally:
         await websocket.close()
-        close_status = format_close(websocket.close_code, websocket.close_reason)
+        assert websocket.close_code is not None and websocket.close_reason is not None
+        close_status = Close(websocket.close_code, websocket.close_reason)
 
         print_over_input(f"Connection closed: {close_status}.")
 
diff --git a/src/websockets/connection.py b/src/websockets/connection.py
index 57d3a22..d682226 100644
--- a/src/websockets/connection.py
+++ b/src/websockets/connection.py
@@ -14,9 +14,8 @@ from .frames import (
     OP_PING,
     OP_PONG,
     OP_TEXT,
+    Close,
     Frame,
-    parse_close,
-    serialize_close,
 )
 from .http11 import Request, Response
 from .streams import StreamReader
@@ -103,15 +102,10 @@ class Connection:
         self.extensions: List[Extension] = []
         self.subprotocol: Optional[Subprotocol] = None
 
-        # Connection state isn't enough to tell if a close frame was received:
-        # when this side closes the connection, state is CLOSING as soon as a
-        # close frame is sent, before a close frame is received.
-        self.close_frame_received = False
-
-        # Close code and reason. Set when receiving a close frame or when the
-        # TCP connection drops.
-        self.close_code: int
-        self.close_reason: str
+        # Close code and reason, set when a close frame is sent or received.
+        self.close_rcvd: Optional[Close] = None
+        self.close_sent: Optional[Close] = None
+        self.close_rcvd_then_sent: Optional[bool] = None
 
         # Track if send_eof() was called.
         self.eof_sent = False
@@ -129,6 +123,36 @@ class Connection:
             self.logger.debug("= connection is %s", state.name)
         self.state = state
 
+    @property
+    def close_code(self) -> Optional[int]:
+        """
+        WebSocket close code received in a close frame.
+
+        Available once the connection is closed.
+
+        """
+        if self.state is not State.CLOSED:
+            return None
+        elif self.close_rcvd is None:
+            return 1006
+        else:
+            return self.close_rcvd.code
+
+    @property
+    def close_reason(self) -> Optional[str]:
+        """
+        WebSocket close reason received in a close frame.
+
+        Available once the connection is closed.
+
+        """
+        if self.state is not State.CLOSED:
+            return None
+        elif self.close_rcvd is None:
+            return ""
+        else:
+            return self.close_rcvd.reason
+
     # Public APIs for receiving data.
 
     def receive_data(self, data: bytes) -> None:
@@ -200,10 +224,13 @@ class Connection:
         if code is None:
             if reason != "":
                 raise ValueError("cannot send a reason without a code")
+            close = Close(1005, "")
             data = b""
         else:
-            data = serialize_close(code, reason)
+            close = Close(code, reason)
+            data = close.serialize()
         self.send_frame(Frame(OP_CLOSE, data))
+        self.close_sent = close
         # send_frame() guarantees that self.state is OPEN at this point.
         # 7.1.3. The WebSocket Closing Handshake is Started
         self.set_state(CLOSING)
@@ -259,7 +286,9 @@ class Connection:
         # sent if it's CLOSING), except when failing the connection because of
         # an error reading from or writing to the network.
         if code != 1006 and self.state is OPEN:
-            self.send_frame(Frame(OP_CLOSE, serialize_close(code, reason)))
+            close = Close(code, reason)
+            self.send_frame(Frame(OP_CLOSE, close.serialize()))
+            self.close_sent = close
             self.set_state(CLOSING)
         if not self.eof_sent:
             self.send_eof()
@@ -305,7 +334,7 @@ class Connection:
             if eof:
                 if self.debug:
                     self.logger.debug("< EOF")
-                if self.close_frame_received:
+                if self.close_rcvd is not None:
                     if not self.eof_sent:
                         self.send_eof()
                     yield
@@ -339,7 +368,7 @@ class Connection:
             if frame.opcode is OP_TEXT or frame.opcode is OP_BINARY:
                 # 5.5.1 Close: "The application MUST NOT send any more data
                 # frames after sending a Close frame."
-                if self.close_frame_received:
+                if self.close_rcvd is not None:
                     raise ProtocolError("data frame after close frame")
 
                 if self.cur_size is not None:
@@ -352,7 +381,7 @@ class Connection:
             elif frame.opcode is OP_CONT:
                 # 5.5.1 Close: "The application MUST NOT send any more data
                 # frames after sending a Close frame."
-                if self.close_frame_received:
+                if self.close_rcvd is not None:
                     raise ProtocolError("data frame after close frame")
 
                 if self.cur_size is None:
@@ -366,7 +395,7 @@ class Connection:
                 # 5.5.2. Ping: "Upon receipt of a Ping frame, an endpoint MUST
                 # send a Pong frame in response, unless it already received a
                 # Close frame."
-                if not self.close_frame_received:
+                if self.close_rcvd is None:
                     pong_frame = Frame(OP_PONG, frame.data)
                     self.send_frame(pong_frame)
 
@@ -376,10 +405,12 @@ class Connection:
                 pass
 
             elif frame.opcode is OP_CLOSE:
-                self.close_frame_received = True
                 # 7.1.5.  The WebSocket Connection Close Code
                 # 7.1.6.  The WebSocket Connection Close Reason
-                self.close_code, self.close_reason = parse_close(frame.data)
+                self.close_rcvd = Close.parse(frame.data)
+                if self.state is CLOSING:
+                    assert self.close_sent is not None
+                    self.close_rcvd_then_sent = False
 
                 if self.cur_size is not None:
                     raise ProtocolError("incomplete fragmented message")
@@ -388,12 +419,15 @@ class Connection:
                 # Close frame in response. (When sending a Close frame in
                 # response, the endpoint typically echos the status code it
                 # received.)"
+
                 if self.state is OPEN:
                     # Echo the original data instead of re-serializing it with
-                    # serialize_close() because that fails when the close frame
-                    # is empty and parse_close() synthetizes a 1005 close code.
+                    # Close.serialize() because that fails when the close frame
+                    # is empty and Close.parse() synthetizes a 1005 close code.
                     # The rest is identical to send_close().
                     self.send_frame(Frame(OP_CLOSE, frame.data))
+                    self.close_sent = self.close_rcvd
+                    self.close_rcvd_then_sent = True
                     self.set_state(CLOSING)
                     if self.side is SERVER:
                         self.send_eof()
diff --git a/src/websockets/exceptions.py b/src/websockets/exceptions.py
index f110322..b346248 100644
--- a/src/websockets/exceptions.py
+++ b/src/websockets/exceptions.py
@@ -76,42 +76,78 @@ class ConnectionClosed(WebSocketException):
     """
     Raised when trying to interact with a closed connection.
 
-    Provides the connection close code and reason in its ``code`` and
-    ``reason`` attributes respectively.
+    If a close frame was received, its code and reason are available in the
+    ``rcvd.code`` and ``rcvd.reason`` attributes. Else, the ``rcvd``
+    attribute is ``None``.
+
+    Likewise, if a close frame was sent, its code and reason are available in
+    the ``sent.code`` and ``sent.reason`` attributes. Else, the ``sent``
+    attribute is ``None``.
+
+    If close frames were received and sent, the ``rcvd_then_sent`` attribute
+    tells in which order this happened, from the perspective of this side of
+    the connection.
 
     """
 
-    def __init__(self, code: int, reason: str) -> None:
-        self.code = code
-        self.reason = reason
-        super().__init__(frames.format_close(code, reason))
+    def __init__(
+        self,
+        rcvd: Optional[frames.Close],
+        sent: Optional[frames.Close],
+        rcvd_then_sent: Optional[bool] = None,
+    ) -> None:
+        self.rcvd = rcvd
+        self.sent = sent
+        self.rcvd_then_sent = rcvd_then_sent
+
+    def __str__(self) -> str:
+        if self.rcvd is None:
+            if self.sent is None:
+                assert self.rcvd_then_sent is None
+                return "no close frame received or sent"
+            else:
+                assert self.rcvd_then_sent is None
+                return f"sent {self.sent}; no close frame received"
+        else:
+            if self.sent is None:
+                assert self.rcvd_then_sent is None
+                return f"received {self.rcvd}; no close frame sent"
+            else:
+                assert self.rcvd_then_sent is not None
+                if self.rcvd_then_sent:
+                    return f"received {self.rcvd}; then sent {self.sent}"
+                else:
+                    return f"sent {self.sent}; then received {self.rcvd}"
+
+    # code and reason attributes are provided for backwards-compatibility
+
+    @property
+    def code(self) -> int:
+        return 1006 if self.rcvd is None else self.rcvd.code
+
+    @property
+    def reason(self) -> str:
+        return "" if self.rcvd is None else self.rcvd.reason
 
 
 class ConnectionClosedError(ConnectionClosed):
     """
     Like :exc:`ConnectionClosed`, when the connection terminated with an error.
 
-    This means the close code is different from 1000 (OK) and 1001 (going away).
+    A close code other than 1000 (OK) or 1001 (going away) was received or
+    sent, or the closing handshake didn't complete properly.
 
     """
 
-    def __init__(self, code: int, reason: str) -> None:
-        assert code != 1000 and code != 1001
-        super().__init__(code, reason)
-
 
 class ConnectionClosedOK(ConnectionClosed):
     """
     Like :exc:`ConnectionClosed`, when the connection terminated properly.
 
-    This means the close code is 1000 (OK) or 1001 (going away).
+    A close code 1000 (OK) or 1001 (going away) was received and sent.
 
     """
 
-    def __init__(self, code: int, reason: str) -> None:
-        assert code == 1000 or code == 1001
-        super().__init__(code, reason)
-
 
 class InvalidHandshake(WebSocketException):
     """
@@ -145,13 +181,14 @@ class InvalidHeader(InvalidHandshake):
     def __init__(self, name: str, value: Optional[str] = None) -> None:
         self.name = name
         self.value = value
-        if value is None:
-            message = f"missing {name} header"
-        elif value == "":
-            message = f"empty {name} header"
+
+    def __str__(self) -> str:
+        if self.value is None:
+            return f"missing {self.name} header"
+        elif self.value == "":
+            return f"empty {self.name} header"
         else:
-            message = f"invalid {name} header: {value}"
-        super().__init__(message)
+            return f"invalid {self.name} header: {self.value}"
 
 
 class InvalidHeaderFormat(InvalidHeader):
@@ -163,9 +200,7 @@ class InvalidHeaderFormat(InvalidHeader):
     """
 
     def __init__(self, name: str, error: str, header: str, pos: int) -> None:
-        self.name = name
-        error = f"{error} at {pos} in {header}"
-        super().__init__(name, error)
+        super().__init__(name, f"{error} at {pos} in {header}")
 
 
 class InvalidHeaderValue(InvalidHeader):
@@ -202,8 +237,12 @@ class InvalidStatus(InvalidHandshake):
 
     def __init__(self, response: http11.Response) -> None:
         self.response = response
-        message = f"server rejected WebSocket connection: HTTP {response.status_code:d}"
-        super().__init__(message)
+
+    def __str__(self) -> str:
+        return (
+            "server rejected WebSocket connection: "
+            f"HTTP {self.response.status_code:d}"
+        )
 
 
 class InvalidStatusCode(InvalidHandshake):
@@ -218,8 +257,9 @@ class InvalidStatusCode(InvalidHandshake):
     def __init__(self, status_code: int, headers: datastructures.Headers) -> None:
         self.status_code = status_code
         self.headers = headers
-        message = f"server rejected WebSocket connection: HTTP {status_code}"
-        super().__init__(message)
+
+    def __str__(self) -> str:
+        return f"server rejected WebSocket connection: HTTP {self.status_code}"
 
 
 class NegotiationError(InvalidHandshake):
@@ -237,8 +277,9 @@ class DuplicateParameter(NegotiationError):
 
     def __init__(self, name: str) -> None:
         self.name = name
-        message = f"duplicate parameter: {name}"
-        super().__init__(message)
+
+    def __str__(self) -> str:
+        return f"duplicate parameter: {self.name}"
 
 
 class InvalidParameterName(NegotiationError):
@@ -249,8 +290,9 @@ class InvalidParameterName(NegotiationError):
 
     def __init__(self, name: str) -> None:
         self.name = name
-        message = f"invalid parameter name: {name}"
-        super().__init__(message)
+
+    def __str__(self) -> str:
+        return f"invalid parameter name: {self.name}"
 
 
 class InvalidParameterValue(NegotiationError):
@@ -262,13 +304,14 @@ class InvalidParameterValue(NegotiationError):
     def __init__(self, name: str, value: Optional[str]) -> None:
         self.name = name
         self.value = value
-        if value is None:
-            message = f"missing value for parameter {name}"
-        elif value == "":
-            message = f"empty value for parameter {name}"
+
+    def __str__(self) -> str:
+        if self.value is None:
+            return f"missing value for parameter {self.name}"
+        elif self.value == "":
+            return f"empty value for parameter {self.name}"
         else:
-            message = f"invalid value for parameter {name}: {value}"
-        super().__init__(message)
+            return f"invalid value for parameter {self.name}: {self.value}"
 
 
 class AbortHandshake(InvalidHandshake):
@@ -290,8 +333,13 @@ class AbortHandshake(InvalidHandshake):
         self.status = status
         self.headers = datastructures.Headers(headers)
         self.body = body
-        message = f"HTTP {status:d}, {len(self.headers)} headers, {len(body)} bytes"
-        super().__init__(message)
+
+    def __str__(self) -> str:
+        return (
+            f"HTTP {self.status:d}, "
+            f"{len(self.headers)} headers, "
+            f"{len(self.body)} bytes"
+        )
 
 
 class RedirectHandshake(InvalidHandshake):
@@ -328,8 +376,9 @@ class InvalidURI(WebSocketException):
 
     def __init__(self, uri: str) -> None:
         self.uri = uri
-        message = "{} isn't a valid URI".format(uri)
-        super().__init__(message)
+
+    def __str__(self) -> str:
+        return f"{self.uri} isn't a valid URI"
 
 
 class PayloadTooBig(WebSocketException):
diff --git a/src/websockets/frames.py b/src/websockets/frames.py
index 510fc61..4c57386 100644
--- a/src/websockets/frames.py
+++ b/src/websockets/frames.py
@@ -35,8 +35,7 @@ __all__ = [
     "Frame",
     "prepare_data",
     "prepare_ctrl",
-    "parse_close",
-    "serialize_close",
+    "Close",
 ]
 
 
@@ -94,18 +93,20 @@ EXTERNAL_CLOSE_CODES = {
     1014,
 }
 
+OK_CLOSE_CODES = {1000, 1001}
+
 
 @dataclasses.dataclass
 class Frame:
     """
     WebSocket frame.
 
+    :param int opcode: opcode
+    :param bytes data: payload data
     :param bool fin: FIN bit
     :param bool rsv1: RSV1 bit
     :param bool rsv2: RSV2 bit
     :param bool rsv3: RSV3 bit
-    :param int opcode: opcode
-    :param bytes data: payload data
 
     Only these fields are needed. The MASK bit, payload length and masking-key
     are handled on the fly by :meth:`parse` and :meth:`serialize`.
@@ -140,8 +141,7 @@ class Frame:
                 binary = binary[:16] + b"\x00\x00" + binary[-8:]
             data = " ".join(f"{byte:02x}" for byte in binary)
         elif self.opcode is OP_CLOSE:
-            code, reason = parse_close(self.data)
-            data = format_close(code, reason)
+            data = str(Close.parse(self.data))
         elif self.data:
             # We don't know if a Continuation frame contains text or binary.
             # Ping and Pong frames could contain UTF-8. Attempt to decode as
@@ -362,68 +362,71 @@ def prepare_ctrl(data: Data) -> bytes:
         raise TypeError("data must be bytes-like or str")
 
 
-def parse_close(data: bytes) -> Tuple[int, str]:
+@dataclasses.dataclass
+class Close:
     """
-    Parse the payload from a close frame.
-
-    Return ``(code, reason)``.
-
-    :raises ~websockets.exceptions.ProtocolError: if data is ill-formed
-    :raises UnicodeDecodeError: if the reason isn't valid UTF-8
+    WebSocket close code and reason.
 
     """
-    length = len(data)
-    if length >= 2:
-        (code,) = struct.unpack("!H", data[:2])
-        check_close(code)
-        reason = data[2:].decode("utf-8")
-        return code, reason
-    elif length == 0:
-        return 1005, ""
-    else:
-        assert length == 1
-        raise exceptions.ProtocolError("close frame too short")
 
+    code: int
+    reason: str
 
-def serialize_close(code: int, reason: str) -> bytes:
-    """
-    Serialize the payload for a close frame.
+    def __str__(self) -> str:
+        """
+        Return a human-readable represention of a close code and reason.
 
-    This is the reverse of :func:`parse_close`.
+        """
+        if 3000 <= self.code < 4000:
+            explanation = "registered"
+        elif 4000 <= self.code < 5000:
+            explanation = "private use"
+        else:
+            explanation = CLOSE_CODES.get(self.code, "unknown")
+        result = f"{self.code} ({explanation})"
 
-    """
-    check_close(code)
-    return struct.pack("!H", code) + reason.encode("utf-8")
+        if self.reason:
+            result = f"{result} {self.reason}"
 
+        return result
 
-def check_close(code: int) -> None:
-    """
-    Check that the close code has an acceptable value for a close frame.
+    @classmethod
+    def parse(cls, data: bytes) -> Close:
+        """
+        Parse the payload of a close frame.
 
-    :raises ~websockets.exceptions.ProtocolError: if the close code
-        is invalid
+        :raises ~websockets.exceptions.ProtocolError: if data is ill-formed
+        :raises UnicodeDecodeError: if the reason isn't valid UTF-8
 
-    """
-    if not (code in EXTERNAL_CLOSE_CODES or 3000 <= code < 5000):
-        raise exceptions.ProtocolError("invalid status code")
+        """
+        if len(data) >= 2:
+            (code,) = struct.unpack("!H", data[:2])
+            reason = data[2:].decode("utf-8")
+            close = cls(code, reason)
+            close.check()
+            return close
+        elif len(data) == 0:
+            return cls(1005, "")
+        else:
+            raise exceptions.ProtocolError("close frame too short")
 
+    def serialize(self) -> bytes:
+        """
+        Serialize the payload for a close frame.
 
-def format_close(code: int, reason: str) -> str:
-    """
-    Display a human-readable version of the close code and reason.
+        This is the reverse of :meth:`parse`.
 
-    """
-    if 3000 <= code < 4000:
-        explanation = "registered"
-    elif 4000 <= code < 5000:
-        explanation = "private use"
-    else:
-        explanation = CLOSE_CODES.get(code, "unknown")
-    result = f"code = {code} ({explanation}), "
+        """
+        self.check()
+        return struct.pack("!H", self.code) + self.reason.encode("utf-8")
 
-    if reason:
-        result += f"reason = {reason}"
-    else:
-        result += "no reason"
+    def check(self) -> None:
+        """
+        Check that the close code has a valid value for a close frame.
 
-    return result
+        :raises ~websockets.exceptions.ProtocolError: if the close code
+            is invalid
+
+        """
+        if not (self.code in EXTERNAL_CLOSE_CODES or 3000 <= self.code < 5000):
+            raise exceptions.ProtocolError("invalid status code")
diff --git a/src/websockets/legacy/framing.py b/src/websockets/legacy/framing.py
index 12e0069..c8ae486 100644
--- a/src/websockets/legacy/framing.py
+++ b/src/websockets/legacy/framing.py
@@ -12,8 +12,9 @@ See `section 5 of RFC 6455`_.
 
 from __future__ import annotations
 
+import dataclasses
 import struct
-from typing import Any, Awaitable, Callable, NamedTuple, Optional, Sequence
+from typing import Any, Awaitable, Callable, NamedTuple, Optional, Sequence, Tuple
 
 from .. import extensions, frames
 from ..exceptions import PayloadTooBig, ProtocolError
@@ -159,7 +160,28 @@ class Frame(NamedTuple):
 
 
 # Backwards compatibility with previously documented public APIs
-from ..frames import parse_close  # noqa
-from ..frames import prepare_data  # noqa
-from ..frames import serialize_close  # noqa
-from ..frames import prepare_ctrl as encode_data  # noqa
+
+from ..frames import Close, prepare_ctrl as encode_data, prepare_data  # noqa
+
+
+def parse_close(data: bytes) -> Tuple[int, str]:
+    """
+    Parse the payload from a close frame.
+
+    Return ``(code, reason)``.
+
+    :raises ~websockets.exceptions.ProtocolError: if data is ill-formed
+    :raises UnicodeDecodeError: if the reason isn't valid UTF-8
+
+    """
+    return dataclasses.astuple(Close.parse(data))  # type: ignore
+
+
+def serialize_close(code: int, reason: str) -> bytes:
+    """
+    Serialize the payload for a close frame.
+
+    This is the reverse of :func:`parse_close`.
+
+    """
+    return Close(code, reason).serialize()
diff --git a/src/websockets/legacy/protocol.py b/src/websockets/legacy/protocol.py
index 8a95577..badd1e0 100644
--- a/src/websockets/legacy/protocol.py
+++ b/src/websockets/legacy/protocol.py
@@ -44,17 +44,17 @@ from ..exceptions import (
 )
 from ..extensions import Extension
 from ..frames import (
+    OK_CLOSE_CODES,
     OP_BINARY,
     OP_CLOSE,
     OP_CONT,
     OP_PING,
     OP_PONG,
     OP_TEXT,
+    Close,
     Opcode,
-    parse_close,
     prepare_ctrl,
     prepare_data,
-    serialize_close,
 )
 from ..typing import Data, LoggerLike, Subprotocol
 from .compatibility import loop_if_py_lt_38
@@ -182,10 +182,10 @@ class WebSocketCommonProtocol(asyncio.Protocol):
         self.extensions: List[Extension] = []
         self.subprotocol: Optional[Subprotocol] = None
 
-        # The close code and reason are set when receiving a close frame or
-        # losing the TCP connection.
-        self.close_code: int
-        self.close_reason: str
+        # Close code and reason, set when a close frame is sent or received.
+        self.close_rcvd: Optional[Close] = None
+        self.close_sent: Optional[Close] = None
+        self.close_rcvd_then_sent: Optional[bool] = None
 
         # Completed when the connection state becomes CLOSED. Translates the
         # :meth:`connection_lost` callback to a :class:`~asyncio.Future`
@@ -340,6 +340,36 @@ class WebSocketCommonProtocol(asyncio.Protocol):
         """
         return self.state is State.CLOSED
 
+    @property
+    def close_code(self) -> Optional[int]:
+        """
+        WebSocket close code received in a close frame.
+
+        Available once the connection is closed.
+
+        """
+        if self.state is not State.CLOSED:
+            return None
+        elif self.close_rcvd is None:
+            return 1006
+        else:
+            return self.close_rcvd.code
+
+    @property
+    def close_reason(self) -> Optional[str]:
+        """
+        WebSocket close reason received in a close frame.
+
+        Available once the connection is closed.
+
+        """
+        if self.state is not State.CLOSED:
+            return None
+        elif self.close_rcvd is None:
+            return ""
+        else:
+            return self.close_rcvd.reason
+
     async def wait_closed(self) -> None:
         """
         Wait until the connection is closed.
@@ -609,7 +639,7 @@ class WebSocketCommonProtocol(asyncio.Protocol):
         """
         try:
             await asyncio.wait_for(
-                self.write_close_frame(serialize_close(code, reason)),
+                self.write_close_frame(Close(code, reason)),
                 self.close_timeout,
                 **loop_if_py_lt_38(self.loop),
             )
@@ -715,14 +745,27 @@ class WebSocketCommonProtocol(asyncio.Protocol):
     # Private methods - no guarantees.
 
     def connection_closed_exc(self) -> ConnectionClosed:
-        exception: ConnectionClosed
-        if self.close_code == 1000 or self.close_code == 1001:
-            exception = ConnectionClosedOK(self.close_code, self.close_reason)
+        exc: ConnectionClosed
+        if (
+            self.close_rcvd is not None
+            and self.close_rcvd.code in OK_CLOSE_CODES
+            and self.close_sent is not None
+            and self.close_sent.code in OK_CLOSE_CODES
+        ):
+            exc = ConnectionClosedOK(
+                self.close_rcvd,
+                self.close_sent,
+                self.close_rcvd_then_sent,
+            )
         else:
-            exception = ConnectionClosedError(self.close_code, self.close_reason)
+            exc = ConnectionClosedError(
+                self.close_rcvd,
+                self.close_sent,
+                self.close_rcvd_then_sent,
+            )
         # Chain to the exception that terminated data transfer, if any.
-        exception.__cause__ = self.transfer_data_exc
-        return exception
+        exc.__cause__ = self.transfer_data_exc
+        return exc
 
     async def ensure_open(self) -> None:
         """
@@ -918,12 +961,14 @@ class WebSocketCommonProtocol(asyncio.Protocol):
             if frame.opcode == OP_CLOSE:
                 # 7.1.5.  The WebSocket Connection Close Code
                 # 7.1.6.  The WebSocket Connection Close Reason
-                self.close_code, self.close_reason = parse_close(frame.data)
+                self.close_rcvd = Close.parse(frame.data)
+                if self.close_sent is not None:
+                    self.close_rcvd_then_sent = False
                 try:
                     # Echo the original data instead of re-serializing it with
-                    # serialize_close() because that fails when the close frame
-                    # is empty and parse_close() synthetizes a 1005 close code.
-                    await self.write_close_frame(frame.data)
+                    # Close.serialize() because that fails when the close frame
+                    # is empty and Close.parse() synthetizes a 1005 close code.
+                    await self.write_close_frame(self.close_rcvd, frame.data)
                 except ConnectionClosed:
                     # Connection closed before we could echo the close frame.
                     pass
@@ -1010,7 +1055,9 @@ class WebSocketCommonProtocol(asyncio.Protocol):
         self.write_frame_sync(fin, opcode, data)
         await self.drain()
 
-    async def write_close_frame(self, data: bytes = b"") -> None:
+    async def write_close_frame(
+        self, close: Close, data: Optional[bytes] = None
+    ) -> None:
         """
         Write a close frame if and only if the connection state is OPEN.
 
@@ -1026,6 +1073,12 @@ class WebSocketCommonProtocol(asyncio.Protocol):
             if self.debug:
                 self.logger.debug("= connection is CLOSING")
 
+            self.close_sent = close
+            if self.close_rcvd is not None:
+                self.close_rcvd_then_sent = True
+            if data is None:
+                data = close.serialize()
+
             # 7.1.2. Start the WebSocket Closing Handshake
             await self.write_frame(True, OP_CLOSE, data, _state=State.CLOSING)
 
@@ -1219,8 +1272,7 @@ class WebSocketCommonProtocol(asyncio.Protocol):
         # an error reading from or writing to the network.
         # Don't send a close frame if the connection is broken.
         if code != 1006 and self.state is State.OPEN:
-
-            frame_data = serialize_close(code, reason)
+            close = Close(code, reason)
 
             # Write the close frame without draining the write buffer.
 
@@ -1228,21 +1280,19 @@ class WebSocketCommonProtocol(asyncio.Protocol):
             # get stuck and simplifies the implementation of the callers.
             # Not drainig the write buffer is acceptable in this context.
 
-            # This duplicates a few lines of code from write_close_frame()
-            # and write_frame().
+            # This duplicates a few lines of code from write_close_frame().
 
             self.state = State.CLOSING
             if self.debug:
                 self.logger.debug("= connection is CLOSING")
 
-            frame = Frame(True, OP_CLOSE, frame_data)
-            if self.debug:
-                self.logger.debug("> %s", frame)
-            frame.write(
-                self.transport.write,
-                mask=self.is_client,
-                extensions=self.extensions,
-            )
+            # If self.close_rcvd was set, the connection state would be
+            # CLOSING. Therefore self.close_rcvd isn't set and we don't
+            # have to set self.close_rcvd_then_sent.
+            assert self.close_rcvd is None
+            self.close_sent = close
+
+            self.write_frame_sync(True, OP_CLOSE, close.serialize())
 
         # Start close_connection_task if the opening handshake didn't succeed.
         if not hasattr(self, "close_connection_task"):
@@ -1303,15 +1353,7 @@ class WebSocketCommonProtocol(asyncio.Protocol):
 
         """
         self.state = State.CLOSED
-        if not hasattr(self, "close_code"):
-            self.close_code = 1006
-        if not hasattr(self, "close_reason"):
-            self.close_reason = ""
-        self.logger.debug(
-            "= connection is CLOSED - code = %d, reason = %s",
-            self.close_code,
-            self.close_reason or "[no reason]",
-        )
+        self.logger.debug("= connection is CLOSED")
 
         self.abort_pings()
 


Test patch:
diff --git a/tests/extensions/test_permessage_deflate.py b/tests/extensions/test_permessage_deflate.py
index 5ba4d8d..bcd08a7 100644
--- a/tests/extensions/test_permessage_deflate.py
+++ b/tests/extensions/test_permessage_deflate.py
@@ -17,8 +17,8 @@ from websockets.frames import (
     OP_PING,
     OP_PONG,
     OP_TEXT,
+    Close,
     Frame,
-    serialize_close,
 )
 
 from .test_base import ClientNoOpExtensionFactory, ServerNoOpExtensionFactory
@@ -70,7 +70,7 @@ class PerMessageDeflateTests(unittest.TestCase, ExtensionTestsMixin):
         self.assertEqual(self.extension.decode(frame), frame)
 
     def test_no_encode_decode_close_frame(self):
-        frame = Frame(OP_CLOSE, serialize_close(1000, ""))
+        frame = Frame(OP_CLOSE, Close(1000, "").serialize())
 
         self.assertEqual(self.extension.encode(frame), frame)
 
diff --git a/tests/legacy/test_framing.py b/tests/legacy/test_framing.py
index 2baa827..4646817 100644
--- a/tests/legacy/test_framing.py
+++ b/tests/legacy/test_framing.py
@@ -171,3 +171,34 @@ class FramingTests(AsyncioTestCase):
         self.round_trip(
             b"\x81\x05uryyb", Frame(True, OP_TEXT, b"hello"), extensions=[Rot13()]
         )
+
+
+class ParseAndSerializeCloseTests(unittest.TestCase):
+    def assertCloseData(self, code, reason, data):
+        """
+        Serializing code / reason yields data. Parsing data yields code / reason.
+
+        """
+        serialized = serialize_close(code, reason)
+        self.assertEqual(serialized, data)
+        parsed = parse_close(data)
+        self.assertEqual(parsed, (code, reason))
+
+    def test_parse_close_and_serialize_close(self):
+        self.assertCloseData(1000, "", b"\x03\xe8")
+        self.assertCloseData(1000, "OK", b"\x03\xe8OK")
+
+    def test_parse_close_empty(self):
+        self.assertEqual(parse_close(b""), (1005, ""))
+
+    def test_parse_close_errors(self):
+        with self.assertRaises(ProtocolError):
+            parse_close(b"\x03")
+        with self.assertRaises(ProtocolError):
+            parse_close(b"\x03\xe7")
+        with self.assertRaises(UnicodeDecodeError):
+            parse_close(b"\x03\xe8\xff\xff")
+
+    def test_serialize_close_errors(self):
+        with self.assertRaises(ProtocolError):
+            serialize_close(999, "")
diff --git a/tests/legacy/test_protocol.py b/tests/legacy/test_protocol.py
index 97aa015..033cebe 100644
--- a/tests/legacy/test_protocol.py
+++ b/tests/legacy/test_protocol.py
@@ -14,7 +14,7 @@ from websockets.frames import (
     OP_PING,
     OP_PONG,
     OP_TEXT,
-    serialize_close,
+    Close,
 )
 from websockets.legacy.compatibility import loop_if_py_lt_38
 from websockets.legacy.framing import Frame
@@ -113,9 +113,9 @@ class CommonTests:
 
         self.protocol._drain = delayed_drain
 
-    close_frame = Frame(True, OP_CLOSE, serialize_close(1000, "close"))
-    local_close = Frame(True, OP_CLOSE, serialize_close(1000, "local"))
-    remote_close = Frame(True, OP_CLOSE, serialize_close(1000, "remote"))
+    close_frame = Frame(True, OP_CLOSE, Close(1000, "close").serialize())
+    local_close = Frame(True, OP_CLOSE, Close(1000, "local").serialize())
+    remote_close = Frame(True, OP_CLOSE, Close(1000, "remote").serialize())
 
     def receive_frame(self, frame):
         """
@@ -156,7 +156,7 @@ class CommonTests:
         This puts the connection in the CLOSED state.
 
         """
-        close_frame_data = serialize_close(code, reason)
+        close_frame_data = Close(code, reason).serialize()
         # Prepare the response to the closing handshake from the remote side.
         self.receive_frame(Frame(True, OP_CLOSE, close_frame_data))
         self.receive_eof_if_client()
@@ -178,7 +178,7 @@ class CommonTests:
         canceled, else asyncio complains about destroying a pending task.
 
         """
-        close_frame_data = serialize_close(code, reason)
+        close_frame_data = Close(code, reason).serialize()
         # Trigger the closing handshake from the local endpoint.
         close_task = self.loop.create_task(self.protocol.close(code, reason))
         self.run_loop_once()  # wait_for executes
@@ -213,7 +213,7 @@ class CommonTests:
         if not self.protocol.is_client:
             self.make_drain_slow()
 
-        close_frame_data = serialize_close(code, reason)
+        close_frame_data = Close(code, reason).serialize()
         # Trigger the closing handshake from the remote endpoint.
         self.receive_frame(Frame(True, OP_CLOSE, close_frame_data))
         self.run_loop_once()  # read_frame executes
@@ -298,7 +298,7 @@ class CommonTests:
         if code == 1006:
             self.assertNoFrameSent()
         else:
-            self.assertOneFrameSent(True, OP_CLOSE, serialize_close(code, message))
+            self.assertOneFrameSent(True, OP_CLOSE, Close(code, message).serialize())
 
     @contextlib.contextmanager
     def assertCompletesWithin(self, min_time, max_time):
@@ -386,6 +386,20 @@ class CommonTests:
         self.close_connection()
         self.assertTrue(wait_closed.done())
 
+    def test_close_code(self):
+        self.close_connection(1001, "Bye!")
+        self.assertEqual(self.protocol.close_code, 1001)
+
+    def test_close_reason(self):
+        self.close_connection(1001, "Bye!")
+        self.assertEqual(self.protocol.close_reason, "Bye!")
+
+    def test_close_code_not_set(self):
+        self.assertIsNone(self.protocol.close_code)
+
+    def test_close_reason_not_set(self):
+        self.assertIsNone(self.protocol.close_reason)
+
     # Test the recv coroutine.
 
     def test_recv_text(self):
@@ -649,7 +663,7 @@ class CommonTests:
             self.loop.run_until_complete(self.protocol.send(["café", b"tea"]))
         self.assertFramesSent(
             (False, OP_TEXT, "café".encode("utf-8")),
-            (True, OP_CLOSE, serialize_close(1011, "")),
+            (True, OP_CLOSE, Close(1011, "").serialize()),
         )
 
     def test_send_iterable_prevents_concurrent_send(self):
@@ -723,7 +737,7 @@ class CommonTests:
             )
         self.assertFramesSent(
             (False, OP_TEXT, "café".encode("utf-8")),
-            (True, OP_CLOSE, serialize_close(1011, "")),
+            (True, OP_CLOSE, Close(1011, "").serialize()),
         )
 
     def test_send_async_iterable_prevents_concurrent_send(self):
@@ -1172,7 +1186,7 @@ class CommonTests:
         # Connection is closed at 6ms.
         self.loop.run_until_complete(asyncio.sleep(4 * MS))
         self.assertOneFrameSent(
-            True, OP_CLOSE, serialize_close(1011, "keepalive ping timeout")
+            True, OP_CLOSE, Close(1011, "keepalive ping timeout").serialize()
         )
 
         # The keepalive ping task is complete.
diff --git a/tests/test_connection.py b/tests/test_connection.py
index 6778812..2a88756 100644
--- a/tests/test_connection.py
+++ b/tests/test_connection.py
@@ -9,8 +9,8 @@ from websockets.frames import (
     OP_PING,
     OP_PONG,
     OP_TEXT,
+    Close,
     Frame,
-    serialize_close,
 )
 
 from .extensions.utils import Rsv2Extension
@@ -60,7 +60,7 @@ class ConnectionTestCase(FramesTestCase):
         """
         close_frame = Frame(
             OP_CLOSE,
-            b"" if code is None else serialize_close(code, reason),
+            b"" if code is None else Close(code, reason).serialize(),
         )
         # A close frame was received.
         self.assertFrameReceived(connection, close_frame)
@@ -76,7 +76,7 @@ class ConnectionTestCase(FramesTestCase):
         """
         close_frame = Frame(
             OP_CLOSE,
-            b"" if code is None else serialize_close(code, reason),
+            b"" if code is None else Close(code, reason).serialize(),
         )
         # No frame was received.
         self.assertFrameReceived(connection, None)
@@ -667,10 +667,58 @@ class BinaryTests(ConnectionTestCase):
 
 class CloseTests(ConnectionTestCase):
     """
-    Test close frames. See 5.5.1. Close in RFC 6544.
+    Test close frames.
+
+    See RFC 6544:
+
+    5.5.1. Close
+    7.1.6.  The WebSocket Connection Close Reason
+    7.1.7.  Fail the WebSocket Connection
 
     """
 
+    def test_close_code(self):
+        client = Connection(Side.CLIENT)
+        client.receive_data(b"\x88\x04\x03\xe8OK")
+        client.set_state(State.CLOSED)
+        self.assertEqual(client.close_code, 1000)
+
+    def test_close_reason(self):
+        server = Connection(Side.SERVER)
+        server.receive_data(b"\x88\x84\x00\x00\x00\x00\x03\xe8OK")
+        server.set_state(State.CLOSED)
+        self.assertEqual(server.close_reason, "OK")
+
+    def test_close_code_not_provided(self):
+        server = Connection(Side.SERVER)
+        server.receive_data(b"\x88\x80\x00\x00\x00\x00")
+        server.set_state(State.CLOSED)
+        self.assertEqual(server.close_code, 1005)
+
+    def test_close_reason_not_provided(self):
+        client = Connection(Side.CLIENT)
+        client.receive_data(b"\x88\x00")
+        client.set_state(State.CLOSED)
+        self.assertEqual(client.close_reason, "")
+
+    def test_close_code_not_available(self):
+        client = Connection(Side.CLIENT)
+        client.set_state(State.CLOSED)
+        self.assertEqual(client.close_code, 1006)
+
+    def test_close_reason_not_available(self):
+        server = Connection(Side.SERVER)
+        server.set_state(State.CLOSED)
+        self.assertEqual(server.close_reason, "")
+
+    def test_close_code_not_available_yet(self):
+        server = Connection(Side.SERVER)
+        self.assertIsNone(server.close_code)
+
+    def test_close_reason_not_available_yet(self):
+        client = Connection(Side.CLIENT)
+        self.assertIsNone(client.close_reason)
+
     def test_client_sends_close(self):
         client = Connection(Side.CLIENT)
         with self.enforce_mask(b"\x3c\x3c\x3c\x3c"):
diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py
index 094fb6d..e172cdd 100644
--- a/tests/test_exceptions.py
+++ b/tests/test_exceptions.py
@@ -2,6 +2,8 @@ import unittest
 
 from websockets.datastructures import Headers
 from websockets.exceptions import *
+from websockets.frames import Close
+from websockets.http11 import Response
 
 
 class ExceptionsTests(unittest.TestCase):
@@ -13,28 +15,36 @@ class ExceptionsTests(unittest.TestCase):
                 "something went wrong",
             ),
             (
-                ConnectionClosed(1000, ""),
-                "code = 1000 (OK), no reason",
+                ConnectionClosed(Close(1000, ""), Close(1000, ""), True),
+                "received 1000 (OK); then sent 1000 (OK)",
             ),
             (
-                ConnectionClosed(1006, None),
-                "code = 1006 (connection closed abnormally [internal]), no reason"
+                ConnectionClosed(Close(1001, "Bye!"), Close(1001, "Bye!"), False),
+                "sent 1001 (going away) Bye!; then received 1001 (going away) Bye!",
             ),
             (
-                ConnectionClosed(3000, None),
-                "code = 3000 (registered), no reason"
+                ConnectionClosed(Close(1000, "race"), Close(1000, "cond"), True),
+                "received 1000 (OK) race; then sent 1000 (OK) cond",
             ),
             (
-                ConnectionClosed(4000, None),
-                "code = 4000 (private use), no reason"
+                ConnectionClosed(Close(1000, "cond"), Close(1000, "race"), False),
+                "sent 1000 (OK) race; then received 1000 (OK) cond",
             ),
             (
-                ConnectionClosedError(1016, None),
-                "code = 1016 (unknown), no reason"
+                ConnectionClosed(None, Close(1009, ""), None),
+                "sent 1009 (message too big); no close frame received",
             ),
             (
-                ConnectionClosedOK(1001, "bye"),
-                "code = 1001 (going away), reason = bye",
+                ConnectionClosed(Close(1002, ""), None, None),
+                "received 1002 (protocol error); no close frame sent",
+            ),
+            (
+                ConnectionClosedOK(Close(1000, ""), Close(1000, ""), True),
+                "received 1000 (OK); then sent 1000 (OK)",
+            ),
+            (
+                ConnectionClosedError(None, None, None),
+                "no close frame received or sent"
             ),
             (
                 InvalidHandshake("invalid request"),
@@ -87,6 +97,10 @@ class ExceptionsTests(unittest.TestCase):
                 InvalidUpgrade("Connection", "websocket"),
                 "invalid Connection header: websocket",
             ),
+            (
+                InvalidStatus(Response(401, "Unauthorized", Headers())),
+                "server rejected WebSocket connection: HTTP 401",
+            ),
             (
                 InvalidStatusCode(403, Headers()),
                 "server rejected WebSocket connection: HTTP 403",
diff --git a/tests/test_frames.py b/tests/test_frames.py
index 85414e9..c8f9867 100644
--- a/tests/test_frames.py
+++ b/tests/test_frames.py
@@ -280,13 +280,13 @@ class StrTests(unittest.TestCase):
     def test_close(self):
         self.assertEqual(
             str(Frame(OP_CLOSE, b"\x03\xe8")),
-            "CLOSE code = 1000 (OK), no reason [2 bytes]",
+            "CLOSE 1000 (OK) [2 bytes]",
         )
 
     def test_close_reason(self):
         self.assertEqual(
             str(Frame(OP_CLOSE, b"\x03\xe9Bye!")),
-            "CLOSE code = 1001 (going away), reason = Bye! [6 bytes]",
+            "CLOSE 1001 (going away) Bye! [6 bytes]",
         )
 
     def test_ping(self):
@@ -394,32 +394,39 @@ class PrepareCtrlTests(unittest.TestCase):
             prepare_ctrl(None)
 
 
-class ParseAndSerializeCloseTests(unittest.TestCase):
-    def assertCloseData(self, code, reason, data):
+class CloseTests(unittest.TestCase):
+    def assertCloseData(self, close, data):
         """
-        Serializing code / reason yields data. Parsing data yields code / reason.
+        Serializing close yields data. Parsing data yields close.
 
         """
-        serialized = serialize_close(code, reason)
+        serialized = close.serialize()
         self.assertEqual(serialized, data)
-        parsed = parse_close(data)
-        self.assertEqual(parsed, (code, reason))
+        parsed = Close.parse(data)
+        self.assertEqual(parsed, close)
 
-    def test_parse_close_and_serialize_close(self):
-        self.assertCloseData(1000, "", b"\x03\xe8")
-        self.assertCloseData(1000, "OK", b"\x03\xe8OK")
+    def test_str(self):
+        self.assertEqual(str(Close(1000, "")), "1000 (OK)")
+        self.assertEqual(str(Close(1001, "Bye!")), "1001 (going away) Bye!")
+        self.assertEqual(str(Close(3000, "")), "3000 (registered)")
+        self.assertEqual(str(Close(4000, "")), "4000 (private use)")
+        self.assertEqual(str(Close(5000, "")), "5000 (unknown)")
 
-    def test_parse_close_empty(self):
-        self.assertEqual(parse_close(b""), (1005, ""))
+    def test_parse_and_serialize(self):
+        self.assertCloseData(Close(1001, ""), b"\x03\xe9")
+        self.assertCloseData(Close(1000, "OK"), b"\x03\xe8OK")
 
-    def test_parse_close_errors(self):
+    def test_parse_empty(self):
+        self.assertEqual(Close.parse(b""), Close(1005, ""))
+
+    def test_parse_errors(self):
         with self.assertRaises(ProtocolError):
-            parse_close(b"\x03")
+            Close.parse(b"\x03")
         with self.assertRaises(ProtocolError):
-            parse_close(b"\x03\xe7")
+            Close.parse(b"\x03\xe7")
         with self.assertRaises(UnicodeDecodeError):
-            parse_close(b"\x03\xe8\xff\xff")
+            Close.parse(b"\x03\xe8\xff\xff")
 
-    def test_serialize_close_errors(self):
+    def test_serialize_errors(self):
         with self.assertRaises(ProtocolError):
-            serialize_close(999, "")
+            Close(999, "").serialize()