Repository: sanic-org/sanic
Pull request #2947 (2024-05-06 03:24:58)

Make the `.message` field on exceptions non-empty
This allows subclasses of SanicException to access their message via the `message` attribute. This makes it consistent with the `status_code`, `quiet`, `headers` attributes that were previously present on the class.

Currently, the message attribute is present on the class but not on the instance, so accessing SanicException("Failed!").message will return an empty string "" instead of the actual message "Failed!", which you can get by calling the `__str__()` method or `str()` function.

This is a bit surprising, since the .message attribute shows up in autocomplete and type-checking. It also happens for the other exceptions, like BadRequest().message == "" as well.

I set the message attribute on instances of SanicException and added tests for this new behavior.

### Doc reference

https://sanic.dev/en/guide/best-practices/exceptions.html#exception-properties

ekzhang: Hi @Tronic, I updated the constructor to decode bytes arguments to str. It is a bit more verbose but works without disabling the type-checker on that line now.

I also updated the test!

---

The current CI failures appear to be from an unrelated error to this changeset.

```
sanic/request/types.py:89: error: "ctx_type" cannot appear after "sanic_type" in type parameter list because it has no default type  [misc]
```
ekzhang: > if not barring minor cleanup (e.g. removal of "utf8" argument on decode, perhaps some restructuring).

Thanks for reviewing so quickly! I followed the pattern of the existing code to use `.decode("utf8")` but I'm also happy to amend that call or restructure anything.
ahopkins: @ekzhang I made some changes to the PR to keep the logic closer to the original intent to handle some of the border cases that your implementation may have broken. Thanks for the PR, and I certainly appreciate the additional tests you added.

Patch:
diff --git a/sanic/exceptions.py b/sanic/exceptions.py
index 52e21058..1a505f6d 100644
--- a/sanic/exceptions.py
+++ b/sanic/exceptions.py
@@ -65,7 +65,7 @@ class SanicException(Exception):
         quiet: Optional[bool] = None,
         context: Optional[Dict[str, Any]] = None,
         extra: Optional[Dict[str, Any]] = None,
-        headers: Optional[Dict[str, Any]] = None,
+        headers: Optional[Dict[str, str]] = None,
     ) -> None:
         self.context = context
         self.extra = extra
@@ -75,17 +75,22 @@ class SanicException(Exception):
         quiet = quiet or getattr(self.__class__, "quiet", None)
         headers = headers or getattr(self.__class__, "headers", {})
         if message is None:
-            if self.message:
-                message = self.message
-            elif status_code:
-                msg: bytes = STATUS_CODES.get(status_code, b"")
-                message = msg.decode("utf8")
+            message = self.message
+            if not message and status_code:
+                msg = STATUS_CODES.get(status_code, b"")
+                message = msg.decode()
+        elif isinstance(message, bytes):
+            message = message.decode()
 
         super().__init__(message)
 
         self.status_code = status_code or self.status_code
         self.quiet = quiet
         self.headers = headers
+        try:
+            self.message = message
+        except AttributeError:
+            ...
 
 
 class HTTPException(SanicException):


Test patch:
diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py
index 29c4e45c..7932eb6d 100644
--- a/tests/test_exceptions.py
+++ b/tests/test_exceptions.py
@@ -398,3 +398,18 @@ def test_exception_aliases():
     assert MethodNotSupported is MethodNotAllowed
     assert ContentRangeError is RangeNotSatisfiable
     assert HeaderExpectationFailed is ExpectationFailed
+
+
+def test_exception_message_attribute():
+    assert ServerError("it failed").message == "it failed"
+    assert ServerError(b"it failed").message == "it failed"
+    assert (
+        ServerError().message == str(ServerError()) == "Internal Server Error"
+    )
+
+    class CustomError(SanicException):
+        message = "Something bad happened"
+
+    assert CustomError().message == CustomError.message == str(CustomError())
+    assert SanicException().message != ""
+    assert SanicException("").message == ""