Skip to content

Commit a6e7807

Browse files
committed
handle when a client might return an error unrelated to a request:
Handle the edge case when a client returns an error that is not related to a request. This was reported from a user having received a response for a request `id` followed by an error with the same response `id` but we were no longer looking for that `id` since the response was already processed. This is on the client, but we should handle it gracefully within the library without hanging indefinitely. - Refactor rpc response validation to ``_utils/validation.py`` and use this in `PersistentConnectionProvider` to handle any errors that are found in the cache. Refactor applying error and null formatters to ``_utils/formatters.py`` - Add a test case that covers a similar scenario to what the user reported. - Add a newsfragment for this change.
1 parent 59aefe0 commit a6e7807

File tree

8 files changed

+281
-209
lines changed

8 files changed

+281
-209
lines changed

newsfragments/3623.misc.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Raise stray errors a client may send that is not tied to any outstanding requests.

tests/core/providers/test_async_ipc_provider.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -360,7 +360,7 @@ async def test_async_ipc_provider_write_messages_end_with_new_line_delimiter(
360360
async with AsyncWeb3(AsyncIPCProvider(pathlib.Path(jsonrpc_ipc_pipe_path))) as w3:
361361
w3.provider._writer.write = Mock()
362362
w3.provider._reader.readline = AsyncMock(
363-
return_value=b'{"id": 0, "result": {}}\n'
363+
return_value=b'{"id": 0, "jsonrpc": "2.0", "result": {}}\n'
364364
)
365365

366366
await w3.provider.make_request("method", [])

tests/core/providers/test_ipc_provider.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,9 @@ def test_web3_auto_gethdev(request_mocker):
191191
def test_ipc_provider_write_messages_end_with_new_line_delimiter(jsonrpc_ipc_pipe_path):
192192
provider = IPCProvider(pathlib.Path(jsonrpc_ipc_pipe_path), timeout=3)
193193
provider._socket.sock = Mock()
194-
provider._socket.sock.recv.return_value = b'{"id":1, "result": {}}\n'
194+
provider._socket.sock.recv.return_value = (
195+
b'{"id":0, "jsonrpc": "2.0", "result": {}}\n'
196+
)
195197

196198
provider.make_request("method", [])
197199

tests/core/providers/test_websocket_provider.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -493,3 +493,20 @@ async def test_websocket_provider_use_text_frames(use_text_frames, expected_send
493493

494494
await provider.make_request(RPCEndpoint("eth_getBlockByNumber"), ["latest", False])
495495
provider._ws.send.assert_called_once_with(expected_send_arg)
496+
497+
498+
@pytest.mark.asyncio
499+
async def test_websocket_provider_raises_errors_from_cache_not_tied_to_a_request():
500+
with patch(
501+
"web3.providers.persistent.websocket.connect",
502+
new=lambda *_1, **_2: WebSocketMessageStreamMock(
503+
messages=[
504+
b'{"id": 0, "jsonrpc": "2.0", "result": "0x0"}\n',
505+
b'{"id": null, "jsonrpc": "2.0", "error": {"code": 21, "message": "Request shutdown"}}\n', # noqa: E501
506+
]
507+
),
508+
):
509+
async_w3 = await AsyncWeb3(WebSocketProvider("ws://mocked"))
510+
with pytest.raises(Web3RPCError, match="Request shutdown"):
511+
await asyncio.sleep(0.1)
512+
await async_w3.eth.block_number

web3/_utils/formatters.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
Callable,
77
Dict,
88
Iterable,
9+
Optional,
910
Tuple,
1011
TypeVar,
1112
)
@@ -26,11 +27,15 @@
2627
compose,
2728
curry,
2829
dissoc,
30+
pipe,
2931
)
3032

3133
from web3._utils.decorators import (
3234
reject_recursive_repeats,
3335
)
36+
from web3.types import (
37+
RPCResponse,
38+
)
3439

3540
TReturn = TypeVar("TReturn")
3641
TValue = TypeVar("TValue")
@@ -131,3 +136,26 @@ def remove_key_if(
131136
return dissoc(input_dict, key)
132137
else:
133138
return input_dict
139+
140+
141+
def apply_error_formatters(
142+
error_formatters: Callable[..., Any],
143+
response: RPCResponse,
144+
) -> RPCResponse:
145+
if error_formatters:
146+
formatted_resp = pipe(response, error_formatters)
147+
return formatted_resp
148+
else:
149+
return response
150+
151+
152+
def apply_null_result_formatters(
153+
null_result_formatters: Callable[..., Any],
154+
response: RPCResponse,
155+
params: Optional[Any] = None,
156+
) -> RPCResponse:
157+
if null_result_formatters:
158+
formatted_resp = pipe(params, null_result_formatters)
159+
return formatted_resp
160+
else:
161+
return response

web3/_utils/validation.py

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import itertools
2+
import logging
23
from typing import (
34
Any,
5+
Callable,
46
Dict,
7+
NoReturn,
8+
Optional,
59
)
610

711
from eth_typing import (
@@ -53,11 +57,22 @@
5357
length_of_array_type,
5458
sub_type_of_array_type,
5559
)
60+
from web3._utils.formatters import (
61+
apply_error_formatters,
62+
)
5663
from web3.exceptions import (
64+
BadResponseFormat,
5765
InvalidAddress,
66+
MethodUnavailable,
67+
RequestTimedOut,
68+
TransactionNotFound,
69+
Web3RPCError,
5870
Web3TypeError,
5971
Web3ValueError,
6072
)
73+
from web3.types import (
74+
RPCResponse,
75+
)
6176

6277

6378
def _prepare_selector_collision_msg(duplicates: Dict[HexStr, ABIFunction]) -> str:
@@ -211,3 +226,179 @@ def assert_one_val(*args: Any, **kwargs: Any) -> None:
211226
"Exactly one of the passed values can be specified. "
212227
f"Instead, values were: {args!r}, {kwargs!r}"
213228
)
229+
230+
231+
# -- RPC Response Validation -- #
232+
233+
KNOWN_REQUEST_TIMEOUT_MESSAGING = {
234+
# Note: It's important to be very explicit here and not too broad. We don't want
235+
# to accidentally catch a message that is not for a request timeout. In the worst
236+
# case, we raise something more generic like `Web3RPCError`. JSON-RPC unfortunately
237+
# has not standardized error codes for request timeouts.
238+
"request timed out", # go-ethereum
239+
}
240+
METHOD_NOT_FOUND = -32601
241+
242+
243+
def _validate_subscription_fields(response: RPCResponse) -> None:
244+
params = response["params"]
245+
subscription = params["subscription"]
246+
if not isinstance(subscription, str) and not len(subscription) == 34:
247+
_raise_bad_response_format(
248+
response, "eth_subscription 'params' must include a 'subscription' field."
249+
)
250+
251+
252+
def _raise_bad_response_format(response: RPCResponse, error: str = "") -> None:
253+
message = "The response was in an unexpected format and unable to be parsed."
254+
raw_response = f"The raw response is: {response}"
255+
256+
if error is not None and error != "":
257+
error = error[:-1] if error.endswith(".") else error
258+
message = f"{message} {error}. {raw_response}"
259+
else:
260+
message = f"{message} {raw_response}"
261+
262+
raise BadResponseFormat(message)
263+
264+
265+
def raise_error_for_batch_response(
266+
response: RPCResponse,
267+
logger: Optional[logging.Logger] = None,
268+
) -> NoReturn:
269+
error = response.get("error")
270+
if error is None:
271+
_raise_bad_response_format(
272+
response,
273+
"Batch response must be formatted as a list of responses or "
274+
"as a single JSON-RPC error response.",
275+
)
276+
validate_rpc_response_and_raise_if_error(
277+
response,
278+
None,
279+
is_subscription_response=False,
280+
logger=logger,
281+
params=[],
282+
)
283+
# This should not be reached, but if it is, raise a generic `BadResponseFormat`
284+
raise BadResponseFormat(
285+
"Batch response was in an unexpected format and unable to be parsed."
286+
)
287+
288+
289+
def validate_rpc_response_and_raise_if_error(
290+
response: RPCResponse,
291+
error_formatters: Optional[Callable[..., Any]],
292+
is_subscription_response: bool = False,
293+
logger: Optional[logging.Logger] = None,
294+
params: Optional[Any] = None,
295+
) -> None:
296+
if "jsonrpc" not in response or response["jsonrpc"] != "2.0":
297+
_raise_bad_response_format(
298+
response, 'The "jsonrpc" field must be present with a value of "2.0".'
299+
)
300+
301+
response_id = response.get("id")
302+
if "id" in response:
303+
int_error_msg = (
304+
'"id" must be an integer or a string representation of an integer.'
305+
)
306+
if response_id is None and "error" in response:
307+
# errors can sometimes have null `id`, according to the JSON-RPC spec
308+
pass
309+
elif not isinstance(response_id, (str, int)):
310+
_raise_bad_response_format(response, int_error_msg)
311+
elif isinstance(response_id, str):
312+
try:
313+
int(response_id)
314+
except ValueError:
315+
_raise_bad_response_format(response, int_error_msg)
316+
elif is_subscription_response:
317+
# if `id` is not present, this must be a subscription response
318+
_validate_subscription_fields(response)
319+
else:
320+
_raise_bad_response_format(
321+
response,
322+
'Response must include an "id" field or be formatted as an '
323+
"`eth_subscription` response.",
324+
)
325+
326+
if all(key in response for key in {"error", "result"}):
327+
_raise_bad_response_format(
328+
response, 'Response cannot include both "error" and "result".'
329+
)
330+
elif (
331+
not any(key in response for key in {"error", "result"})
332+
and not is_subscription_response
333+
):
334+
_raise_bad_response_format(
335+
response, 'Response must include either "error" or "result".'
336+
)
337+
elif "error" in response:
338+
web3_rpc_error: Optional[Web3RPCError] = None
339+
error = response["error"]
340+
341+
# raise the error when the value is a string
342+
if error is None or not isinstance(error, dict):
343+
_raise_bad_response_format(
344+
response,
345+
'response["error"] must be a valid object as defined by the '
346+
"JSON-RPC 2.0 specification.",
347+
)
348+
349+
# errors must include a message
350+
error_message = error.get("message")
351+
if not isinstance(error_message, str):
352+
_raise_bad_response_format(
353+
response, 'error["message"] is required and must be a string value.'
354+
)
355+
elif error_message == "transaction not found":
356+
transaction_hash = params[0]
357+
web3_rpc_error = TransactionNotFound(
358+
repr(error),
359+
rpc_response=response,
360+
user_message=(f"Transaction with hash {transaction_hash!r} not found."),
361+
)
362+
363+
# errors must include an integer code
364+
code = error.get("code")
365+
if not isinstance(code, int):
366+
_raise_bad_response_format(
367+
response, 'error["code"] is required and must be an integer value.'
368+
)
369+
elif code == METHOD_NOT_FOUND:
370+
web3_rpc_error = MethodUnavailable(
371+
repr(error),
372+
rpc_response=response,
373+
user_message=(
374+
"This method is not available. Check your node provider or your "
375+
"client's API docs to see what methods are supported and / or "
376+
"currently enabled."
377+
),
378+
)
379+
elif any(
380+
# parse specific timeout messages
381+
timeout_str in error_message.lower()
382+
for timeout_str in KNOWN_REQUEST_TIMEOUT_MESSAGING
383+
):
384+
web3_rpc_error = RequestTimedOut(
385+
repr(error),
386+
rpc_response=response,
387+
user_message=(
388+
"The request timed out. Check the connection to your node and "
389+
"try again."
390+
),
391+
)
392+
393+
if web3_rpc_error is None:
394+
# if no condition was met above, raise a more generic `Web3RPCError`
395+
web3_rpc_error = Web3RPCError(repr(error), rpc_response=response)
396+
397+
response = apply_error_formatters(error_formatters, response)
398+
if logger is not None:
399+
logger.debug(f"RPC error response: {response}")
400+
401+
raise web3_rpc_error
402+
403+
elif "result" not in response and not is_subscription_response:
404+
_raise_bad_response_format(response)

0 commit comments

Comments
 (0)