diff --git a/changelogs/1904.feature.rst b/changelogs/1904.feature.rst new file mode 100644 index 00000000..2a668498 --- /dev/null +++ b/changelogs/1904.feature.rst @@ -0,0 +1,3 @@ +Adds WEBSOCKET_PING_TIMEOUT and WEBSOCKET_PING_INTERVAL configuration values + +Allows setting the ping_interval and ping_timeout arguments when initializing `WebSocketCommonProtocol`. \ No newline at end of file diff --git a/docs/sanic/config.rst b/docs/sanic/config.rst index 4c24ddc4..a5d1baf7 100644 --- a/docs/sanic/config.rst +++ b/docs/sanic/config.rst @@ -98,7 +98,7 @@ The config files are regular Python files which are executed in order to load th Builtin Configuration Values ---------------------------- -Out of the box there are just a few predefined values which can be overwritten when creating the application. +Out of the box there are just a few predefined values which can be overwritten when creating the application. Note that websocket configuration values will have no impact if running in ASGI mode. +---------------------------+-------------------+-----------------------------------------------------------------------------+ | Variable | Default | Description | @@ -123,6 +123,10 @@ Out of the box there are just a few predefined values which can be overwritten w +---------------------------+-------------------+-----------------------------------------------------------------------------+ | WEBSOCKET_WRITE_LIMIT | 2^16 | High-water limit of the buffer for outgoing bytes | +---------------------------+-------------------+-----------------------------------------------------------------------------+ +| WEBSOCKET_PING_INTERVAL | 20 | A Ping frame is sent every ping_interval seconds. | ++---------------------------+-------------------+-----------------------------------------------------------------------------+ +| WEBSOCKET_PING_TIMEOUT | 20 | Connection is closed when Pong is not received after ping_timeout seconds | ++---------------------------+-------------------+-----------------------------------------------------------------------------+ | GRACEFUL_SHUTDOWN_TIMEOUT | 15.0 | How long to wait to force close non-idle connection (sec) | +---------------------------+-------------------+-----------------------------------------------------------------------------+ | ACCESS_LOG | True | Disable or enable access log | diff --git a/docs/sanic/websocket.rst b/docs/sanic/websocket.rst index 3421e734..bff53ef6 100644 --- a/docs/sanic/websocket.rst +++ b/docs/sanic/websocket.rst @@ -51,5 +51,9 @@ You could setup your own WebSocket configuration through ``app.config``, like app.config.WEBSOCKET_MAX_QUEUE = 32 app.config.WEBSOCKET_READ_LIMIT = 2 ** 16 app.config.WEBSOCKET_WRITE_LIMIT = 2 ** 16 + app.config.WEBSOCKET_PING_INTERVAL = 20 + app.config.WEBSOCKET_PING_TIMEOUT = 20 + +These settings will have no impact if running in ASGI mode. Find more in ``Configuration`` section. diff --git a/sanic/config.py b/sanic/config.py index 307c7b5f..3fbc157f 100644 --- a/sanic/config.py +++ b/sanic/config.py @@ -24,6 +24,8 @@ DEFAULT_CONFIG = { "WEBSOCKET_MAX_QUEUE": 32, "WEBSOCKET_READ_LIMIT": 2 ** 16, "WEBSOCKET_WRITE_LIMIT": 2 ** 16, + "WEBSOCKET_PING_TIMEOUT": 20, + "WEBSOCKET_PING_INTERVAL": 20, "GRACEFUL_SHUTDOWN_TIMEOUT": 15.0, # 15 sec "ACCESS_LOG": True, "FORWARDED_SECRET": None, diff --git a/sanic/server.py b/sanic/server.py index 6adf5dde..c4e08e76 100644 --- a/sanic/server.py +++ b/sanic/server.py @@ -14,11 +14,13 @@ from ipaddress import ip_address from signal import SIG_IGN, SIGINT, SIGTERM, Signals from signal import signal as signal_func from time import time +from typing import Type from httptools import HttpRequestParser # type: ignore from httptools.parser.errors import HttpParserError # type: ignore from sanic.compat import Header, ctrlc_workaround_for_windows +from sanic.config import Config from sanic.exceptions import ( HeaderExpectationFailed, InvalidUsage, @@ -844,6 +846,7 @@ def serve( app.asgi = False connections = connections if connections is not None else set() + protocol_kwargs = _build_protocol_kwargs(protocol, app.config) server = partial( protocol, loop=loop, @@ -852,6 +855,7 @@ def serve( app=app, state=state, unix=unix, + **protocol_kwargs, ) asyncio_server_kwargs = ( asyncio_server_kwargs if asyncio_server_kwargs else {} @@ -948,6 +952,21 @@ def serve( remove_unix_socket(unix) +def _build_protocol_kwargs( + protocol: Type[HttpProtocol], config: Config +) -> dict: + if hasattr(protocol, "websocket_timeout"): + return { + "max_size": config.WEBSOCKET_MAX_SIZE, + "max_queue": config.WEBSOCKET_MAX_QUEUE, + "read_limit": config.WEBSOCKET_READ_LIMIT, + "write_limit": config.WEBSOCKET_WRITE_LIMIT, + "ping_timeout": config.WEBSOCKET_PING_TIMEOUT, + "ping_interval": config.WEBSOCKET_PING_INTERVAL, + } + return {} + + def bind_socket(host: str, port: int, *, backlog=100) -> socket.socket: """Create TCP server socket. :param host: IPv4, IPv6 or hostname may be specified diff --git a/sanic/websocket.py b/sanic/websocket.py index 9443b704..0c728908 100644 --- a/sanic/websocket.py +++ b/sanic/websocket.py @@ -35,6 +35,8 @@ class WebSocketProtocol(HttpProtocol): websocket_max_queue=None, websocket_read_limit=2 ** 16, websocket_write_limit=2 ** 16, + websocket_ping_interval=20, + websocket_ping_timeout=20, **kwargs ): super().__init__(*args, **kwargs) @@ -45,6 +47,8 @@ class WebSocketProtocol(HttpProtocol): self.websocket_max_queue = websocket_max_queue self.websocket_read_limit = websocket_read_limit self.websocket_write_limit = websocket_write_limit + self.websocket_ping_interval = websocket_ping_interval + self.websocket_ping_timeout = websocket_ping_timeout # timeouts make no sense for websocket routes def request_timeout_callback(self): @@ -119,6 +123,8 @@ class WebSocketProtocol(HttpProtocol): max_queue=self.websocket_max_queue, read_limit=self.websocket_read_limit, write_limit=self.websocket_write_limit, + ping_interval=self.websocket_ping_interval, + ping_timeout=self.websocket_ping_timeout, ) # Following two lines are required for websockets 8.x self.websocket.is_client = False diff --git a/tests/test_app.py b/tests/test_app.py index c7791394..6e1d28ff 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1,6 +1,7 @@ import asyncio import logging import sys +from unittest.mock import patch from inspect import isawaitable @@ -148,6 +149,35 @@ def test_app_enable_websocket(app, websocket_enabled, enable): assert app.websocket_enabled == True +@patch("sanic.app.WebSocketProtocol") +def test_app_websocket_parameters(websocket_protocol_mock, app): + app.config.WEBSOCKET_MAX_SIZE = 44 + app.config.WEBSOCKET_MAX_QUEUE = 45 + app.config.WEBSOCKET_READ_LIMIT = 46 + app.config.WEBSOCKET_WRITE_LIMIT = 47 + app.config.WEBSOCKET_PING_TIMEOUT = 48 + app.config.WEBSOCKET_PING_INTERVAL = 50 + + @app.websocket("/ws") + async def handler(request, ws): + await ws.send("test") + + try: + # This will fail because WebSocketProtocol is mocked and only the call kwargs matter + app.test_client.get("/ws") + except: + pass + + websocket_protocol_call_args = websocket_protocol_mock.call_args + ws_kwargs = websocket_protocol_call_args[1] + assert ws_kwargs["max_size"] == app.config.WEBSOCKET_MAX_SIZE + assert ws_kwargs["max_queue"] == app.config.WEBSOCKET_MAX_QUEUE + assert ws_kwargs["read_limit"] == app.config.WEBSOCKET_READ_LIMIT + assert ws_kwargs["write_limit"] == app.config.WEBSOCKET_WRITE_LIMIT + assert ws_kwargs["ping_timeout"] == app.config.WEBSOCKET_PING_TIMEOUT + assert ws_kwargs["ping_interval"] == app.config.WEBSOCKET_PING_INTERVAL + + def test_handle_request_with_nested_exception(app, monkeypatch): err_msg = "Mock Exception"