Repository: ethereum/web3.py

Issue:
Raise custom exception instead of ValueError when a method is unavailable at the node
* Version: 5.29
* Python: 3.9
* OS: linux
* `pip freeze` output

```
aiohttp==3.8.1
aiosignal==1.2.0
alabaster==0.7.12
altgraph==0.17.2
aniso8601==9.0.1
asn1crypto==1.5.1
astroid==2.11.3
astunparse==1.6.3
async-timeout==4.0.2
attrs==21.4.0
Babel==2.9.1
base58==2.1.1
base58check==1.0.2
beautifulsoup4==4.11.1
bech32==1.2.0
bitarray==1.2.2
bump2version==1.0.1
cachetools==5.0.0
certifi==2021.10.8
cffi==1.15.0
charset-normalizer==2.0.12
click==8.0.4
coincurve==17.0.0
colorama==0.4.4
coverage==6.3.2
cryptography==36.0.1
cytoolz==0.11.2
dill==0.3.4
docutils==0.17.1
ecdsa==0.17.0
elpy==1.999
eth-abi==2.1.1
eth-account==0.5.7
eth-hash==0.3.2
eth-keyfile==0.5.1
eth-keys==0.3.4
eth-rlp==0.2.1
eth-typing==2.3.0
eth-utils==1.10.0
fancycompleter==0.9.1
flake8==4.0.1
flake8-bugbear==22.3.23
flake8-commas==2.1.0
flake8-comprehensions==3.8.0
flake8-debugger==4.0.0
flake8-executable==2.1.1
flake8-mutable==1.2.0
flake8-polyfill==1.0.2
flake8-tuple==0.4.1
flaky==3.7.0
Flask==2.1.1
Flask-Cors==3.0.10
Flask-RESTful==0.3.9
freezegun==1.2.1
frozenlist==1.3.0
gevent==21.12.0
gevent-websocket==0.10.1
google-api-core==2.7.2
google-api-python-client==2.45.0
google-auth==2.6.5
google-auth-httplib2==0.1.0
googleapis-common-protos==1.56.0
gql==2.0.0
graphql-core==2.3.2
greenlet==1.1.2
hexbytes==0.2.2
httplib2==0.20.4
idna==3.3
imagesize==1.3.0
importlib-metadata==4.11.3
iniconfig==1.1.1
ipfshttpclient==0.8.0a2
isort==5.10.1
itsdangerous==2.1.1
jedi==0.17.2
Jinja2==3.0.3
jsonschema==3.2.0
lazy-object-proxy==1.7.1
livereload==2.6.3
lru-dict==1.1.7
MarkupSafe==2.1.1
marshmallow==3.15.0
maxminddb==2.2.0
mccabe==0.6.1
miniupnpc==2.0.2
more-itertools==8.12.0
multiaddr==0.0.9
multidict==6.0.2
mypy==0.942
mypy-extensions==0.4.3
netaddr==0.8.0
oauthlib==3.2.0
packaging==21.3
parsimonious==0.8.1
parso==0.7.1
pdbpp==0.10.3
pep8-naming==0.12.1
pipdeptree==2.2.1
platformdirs==2.5.1
pluggy==1.0.0
promise==2.3
protobuf==3.19.4
psutil==5.9.0
py==1.11.0
py-bip39-bindings==0.1.8
py-ed25519-bindings==1.0.1
py-sr25519-bindings==0.1.4
pyasn1==0.4.8
pyasn1-modules==0.2.8
pycodestyle==2.8.0
pycparser==2.21
pycryptodome==3.14.1
pyflakes==2.4.0
Pygments==2.11.2
pyinstaller==4.8
pyinstaller-hooks-contrib==2022.3
pylint==2.13.7
pyparsing==3.0.7
pyrepl==0.9.0
pyrsistent==0.18.1
pysqlcipher3==1.0.4
pytest==7.1.1
pytest-cov==3.0.0
pytest-freezegun==0.4.2
python-dateutil==2.8.2
pytz==2022.1
requests==2.27.1
requests-oauthlib==1.3.1
rlp==2.0.1
-e git+ssh://git@github.com/LefterisJP/rotkehlchen.git@033970e26f80fe51eab4b2fd9d9e3396ed33cb1c#egg=rotkehlchen
rotki-releases==2.0.1
rsa==4.8
Rx==1.6.1
scalecodec==1.0.34
semantic-version==2.6.0
six==1.16.0
snowballstemmer==2.2.0
soupsieve==2.3.1
Sphinx==4.4.0
sphinx-autobuild==2021.3.14
sphinx-rtd-theme==1.0.0
sphinxcontrib-applehelp==1.0.2
sphinxcontrib-devhelp==1.0.2
sphinxcontrib-htmlhelp==2.0.0
sphinxcontrib-httpdomain==1.8.0
sphinxcontrib-httpexample==1.1
sphinxcontrib-jsmath==1.0.1
sphinxcontrib-qthelp==1.0.3
sphinxcontrib-serializinghtml==1.1.5
substrate-interface==1.2.2
toml==0.10.2
tomli==2.0.1
toolz==0.11.2
tornado==6.1
types-chardet==4.0.3
types-cryptography==3.3.19
types-enum34==1.1.8
types-ipaddress==1.0.8
types-pkg-resources==0.1.3
types-requests==2.27.16
types-toml==0.10.4
types-urllib3==1.26.11
typing_extensions==4.1.1
uritemplate==4.1.1
urllib3==1.26.9
varint==1.0.2
vulture==2.3
web3==5.28.0
webargs==8.1.0
websocket-client==1.3.1
websockets==9.1
Werkzeug==2.0.3
wmctrl==0.4
wrapt==1.13.3
wsaccel==0.6.3
xxhash==2.0.2
yarl==1.7.2
zipp==3.8.0
zope.event==4.5.0
zope.interface==5.4.0
```


### What was wrong?

Please include any of the following that are applicable:

* The code which produced the error

Make this call via web3.py

```
curl -s --data '{"jsonrpc":"2.0","method":"eth_getTransactionByHash","params":["0xb2fea9c4b24775af6990237aa90228e5e092c56bdaee74496992a53c208da1ee"],"id":"1"}' -H "Content-Type: application/json" -X POST https://nodes.mewapi.io/rpc/eth
```



* The full output of the error

```
[28/04/2022 17:43:22 CEST] DEBUG web3.providers.HTTPProvider Getting response HTTP. URI: https://nodes.mewapi.io/rpc/eth, Method: eth_getTransactionByHash, Response: {'jsonrpc': '2.0', 'id': 42, 'error': {'code': -32601, 'message': 'the method eth_getTransactionByHash does not exist/is not available'}}
[28/04/2022 17:43:22 CEST] ERROR rotkehlchen.greenlets Greenlet with id 140152317009056: Query ethereum transactions for 0x9ba961989Dd6609Ed091f512bE947118c40F2291 died with exception: {'code': -32601, 'message': 'the method eth_getTransactionByHash does not exist/is not available'}.
Exception Name: <class 'ValueError'>
Exception Info: {'code': -32601, 'message': 'the method eth_getTransactionByHash does not exist/is not available'}
Traceback:
   File "src/gevent/greenlet.py", line 906, in gevent._gevent_cgreenlet.Greenlet.run
  File "/home/yabirgb/work/rotkib/rotkehlchen/chain/ethereum/transactions.py", line 87, in single_address_query_transactions
    self._get_erc20_transfers_for_ranges(address=address, ranges_to_query=ranges_to_query)
  File "/home/yabirgb/work/rotkib/rotkehlchen/chain/ethereum/transactions.py", line 223, in _get_erc20_transfers_for_ranges
    transaction = self.ethereum.get_transaction_by_hash(tx_hash_bytes)
  File "/home/yabirgb/work/rotkib/rotkehlchen/chain/ethereum/manager.py", line 933, in get_transaction_by_hash
    return self.query(
  File "/home/yabirgb/work/rotkib/rotkehlchen/chain/ethereum/manager.py", line 444, in query
    result = method(web3, **kwargs)
  File "/home/yabirgb/work/rotkib/rotkehlchen/chain/ethereum/manager.py", line 917, in _get_transaction_by_hash
    tx_data = web3.eth.get_transaction(tx_hash)  # type: ignore
  File "/home/yabirgb/work/rotkib/.venv/lib/python3.9/site-packages/web3/eth.py", line 724, in get_transaction
    return self._get_transaction(transaction_hash)
  File "/home/yabirgb/work/rotkib/.venv/lib/python3.9/site-packages/web3/module.py", line 57, in caller
    result = w3.manager.request_blocking(method_str,
  File "/home/yabirgb/work/rotkib/.venv/lib/python3.9/site-packages/web3/manager.py", line 198, in request_blocking
    return self.formatted_response(response,
  File "/home/yabirgb/work/rotkib/.venv/lib/python3.9/site-packages/web3/manager.py", line 171, in formatted_response
    raise ValueError(response["error"])
```

* What type of node you were connecting to.

`https://nodes.mewapi.io/rpc/eth`

### How can it be fixed?

I think it would be much better if we, the users of web3, have a different exception to catch when a method is not available in the node, rather than raising a generic `ValueError`.

How about `MethodUnavailable` ?


Discussion:
I like this idea but we'll have to think about how to parse the error response to accommodate the most users. I can't remember if error code numbers are standardized across clients.  I'll add this to our v6 breaking changes issue to discuss with the team. Eventually in v6 we're also planning on adding a `Web3Exception` class that all of our custom exceptions will inherit from which should make things nicer too. 
> Eventually in v6 we're also planning on adding a Web3Exception class that all of our custom exceptions will inherit from which should make things nicer too.

Understood! That would be a really cool change.

Will be looking at the issue to see how it progresses :heart: 

Resolving patch:
diff --git a/web3/exceptions.py b/web3/exceptions.py
--- a/web3/exceptions.py
+++ b/web3/exceptions.py
@@ -287,3 +287,11 @@ class BadResponseFormat(Web3Exception):
     """
 
     pass
+
+
+class MethodUnavailable(Web3Exception):
+    """
+    Raised when the method is not available on the node
+    """
+
+    pass
diff --git a/web3/manager.py b/web3/manager.py
--- a/web3/manager.py
+++ b/web3/manager.py
@@ -34,6 +34,7 @@
 )
 from web3.exceptions import (
     BadResponseFormat,
+    MethodUnavailable,
 )
 from web3.middleware import (
     abi_middleware,
@@ -188,6 +189,13 @@ def formatted_response(
     ) -> Any:
         if "error" in response:
             apply_error_formatters(error_formatters, response)
+
+            # guard against eth-tester case - eth-tester returns a string
+            # with no code, so can't parse what the error is.
+            if isinstance(response["error"], dict):
+                resp_code = response["error"].get("code")
+                if resp_code == -32601:
+                    raise MethodUnavailable(response["error"])
             raise ValueError(response["error"])
         # NULL_RESPONSES includes None, so return False here as the default
         # so we don't apply the null_result_formatters if there is no 'result' key


Test patch:
diff --git a/tests/core/manager/test_response_formatters.py b/tests/core/manager/test_response_formatters.py
--- a/tests/core/manager/test_response_formatters.py
+++ b/tests/core/manager/test_response_formatters.py
@@ -13,6 +13,7 @@
     BadResponseFormat,
     BlockNotFound,
     ContractLogicError,
+    MethodUnavailable,
     TransactionNotFound,
 )
 
@@ -32,6 +33,17 @@
     "message": "You cannot query logs for more than 10000 blocks at once.",
     "method": "eth_getLogs",
 }
+METHOD_NOT_FOUND_RESP_FORMAT = {
+    "jsonrpc": "2.0",
+    "error": {
+        "code": -32601,
+        "message": "the method eth_getTransactionByHash does not exist/is not "
+        "available",
+    },
+}
+ETH_TESTER_METHOD_NOT_FOUND_RESP_FORMAT = {
+    "error": "the method eth_getTransactionByHash does not exist/is not available",
+}
 
 
 def raise_contract_logic_error(response):
@@ -105,6 +117,20 @@ def raise_contract_logic_error(response):
             raise_transaction_not_found,
             TransactionNotFound,
         ),
+        (
+            METHOD_NOT_FOUND_RESP_FORMAT,
+            (),
+            identity,
+            identity,
+            MethodUnavailable,
+        ),
+        (
+            ETH_TESTER_METHOD_NOT_FOUND_RESP_FORMAT,
+            (),
+            identity,
+            identity,
+            ValueError,
+        ),
     ],
 )
 def test_formatted_response_raises_errors(