Repository: falconry/falcon

Issue:
Websocket handler fails in some cases of HTTPError/HTTPStatus
Hi,

Writing ASGI falcon using websockets, using the ```process_request_ws``` middleware handler and ```app.add_error_handler(HTTPError, my_error_handler)``` error handler.

When raising a ```falcon.HTTPError``` in the middleware its picked up by the error handler via the ```ex``` param. 
The object is not none, however this is my stacktrace: <br/>

I raise the "falcon.http_error.HTTPError: <HTTPError: 403 Forbidden>" in the middleware, and then raise the "falcon.http_error.HTTPError: <HTTPError: 500>" in the error handler, which results in the final traceback (falcon internal).


```bash
'''
[2023-03-29 13:39:47 +0200] [82450] [INFO] ('127.0.0.1', 57224) - "WebSocket /message" 403
[2023-03-29 13:39:47 +0200] [82450] [ERROR] Exception in ASGI application
Traceback (most recent call last):
  File ".pyenv/versions/wm/lib/python3.11/site-packages/falcon/asgi/app.py", line 1000, in _handle_websocket
    await process_request_ws(req, web_socket)
  File "Desktop/mirror/whatsapp-service/whatsapp_service/middleware/basic_ws_middleware.py", line 106, in process_request_ws
    raise HTTPError(status=falcon.HTTP_403, title="Invalid header")
falcon.http_error.HTTPError: <HTTPError: 403 Forbidden>

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File ".pyenv/versions/wm/lib/python3.11/site-packages/falcon/asgi/app.py", line 1095, in _handle_exception
    await err_handler(req, resp, ex, params, **kwargs)
  File "Desktop/mirror/whatsapp-service/whatsapp_service/utils/handlers/error_handlers.py", line 24, in my_error_handler
    raise HTTPError(status=500, title='Custom Websocket Error')
falcon.http_error.HTTPError: <HTTPError: 500>

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File ".pyenv/versions/wm/lib/python3.11/site-packages/uvicorn/protocols/websockets/websockets_impl.py", line 238, in run_asgi
    result = await self.app(self.scope, self.asgi_receive, self.asgi_send)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".pyenv/versions/wm/lib/python3.11/site-packages/uvicorn/middleware/proxy_headers.py", line 78, in __call__
    return await self.app(scope, receive, send)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".pyenv/versions/wm/lib/python3.11/site-packages/falcon/asgi/app.py", line 312, in __call__
    await self._handle_websocket(spec_version, scope, receive, send)
  File ".pyenv/versions/wm/lib/python3.11/site-packages/falcon/asgi/app.py", line 1018, in _handle_websocket
    if not await self._handle_exception(req, None, ex, params, ws=web_socket):
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".pyenv/versions/wm/lib/python3.11/site-packages/falcon/asgi/app.py", line 1100, in _handle_exception
    self._compose_error_response(req, resp, error)
  File "falcon/app.py", line 975, in falcon.app.App._compose_error_response
'''

```
The usage of the method resolution order is incorrect here as the ```type(exc)``` invocation returns type not the exception: 
https://github.com/falconry/falcon/blob/18503813059e648c693d03064adf2acdbe68d654/falcon/app.py#L1001




Discussion:
Steps to reproduce:
```python
from falcon.errors import HTTPError
from falcon.http_status import HTTPStatus

ehs = [Exception, HTTPError(status=400), HTTPStatus(status=400)]

# current implementation (bug, see: https://docs.python.org/3/library/functions.html#type)
for exc in type(HTTPError).__mro__[:-1]:
    assert exc in (type, object)

try:
    # preffered but still has a bug:
    for exc in HTTPError.__mro__[:-1]:
        assert exc in (HTTPError, HTTPStatus, Exception)
except AssertionError as e:
    print('assertion error')

# preffered bug free
for exc in HTTPError.__mro__[:-1]:
    assert exc in (HTTPError, Exception, BaseException)


```
Hi,

I don't think the issue is `type(ex)` here, since `ex` in an instance of an exception, not a class.
The stack trace is is not complete, so I don't know what caused it
Hi @CaselIT ,

Even with an instance of exception, because all classes inherit from object, calling ```type()``` on any instance irrespective of inheritance or the class itself will return <class 'type'>.

Thats why this example which is similar to the code in falcon, passes the assertion, regardless of the mro calling ```type()``` on any object even on ```int``` will result in the type class on which dunder mro is then called: 

```python
for exc in type(HTTPError).__mro__[:-1]:
    assert exc in (type, object)
```

python docs: "The isinstance built-in function is recommended for testing the type of an object, because it takes subclasses into account. " - [type()](https://docs.python.org/3/library/functions.html#type)



Another example: 

```python
# Exception (from python std lib)
class ParentException(Exception):
    pass

class ChildException(Exception):
    pass


print('ChildException.mro():', ChildException.mro())
# >>> ChildException.mro(): [<class '__main__.ChildException'>, <class 'Exception'>, <class 'BaseException'>, <class 'object'>]

# irrespective of the object, :type(): will always return an instance of type
print('type(ChildException): ',type(ChildException)) # >>> type(ChildException):  <class 'type'>

print('type(ParentException):', type(ParentException)) # >>> type(ParentException): <class 'type'>

print('type(int): ', type(int))  # >>> type(int):  <class 'type'>

# therefore calling the method resolution order of type yields (type, object)
print('type(ChildException).__mro__:' , type(ChildException).__mro__)   # >>> type(ChildException).__mro__: (<class 'type'>, <class 'object'>)
```
> Even with an instance of exception, because all classes inherit from object, calling type() on any instance irrespective of inheritance or the class itself will return <class 'type'>.

Sorry but this statement is wrong:
```py
print(type(Exception()).__mro__[:-1])
# (<class 'Exception'>, <class 'BaseException'>)
```

there is no type here. Removing the -1 will revel object. Since an instance it's not a type

```py
print(type(Exception()).__mro__)
# (<class 'Exception'>, <class 'BaseException'>, <class 'object'>)
```

The following is also wrong, since type is called classes not instances. That is not representative of what falcon is doing. It's calling `type()` in instances _not_ classes.


Returning on the exception in the opening post, can you provide the complete stack trace?


Hi @CaselIT ,

That is my entire stack trace.

I see the usage of instances now, the distinction between instances and classes when using type(), very different behavior, thanks for pointing it out. 

Then I am unsure why  the error is raised internally to falcon.

The line that seems to be errororing is https://github.com/falconry/falcon/blob/18503813059e648c693d03064adf2acdbe68d654/falcon/app.py#L974
Hi @sashn-citiqprepaid, and thanks for reporting this.
Echoing @CaselIT, could you post a small program that illustrates the problem; not Falcon's own internal code, but your program, ideally [as small as possible](https://en.wikipedia.org/wiki/Minimal_reproducible_example), so that we could reproduce it?

You have added some interesting analyses why Falcon's way to map exceptions to error handlers works or doesn't work as expected, but we still want to think of this in terms of the framework's user experience. When I have this program, what happens? Also, we could then test if this is specific to WebSocket, or it affects all kinds of errors, like HTTP APIs too.
Hi @vytas7 .

It might be my implementation that is unexpected, but worth exploring.
Will put together an example.
Hi @vytas7,

Ive made available https://github.com/sashn-citiqprepaid/2146
Ok as suspected the stack trace was not complete.
Here is the complete stack trace:
```py
Traceback (most recent call last):
  File "me\falcon\falcon\asgi\app.py", line 998, in _handle_websocket
    await process_request_ws(req, web_socket)
  File "me\falcon\try2.py", line 31, in process_request_ws
    raise HTTPError(status=falcon.HTTP_401, title="Invalid header")
falcon.http_error.HTTPError: <HTTPError: 401 Unauthorized>

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "me\falcon\falcon\asgi\app.py", line 1093, in _handle_exception
    await err_handler(req, resp, ex, params, **kwargs)
  File "me\falcon\try2.py", line 60, in my_error_handler
    raise HTTPError(status=418, title='Custom Websocket Error')
falcon.http_error.HTTPError: <HTTPError: 418>

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "also_me\venvs\falcon\Lib\site-packages\uvicorn\protocols\websockets\websockets_impl.py", line 254, in run_asgi
    result = await self.app(self.scope, self.asgi_receive, self.asgi_send)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "also_me\venvs\falcon\Lib\site-packages\uvicorn\middleware\proxy_headers.py", line 78, in __call__
    return await self.app(scope, receive, send)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "me\falcon\falcon\asgi\app.py", line 310, in __call__
    await self._handle_websocket(spec_version, scope, receive, send)
  File "me\falcon\falcon\asgi\app.py", line 1016, in _handle_websocket
    if not await self._handle_exception(req, None, ex, params, ws=web_socket):
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "me\falcon\falcon\asgi\app.py", line 1098, in _handle_exception
    self._compose_error_response(req, resp, error)
  File "me\falcon\falcon\app.py", line 974, in _compose_error_response
    resp.status = error.status
    ^^^^^^^^^^^
AttributeError: 'NoneType' object has no attribute 'status'
```

and that's indeed a bug of falcon, since there is no resp when dealing with a websocket.

cc @kgriffs 
Looking a bit, I think that things are quite limited here, since if the web-socket has not yet been accepted the only thing available is close.

I guess we can do the same as the default error handler (with a different log message)
https://github.com/falconry/falcon/blob/d2981f684c91598f229e6c2fa2408a3a857da0da/falcon/asgi/app.py#L1035-L1042

same for status?
I don't think so.

The issue is that error raised in error handlers when using websockets were not correctly considered by falcon, so we try to serialize it on the a response object that missing since it's a websocket response.

A workaround is to instead of raising the error in the error handler, to close the websocket if the websocket object is provided to the handler
Hi @CaselIT ,
Just removed the comment, saw the same stack-trace.
Ive forked falcon, and having a look aswell.
@CaselIT would this entail a new error response composer for a websocket?
I don't think so, since the only think that can be safely done is close, we can document that that's what the framework will do
btw thanks @sashn-citiqprepaid for supplying a complete example that reproduced your issue
@CaselIT absolute pleasure, write a fair amount of falcon code and thoroughly enjoy using it, favorite feature has to be the request.context in the middleware thats passed onto collections.   
It seems it is even worse for the case of `HTTPStatus`, as no complex custom error handlers are even required to reproduce this issue, the MRE becomes even more trivial:
```python
import falcon
import falcon.asgi


class Messages:
    async def on_websocket(self, req, ws):
        raise falcon.HTTPStatus(204)


app = falcon.asgi.App()
app.add_route('/messages', Messages())
```

Resolving patch:
diff --git a/falcon/asgi/app.py b/falcon/asgi/app.py
--- a/falcon/asgi/app.py
+++ b/falcon/asgi/app.py
@@ -44,6 +44,7 @@
 from .request import Request
 from .response import Response
 from .structures import SSEvent
+from .ws import http_status_to_ws_code
 from .ws import WebSocket
 from .ws import WebSocketOptions
 
@@ -1025,30 +1026,45 @@ def _prepare_middleware(self, middleware=None, independent_middleware=False):
             asgi=True,
         )
 
-    async def _http_status_handler(self, req, resp, status, params):
-        self._compose_status_response(req, resp, status)
+    async def _http_status_handler(self, req, resp, status, params, ws=None):
+        if resp:
+            self._compose_status_response(req, resp, status)
+        elif ws:
+            code = http_status_to_ws_code(status.status)
+            falcon._logger.error(
+                '[FALCON] HTTPStatus %s raised while handling WebSocket. '
+                'Closing with code %s',
+                status,
+                code,
+            )
+            await ws.close(code)
+        else:
+            raise NotImplementedError('resp or ws expected')
 
     async def _http_error_handler(self, req, resp, error, params, ws=None):
         if resp:
             self._compose_error_response(req, resp, error)
-
-        if ws:
+        elif ws:
+            code = http_status_to_ws_code(error.status_code)
             falcon._logger.error(
-                '[FALCON] WebSocket handshake rejected due to raised HTTP error: %s',
+                '[FALCON] HTTPError %s raised while handling WebSocket. '
+                'Closing with code %s',
                 error,
+                code,
             )
-
-            code = 3000 + error.status_code
             await ws.close(code)
+        else:
+            raise NotImplementedError('resp or ws expected')
 
     async def _python_error_handler(self, req, resp, error, params, ws=None):
         falcon._logger.error('[FALCON] Unhandled exception in ASGI app', exc_info=error)
 
         if resp:
             self._compose_error_response(req, resp, falcon.HTTPInternalServerError())
-
-        if ws:
+        elif ws:
             await self._ws_cleanup_on_error(ws)
+        else:
+            raise NotImplementedError('resp or ws expected')
 
     async def _ws_disconnected_error_handler(self, req, resp, error, params, ws):
         falcon._logger.debug(
@@ -1093,9 +1109,9 @@ async def _handle_exception(self, req, resp, ex, params, ws=None):
                 await err_handler(req, resp, ex, params, **kwargs)
 
             except HTTPStatus as status:
-                self._compose_status_response(req, resp, status)
+                await self._http_status_handler(req, resp, status, params, ws=ws)
             except HTTPError as error:
-                self._compose_error_response(req, resp, error)
+                await self._http_error_handler(req, resp, error, params, ws=ws)
 
             return True
 
diff --git a/falcon/asgi/ws.py b/falcon/asgi/ws.py
--- a/falcon/asgi/ws.py
+++ b/falcon/asgi/ws.py
@@ -701,3 +701,8 @@ async def _pump(self):
             if self._pop_message_waiter is not None:
                 self._pop_message_waiter.set_result(None)
                 self._pop_message_waiter = None
+
+
+def http_status_to_ws_code(http_status: int) -> int:
+    """Convert the provided http status to a websocket close code by adding 3000."""
+    return http_status + 3000
diff --git a/falcon/util/misc.py b/falcon/util/misc.py
--- a/falcon/util/misc.py
+++ b/falcon/util/misc.py
@@ -458,10 +458,10 @@ def code_to_http_status(status):
 
     try:
         code = int(status)
-        if not 100 <= code <= 999:
-            raise ValueError('{} is not a valid status code'.format(status))
     except (ValueError, TypeError):
         raise ValueError('{!r} is not a valid status code'.format(status))
+    if not 100 <= code <= 999:
+        raise ValueError('{} is not a valid status code'.format(status))
 
     try:
         # NOTE(kgriffs): We do this instead of using http.HTTPStatus since


Test patch:
diff --git a/tests/asgi/test_misc.py b/tests/asgi/test_misc.py
new file mode 100644
--- /dev/null
+++ b/tests/asgi/test_misc.py
@@ -0,0 +1,32 @@
+# misc test for 100% coverage
+
+from unittest.mock import MagicMock
+
+import pytest
+
+from falcon.asgi import App
+from falcon.http_error import HTTPError
+from falcon.http_status import HTTPStatus
+
+
+@pytest.mark.asyncio
+async def test_http_status_not_impl():
+    app = App()
+    with pytest.raises(NotImplementedError):
+        await app._http_status_handler(MagicMock(), None, HTTPStatus(200), {}, None)
+
+
+@pytest.mark.asyncio
+async def test_http_error_not_impl():
+    app = App()
+    with pytest.raises(NotImplementedError):
+        await app._http_error_handler(MagicMock(), None, HTTPError(400), {}, None)
+
+
+@pytest.mark.asyncio
+async def test_python_error_not_impl():
+    app = App()
+    with pytest.raises(NotImplementedError):
+        await app._python_error_handler(
+            MagicMock(), None, ValueError('error'), {}, None
+        )
diff --git a/tests/asgi/test_ws.py b/tests/asgi/test_ws.py
--- a/tests/asgi/test_ws.py
+++ b/tests/asgi/test_ws.py
@@ -1092,3 +1092,126 @@ def test_msgpack_missing():
 
     with pytest.raises(RuntimeError):
         handler.deserialize(b'{}')
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize('status', [200, 500, 422, 400])
+@pytest.mark.parametrize('thing', [falcon.HTTPStatus, falcon.HTTPError])
+@pytest.mark.parametrize('accept', [True, False])
+async def test_ws_http_error_or_status_response(conductor, status, thing, accept):
+    class Resource:
+        async def on_websocket(self, req, ws):
+            if accept:
+                await ws.accept()
+            raise thing(status)
+
+    conductor.app.add_route('/', Resource())
+    exp_code = 3000 + status
+
+    async with conductor as c:
+        if accept:
+            async with c.simulate_ws() as ws:
+                assert ws.closed
+                assert ws.close_code == exp_code
+        else:
+            with pytest.raises(falcon.WebSocketDisconnected) as err:
+                async with c.simulate_ws():
+                    pass
+            assert err.value.code == exp_code
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize('status', [200, 500, 422, 400])
+@pytest.mark.parametrize(
+    'thing',
+    [
+        falcon.HTTPStatus,
+        falcon.HTTPError,
+    ],
+)
+@pytest.mark.parametrize('place', ['request', 'resource'])
+async def test_ws_http_error_or_status_middleware(conductor, status, thing, place):
+    called = False
+
+    class Resource:
+        async def on_websocket(self, req, ws):
+            nonlocal called
+            called = True
+
+    class Middleware:
+        async def process_request_ws(self, req, ws):
+            if place == 'request':
+                raise thing(status)
+
+        async def process_resource_ws(self, req, ws, res, params):
+            if place == 'resource':
+                raise thing(status)
+
+    conductor.app.add_route('/', Resource())
+    conductor.app.add_middleware(Middleware())
+    exp_code = 3000 + status
+
+    async with conductor as c:
+        with pytest.raises(falcon.WebSocketDisconnected) as err:
+            async with c.simulate_ws():
+                pass
+        assert err.value.code == exp_code
+    assert not called
+
+
+class FooBarError(Exception):
+    pass
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize('status', [200, 500, 422, 400])
+@pytest.mark.parametrize('thing', [falcon.HTTPStatus, falcon.HTTPError])
+@pytest.mark.parametrize(
+    'place', ['request', 'resource', 'ws_before_accept', 'ws_after_accept']
+)
+@pytest.mark.parametrize('handler_has_ws', [True, False])
+async def test_ws_http_error_or_status_error_handler(
+    conductor, status, thing, place, handler_has_ws
+):
+    class Resource:
+        async def on_websocket(self, req, ws):
+            if place == 'ws_before_accept':
+                raise FooBarError
+            await ws.accept()
+            if place == 'ws_after_accept':
+                raise FooBarError
+
+    class Middleware:
+        async def process_request_ws(self, req, ws):
+            if place == 'request':
+                raise FooBarError
+
+        async def process_resource_ws(self, req, ws, res, params):
+            if place == 'resource':
+                raise FooBarError
+
+    if handler_has_ws:
+
+        async def handle_foobar(req, resp, ex, param, ws=None):   # type: ignore
+            raise thing(status)
+
+    else:
+
+        async def handle_foobar(req, resp, ex, param):  # type: ignore
+            raise thing(status)
+
+    conductor.app.add_route('/', Resource())
+    conductor.app.add_middleware(Middleware())
+    conductor.app.add_error_handler(FooBarError, handle_foobar)
+    exp_code = 3000 + status
+
+    async with conductor as c:
+        if place == 'ws_after_accept':
+            async with c.simulate_ws() as ws:
+                assert ws.closed
+                assert ws.close_code == exp_code
+        else:
+            with pytest.raises(falcon.WebSocketDisconnected) as err:
+                async with c.simulate_ws():
+                    pass
+            assert err.value.code == exp_code
diff --git a/tests/test_utils.py b/tests/test_utils.py
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -522,7 +522,23 @@ def test_get_http_status(self):
     def test_code_to_http_status(self, v_in, v_out):
         assert falcon.code_to_http_status(v_in) == v_out
 
-    @pytest.mark.parametrize('v', [0, 13, 99, 1000, 1337.01, -99, -404.3, -404, -404.3])
+    @pytest.mark.parametrize(
+        'v',
+        [
+            0,
+            13,
+            99,
+            1000,
+            1337.01,
+            -99,
+            -404.3,
+            -404,
+            -404.3,
+            'Successful',
+            'Failed',
+            None,
+        ],
+    )
     def test_code_to_http_status_value_error(self, v):
         with pytest.raises(ValueError):
             falcon.code_to_http_status(v)