Repository: hikari-py/hikari
Pull request #1120 (2022-04-01 17:27:06)

Fix unicode decode error caused by `latin-1` encoding when sending the banner
Special thanks to @Jonxslays for coming up with the initial idea to fix this!

### Checklist
<!-- Make sure to tick all the following boxes by putting an `x` in between (like this `[x]`) -->
- [x] I have run `nox` and all the pipelines have passed.
- [x] I have made unittests according to the code I have added/modified/deleted.



Patch:
diff --git a/changes/1120.bugfix.md b/changes/1120.bugfix.md
new file mode 100644
index 00000000..04734d5e
--- /dev/null
+++ b/changes/1120.bugfix.md
@@ -0,0 +1,1 @@
+Fix unicode decode error caused by `latin-1` encoding when sending the banner.
diff --git a/changes/1121.bugfix.md b/changes/1121.bugfix.md
new file mode 100644
index 00000000..67440ca3
--- /dev/null
+++ b/changes/1121.bugfix.md
@@ -0,0 +1,2 @@
+Don't error on an out-of-spec HTTP status code (e.g one of Cloudflare's custom status codes).
+`HTTPResponseError.status` may now be of type `http.HTTPStatus` or `int`.
diff --git a/hikari/errors.py b/hikari/errors.py
index 4273d489..470bb553 100644
--- a/hikari/errors.py
+++ b/hikari/errors.py
@@ -231,8 +231,12 @@ class HTTPResponseError(HTTPError):
     url: str = attr.field()
     """The URL that produced this error message."""
 
-    status: http.HTTPStatus = attr.field()
-    """The HTTP status code for the response."""
+    status: typing.Union[http.HTTPStatus, int] = attr.field()
+    """The HTTP status code for the response.
+
+    This will be `int` if it's outside the range of status codes in the HTTP
+    specification (e.g. one of Cloudflare's non-standard status codes).
+    """
 
     headers: data_binding.Headers = attr.field()
     """The headers received in the error response."""
@@ -247,8 +251,12 @@ class HTTPResponseError(HTTPError):
     """The error code."""
 
     def __str__(self) -> str:
-        name = self.status.name.replace("_", " ").title()
-        name_value = f"{name} {self.status.value}"
+        if isinstance(self.status, http.HTTPStatus):
+            name = self.status.name.replace("_", " ").title()
+            name_value = f"{name} {self.status.value}"
+
+        else:
+            name_value = f"Unknown Status {self.status}"
 
         if self.code:
             code_str = f" ({self.code})"
diff --git a/hikari/internal/net.py b/hikari/internal/net.py
index 3024f513..4e0f0cd8 100644
--- a/hikari/internal/net.py
+++ b/hikari/internal/net.py
@@ -62,7 +62,10 @@ async def generate_error_response(response: aiohttp.ClientResponse) -> errors.HT
     if response.status == http.HTTPStatus.NOT_FOUND:
         return errors.NotFoundError(*args)
 
-    status = http.HTTPStatus(response.status)
+    try:
+        status: typing.Union[http.HTTPStatus, int] = http.HTTPStatus(response.status)
+    except ValueError:
+        status = response.status
 
     if 400 <= status < 500:
         return errors.ClientHTTPResponseError(real_url, status, response.headers, raw_body)
diff --git a/hikari/internal/ux.py b/hikari/internal/ux.py
index 04217fc9..e4604a5c 100644
--- a/hikari/internal/ux.py
+++ b/hikari/internal/ux.py
@@ -33,7 +33,6 @@
 import re
 import string
 import sys
-import time
 import typing
 import warnings
 
@@ -223,10 +222,9 @@ def print_banner(
         for code in colorlog.escape_codes.escape_codes:
             args[code] = ""
 
-    sys.stdout.write(string.Template(raw_banner).safe_substitute(args))
-    # Give the stream some time to flush
-    sys.stdout.flush()
-    time.sleep(0.125)
+    # 1 maps to stdout
+    with open(1, "w", encoding="utf-8") as stdout:
+        stdout.write(string.Template(raw_banner).safe_substitute(args))
 
 
 def supports_color(allow_color: bool, force_color: bool) -> bool:


Test patch:
diff --git a/tests/hikari/internal/test_net.py b/tests/hikari/internal/test_net.py
index 4d7312fb..62891653 100644
--- a/tests/hikari/internal/test_net.py
+++ b/tests/hikari/internal/test_net.py
@@ -70,6 +70,52 @@ async def json(self):
     assert returned is error()
 
 
+@pytest.mark.parametrize(
+    ("status_", "expected_error"),
+    [
+        # The following internal server non-conforming status codes are used by cloudflare.
+        # Source I made it up...
+        # jk https://en.wikipedia.org/wiki/List_of_HTTP_status_codes
+        (520, "InternalServerError"),
+        (521, "InternalServerError"),
+        (522, "InternalServerError"),
+        (523, "InternalServerError"),
+        (524, "InternalServerError"),
+        (525, "InternalServerError"),
+        (526, "InternalServerError"),
+        (527, "InternalServerError"),
+        (530, "InternalServerError"),
+        # These non-conforming bad requests status codes are sent by NGINX.
+        # Same source as cloudflare status codes.
+        (494, "ClientHTTPResponseError"),
+        (495, "ClientHTTPResponseError"),
+        (496, "ClientHTTPResponseError"),
+        (497, "ClientHTTPResponseError"),
+        # This non-conforming status code is made up.
+        (694, "HTTPResponseError"),
+    ],
+)
+@pytest.mark.asyncio()
+async def test_generate_error_response_with_non_conforming_status_code(status_, expected_error):
+    class StubResponse:
+        real_url = "https://some.url"
+        status = status_
+        headers = {}
+
+        async def read(self):
+            return "some raw body"
+
+        async def json(self):
+            return {"message": "raw message", "code": 123}
+
+    with mock.patch.object(errors, expected_error) as error:
+        returned = await net.generate_error_response(StubResponse())
+
+    error.assert_called_once_with("https://some.url", status_, {}, "some raw body")
+
+    assert returned is error()
+
+
 @pytest.mark.parametrize(
     ("status_", "expected_error"),
     [
diff --git a/tests/hikari/internal/test_ux.py b/tests/hikari/internal/test_ux.py
index d5c6b96f..ff544396 100644
--- a/tests/hikari/internal/test_ux.py
+++ b/tests/hikari/internal/test_ux.py
@@ -19,6 +19,7 @@
 # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 # SOFTWARE.
+import builtins
 import contextlib
 import importlib
 import logging
@@ -180,7 +181,7 @@ def test_when_supports_color(self, mock_args):
         supports_color = stack.enter_context(mock.patch.object(ux, "supports_color", return_value=True))
         read_text = stack.enter_context(mock.patch.object(importlib.resources, "read_text"))
         template = stack.enter_context(mock.patch.object(string, "Template"))
-        write = stack.enter_context(mock.patch.object(sys.stdout, "write"))
+        builtins_open = stack.enter_context(mock.patch.object(builtins, "open"))
         abspath = stack.enter_context(mock.patch.object(os.path, "abspath", return_value="some path"))
         dirname = stack.enter_context(mock.patch.object(os.path, "dirname"))
 
@@ -207,7 +208,8 @@ def test_when_supports_color(self, mock_args):
 
         template.assert_called_once_with(read_text())
         template().safe_substitute.assert_called_once_with(args)
-        write.assert_called_once_with(template().safe_substitute())
+        builtins_open.assert_called_once_with(1, "w", encoding="utf-8")
+        builtins_open.return_value.__enter__.return_value.write.assert_called_once_with(template().safe_substitute())
         dirname.assert_called_once_with("~/hikari")
         abspath.assert_called_once_with(dirname())
         supports_color.assert_called_once_with(True, False)
@@ -221,9 +223,9 @@ def test_when_doesnt_supports_color(self, mock_args):
         supports_color = stack.enter_context(mock.patch.object(ux, "supports_color", return_value=False))
         read_text = stack.enter_context(mock.patch.object(importlib.resources, "read_text"))
         template = stack.enter_context(mock.patch.object(string, "Template"))
-        write = stack.enter_context(mock.patch.object(sys.stdout, "write"))
         abspath = stack.enter_context(mock.patch.object(os.path, "abspath", return_value="some path"))
         dirname = stack.enter_context(mock.patch.object(os.path, "dirname"))
+        builtins_open = stack.enter_context(mock.patch.object(builtins, "open"))
 
         with stack:
             ux.print_banner("hikari", True, False)
@@ -248,10 +250,11 @@ def test_when_doesnt_supports_color(self, mock_args):
 
         template.assert_called_once_with(read_text())
         template().safe_substitute.assert_called_once_with(args)
-        write.assert_called_once_with(template().safe_substitute())
         dirname.assert_called_once_with("~/hikari")
         abspath.assert_called_once_with(dirname())
         supports_color.assert_called_once_with(True, False)
+        builtins_open.assert_called_once_with(1, "w", encoding="utf-8")
+        builtins_open.return_value.__enter__.return_value.write.assert_called_once_with(template().safe_substitute())
 
     def test_use_extra_args(self, mock_args):
         stack = contextlib.ExitStack()
@@ -259,7 +262,7 @@ def test_use_extra_args(self, mock_args):
         stack.enter_context(mock.patch.object(time, "sleep"))
         read_text = stack.enter_context(mock.patch.object(importlib.resources, "read_text"))
         template = stack.enter_context(mock.patch.object(string, "Template"))
-        write = stack.enter_context(mock.patch.object(sys.stdout, "write"))
+        builtins_open = stack.enter_context(mock.patch.object(builtins, "open"))
         stack.enter_context(mock.patch.object(os.path, "abspath", return_value="some path"))
 
         extra_args = {
@@ -289,7 +292,8 @@ def test_use_extra_args(self, mock_args):
 
         template.assert_called_once_with(read_text())
         template().safe_substitute.assert_called_once_with(args)
-        write.assert_called_once_with(template().safe_substitute())
+        builtins_open.assert_called_once_with(1, "w", encoding="utf-8")
+        builtins_open.return_value.__enter__.return_value.write.assert_called_once_with(template().safe_substitute())
 
     def test_overwrite_args_raises_error(self, mock_args):
         stack = contextlib.ExitStack()
diff --git a/tests/hikari/test_errors.py b/tests/hikari/test_errors.py
index 4d6d9558..9b71a109 100644
--- a/tests/hikari/test_errors.py
+++ b/tests/hikari/test_errors.py
@@ -87,6 +87,10 @@ def error(self):
     def test_str(self, error):
         assert str(error) == "Bad Request 400: (12345) 'message' for https://some.url"
 
+    def test_str_when_int_status_code(self, error):
+        error.status = 699
+        assert str(error) == "Unknown Status 699: (12345) 'message' for https://some.url"
+
     def test_str_when_message_is_None(self, error):
         error.message = None
         assert str(error) == "Bad Request 400: (12345) 'raw body' for https://some.url"