Skip to content

Commit bbe0456

Browse files
committed
Add timeout around request / response id matching while loop:
- Use the call timeout for the provider to set up a reasonable escape time for the while look that tries to match the response `id` with the request `id`. All providers should technically return matching ids in the response but we've run into issues with this in the past and this prevents an infinite loop, handling it somewhat gracefully. - Make sure that the ``WebsocketProviderV2`` sets a default timeout.
1 parent 1081e80 commit bbe0456

File tree

3 files changed

+73
-23
lines changed

3 files changed

+73
-23
lines changed

tests/core/providers/test_wsv2_provider.py

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66
to_bytes,
77
)
88

9+
from web3.exceptions import (
10+
TimeExhausted,
11+
)
912
from web3.providers.websocket import (
1013
WebsocketProviderV2,
1114
)
@@ -14,24 +17,28 @@
1417
)
1518

1619

20+
def _mock_ws(provider):
21+
# move to top of file when python 3.7 is no longer supported in web3.py
22+
from unittest.mock import (
23+
AsyncMock,
24+
)
25+
26+
provider._ws = AsyncMock()
27+
28+
1729
@pytest.mark.asyncio
1830
@pytest.mark.skipif(
1931
# TODO: remove when python 3.7 is no longer supported in web3.py
2032
# python 3.7 is already sunset so this feels like a reasonable tradeoff
2133
sys.version_info < (3, 8),
22-
reason="Mock args behave differently in python 3.7 but test should still pass.",
34+
reason="Uses AsyncMock, not supported by python 3.7",
2335
)
2436
async def test_async_make_request_caches_all_undesired_responses_and_returns_desired():
25-
# move to top of file when python 3.7 is no longer supported in web3.py
26-
from unittest.mock import (
27-
AsyncMock,
28-
)
29-
3037
provider = WebsocketProviderV2("ws://mocked")
3138

3239
method_under_test = provider.make_request
3340

34-
provider._ws = AsyncMock()
41+
_mock_ws(provider)
3542
undesired_responses_count = 10
3643
ws_recv_responses = [
3744
to_bytes(
@@ -90,3 +97,25 @@ async def test_async_make_request_returns_cached_response_with_no_recv_if_cached
9097

9198
assert len(provider._request_processor._raw_response_cache) == 0
9299
assert not provider._ws.recv.called # type: ignore
100+
101+
102+
@pytest.mark.asyncio
103+
@pytest.mark.skipif(
104+
# TODO: remove when python 3.7 is no longer supported in web3.py
105+
# python 3.7 is already sunset so this feels like a reasonable tradeoff
106+
sys.version_info < (3, 8),
107+
reason="Uses AsyncMock, not supported by python 3.7",
108+
)
109+
async def test_async_make_request_times_out_of_while_loop_looking_for_response():
110+
provider = WebsocketProviderV2("ws://mocked", call_timeout=0.1)
111+
112+
method_under_test = provider.make_request
113+
114+
_mock_ws(provider)
115+
provider._ws.recv.side_effect = lambda *args, **kwargs: b'{"jsonrpc": "2.0"}'
116+
117+
with pytest.raises(
118+
TimeExhausted,
119+
match="Timed out waiting for response to request id `0` after 0.1 seconds.",
120+
):
121+
await method_under_test(RPCEndpoint("some_method"), ["desired_params"])

web3/providers/persistent.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ def __init__(
3434
self,
3535
endpoint_uri: str,
3636
request_cache_size: int = 100,
37-
call_timeout: int = DEFAULT_PERSISTENT_CONNECTION_TIMEOUT,
37+
call_timeout: float = DEFAULT_PERSISTENT_CONNECTION_TIMEOUT,
3838
) -> None:
3939
super().__init__()
4040
self.endpoint_uri = endpoint_uri

web3/providers/websocket/websocket_v2.py

Lines changed: 36 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,11 @@
2727
)
2828
from web3.exceptions import (
2929
ProviderConnectionError,
30+
TimeExhausted,
3031
Web3ValidationError,
3132
)
3233
from web3.providers.persistent import (
34+
DEFAULT_PERSISTENT_CONNECTION_TIMEOUT,
3335
PersistentConnectionProvider,
3436
)
3537
from web3.types import (
@@ -64,7 +66,7 @@ def __init__(
6466
self,
6567
endpoint_uri: Optional[Union[URI, str]] = None,
6668
websocket_kwargs: Optional[Dict[str, Any]] = None,
67-
call_timeout: Optional[int] = None,
69+
call_timeout: Optional[float] = DEFAULT_PERSISTENT_CONNECTION_TIMEOUT,
6870
) -> None:
6971
self.endpoint_uri = URI(endpoint_uri)
7072
if self.endpoint_uri is None:
@@ -167,21 +169,40 @@ async def make_request(self, method: RPCEndpoint, params: Any) -> RPCResponse:
167169
return response
168170

169171
async def _get_response_for_request_id(self, request_id: RPCId) -> RPCResponse:
170-
response_id = None
171-
response = None
172-
while response_id != request_id:
173-
response = await self._ws_recv()
174-
response_id = response.get("id")
172+
async def _match_response_id_to_request_id() -> RPCResponse:
173+
response_id = None
174+
response = None
175+
while response_id != request_id:
176+
response = await self._ws_recv()
177+
response_id = response.get("id")
178+
179+
if response_id == request_id:
180+
break
181+
else:
182+
# cache all responses that are not the desired response
183+
await self._request_processor.cache_raw_response(
184+
response,
185+
)
186+
await asyncio.sleep(0.1)
187+
188+
return response
175189

176-
if response_id == request_id:
177-
break
178-
else:
179-
# cache all responses that are not the desired response
180-
await self._request_processor.cache_raw_response(
181-
response,
182-
)
183-
184-
return response
190+
try:
191+
# Enters a while loop, looking for a response id match to the request id.
192+
# If the provider does not give responses with matching ids, this will
193+
# hang forever. The JSON-RPC spec requires that providers respond with
194+
# the same id that was sent in the request, but we need to handle these
195+
# "bad" cases somewhat gracefully.
196+
return await asyncio.wait_for(
197+
_match_response_id_to_request_id(), self.call_timeout
198+
)
199+
except asyncio.TimeoutError:
200+
raise TimeExhausted(
201+
f"Timed out waiting for response to request id `{request_id}` after "
202+
f"{self.call_timeout} seconds. This is likely due to the provider not "
203+
"issuing a response with the same id that was sent in the request, "
204+
"which is required by the JSON-RPC spec."
205+
)
185206

186207
async def _ws_recv(self) -> RPCResponse:
187208
return json.loads(

0 commit comments

Comments
 (0)