litestar-org / litestar

Production-ready, Light, Flexible and Extensible ASGI API framework | Effortlessly Build Performant APIs
https://litestar.dev/
MIT License
5.52k stars 377 forks source link

Bug: WebSocket connection fails due to 'GET' method being sent instead of None (Litestar expects None) #3840

Closed gangstand closed 1 week ago

gangstand commented 1 week ago

Description

When using Litestar with Socketify as the ASGI server for handling WebSocket connections, I encountered a MethodNotAllowedException with the following traceback. The error seems to stem from the fact that Socketify is sending a 'GET' method in the ASGI scope, whereas Litestar expects the method to be None for WebSocket connections.

Additional Information: Upon investigation, it seems that Socketify is passing 'GET' in the ASGI scope for WebSocket upgrades. However, Litestar expects the method to be None for WebSocket connections. The code for the ASGI implementation in Socketify at line 106 shows that the method is being set to 'GET'.

https://github.com/cirospaciari/socketify.py/blob/main/src/socketify/asgi.py

"method": ffi.unpack(info.method, info.method_size).decode("utf8"),

It would be helpful if Litestar could gracefully handle this scenario or if Socketify could adjust its ASGI scope generation for WebSocket connections to comply with the expected behavior.

URL to code causing the issue

https://github.com/litestar-org/litestar/blob/main/litestar/_asgi/routing_trie/traversal.py

MCVE

from litestar import Litestar, websocket_listener
from socketify import ASGI

@websocket_listener("/")
async def handler(data: str) -> str:
    return data

litestar_app = Litestar([handler], debug=True)

if __name__ == "__main__":
    app = ASGI(litestar_app)
    app.listen(8000, lambda config: print("Listening on port http://localhost:%d now\n" % config.port))
    app.run()

Steps to reproduce

1. Run the code provided above.
2. Initiate a WebSocket connection to ws://localhost:8000.
3. Observe the error in the logs.

Expected behavior: The WebSocket connection should be established successfully, and Litestar should handle the connection without throwing a MethodNotAllowedException.

Screenshots

No response

Logs

Listening on port http://localhost:8000 now

ERROR - 2024-10-25 11:05:06,635 - litestar - config - Uncaught exception (connection_type=websocket, path=/):
Traceback (most recent call last):
  File "C:\Users\gangstand\Desktop\ws.chat\.venv\Lib\site-packages\litestar\_asgi\routing_trie\traversal.py", line 136, in parse_path_to_route
    asgi_app, handler = parse_node_handlers(node=root_node.children[path], method=method)
                        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\gangstand\Desktop\ws.chat\.venv\Lib\site-packages\litestar\_asgi\routing_trie\traversal.py", line 82, in parse_node_handlers
    return node.asgi_handlers[method]
           ~~~~~~~~~~~~~~~~~~^^^^^^^^
KeyError: 'GET'

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "C:\Users\gangstand\Desktop\ws.chat\.venv\Lib\site-packages\litestar\middleware\_internal\exceptions\middleware.py", line 159, in call
    await self.app(scope, receive, capture_response_started)
  File "C:\Users\gangstand\Desktop\ws.chat\.venv\Lib\site-packages\litestar\_asgi\asgi_router.py", line 90, in call
    asgi_app, route_handler, scope["path"], scope["path_params"], path_template = self.handle_routing(
                                                                                  ^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\gangstand\Desktop\ws.chat\.venv\Lib\site-packages\litestar\_asgi\asgi_router.py", line 115, in handle_routing
    return parse_path_to_route(
           ^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\gangstand\Desktop\ws.chat\.venv\Lib\site-packages\litestar\_asgi\routing_trie\traversal.py", line 173, in parse_path_to_route
    raise MethodNotAllowedException() from e
litestar.exceptions.http_exceptions.MethodNotAllowedException: 405: Method Not Allowed

Litestar Version

[tool.poetry] name = "app" version = "0.1.0" description = "WebSocket connection fails due to 'GET' method being sent instead of None (Litestar expects None)" authors = ["gangstand ganggstand@gmail.com"]

[tool.poetry.dependencies] python = "^3.12" socketify = "^0.0.28" litestar = "^2.12.1" granian = "^1.6.1" uvicorn = "^0.32.0" websockets = "^13.1"

[tool.poetry.dev-dependencies] ruff = "" isort = "" mypy = "*"

[build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api"

[tool.ruff] fix = true unsafe-fixes = true line-length = 120

[tool.ruff.format] docstring-code-format = true

[tool.ruff.lint] select = ["ALL"] ignore = ["EM", "FBT", "TRY003", "D1", "D203", "D213", "G004", "FA", "COM812", "ISC001", "PLR0913"]

[tool.ruff.lint.isort] no-lines-before = ["standard-library", "local-folder"] known-third-party = [] known-local-folder = [] lines-after-imports = 2

[tool.ruff.lint.extend-per-file-ignores] "tests/*.py" = ["S101", "S311"]

[tool.coverage.report] exclude_also = ["if typing.TYPE_CHECKING:"]

Platform


[!NOTE]
While we are open for sponsoring on GitHub Sponsors and OpenCollective, we also utilize Polar.sh to engage in pledge-based sponsorship.

Check out all issues funded or available for funding on our Polar.sh dashboard

  • If you would like to see an issue prioritized, make a pledge towards it!
  • We receive the pledge once the issue is completed & verified
  • This, along with engagement in the community, helps us know which features are a priority to our users.

Fund with Polar

gangstand commented 1 week ago

The same issue occurs when using Granian as the ASGI server. When I run the application with Granian, I encounter the same MethodNotAllowedException related to sending a 'GET' method when attempting to establish a WebSocket connection. Here are the logs from Granian:

granian --interface asgi main:litestar_app
[INFO] Starting granian (main PID: 15764)
[INFO] Listening at: http://127.0.0.1:8000
[INFO] Spawning worker-1 with pid: 15292
[INFO] Started worker-1
[INFO] Started worker-1 runtime-1
ERROR - 2024-10-25 11:37:30,842 - litestar - config - Uncaught exception (connection_type=websocket, path=/):
Traceback (most recent call last):
  File "C:\Users\gangstand\Desktop\ws.chat\.venv\Lib\site-packages\litestar\_asgi\routing_trie\traversal.py", line 136, in parse_path_to_route
    asgi_app, handler = parse_node_handlers(node=root_node.children[path], method=method)
                        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\gangstand\Desktop\ws.chat\.venv\Lib\site-packages\litestar\_asgi\routing_trie\traversal.py", line 82, in parse_node_handlers
    return node.asgi_handlers[method]
           ~~~~~~~~~~~~~~~~~~^^^^^^^^
KeyError: 'GET'

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "C:\Users\gangstand\Desktop\ws.chat\.venv\Lib\site-packages\litestar\middleware\_internal\exceptions\middleware.py", line 159, in __call__
    await self.app(scope, receive, capture_response_started)
  File "C:\Users\gangstand\Desktop\ws.chat\.venv\Lib\site-packages\litestar\_asgi\asgi_router.py", line 90, in __call__
    asgi_app, route_handler, scope["path"], scope["path_params"], path_template = self.handle_routing(
                                                                                  ^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\gangstand\Desktop\ws.chat\.venv\Lib\site-packages\litestar\_asgi\asgi_router.py", line 115, in handle_routing
    return parse_path_to_route(
           ^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\gangstand\Desktop\ws.chat\.venv\Lib\site-packages\litestar\_asgi\routing_trie\traversal.py", line 173, in parse_path_to_route
    raise MethodNotAllowedException() from e
litestar.exceptions.http_exceptions.MethodNotAllowedException: 405: Method Not Allowed

Thus, the problem is reproducible with Granian as well, where the WebSocket connection sends a 'GET' method instead of the expected None.

soltanoff commented 1 week ago

According to RFC 6455, which defines the WebSocket protocol, the connection is indeed initiated with an HTTP GET request.

This initial HTTP request is used to establish the connection and includes specific headers required to successfully switch to the WebSocket protocol.

   The handshake from the client looks as follows:

        GET /chat HTTP/1.1
        Host: server.example.com
        Upgrade: websocket
        Connection: Upgrade
        Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
        Origin: http://example.com
        Sec-WebSocket-Protocol: chat, superchat
        Sec-WebSocket-Version: 13

   The handshake from the server looks as follows:

        HTTP/1.1 101 Switching Protocols
        Upgrade: websocket
        Connection: Upgrade
        Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
        Sec-WebSocket-Protocol: chat

   The leading line from the client follows the Request-Line format.
   The leading line from the server follows the Status-Line format.  The
   Request-Line and Status-Line productions are defined in RFC2616 (https://datatracker.ietf.org/doc/html/rfc2616).

Please correct me if I'm wrong.

provinzkraut commented 1 week ago

I think the bug lies with socketify / granian here. If you take a look at the ASGI specs, the websocket scope does not define a method key: https://asgi.readthedocs.io/en/latest/specs/www.html#websocket-connection-scope.

The RFC you are referring to is the initial request, which isn't handled by the ASGI app, but the ASGI server.