Repository: rotki/rotki

Issue:
Creating a Kraken API connection with a Kraken user with no balances/transactions fails
## Problem Definition

If the user has a Kraken account that is "empty" (no balances, trades, etc.) and they attempt to create a Kraken API connection with valid API keys, the following happens:
* The backend returns a 500 error to the client
* The user is unable to set up a Kraken connection from this point on as they get an error that Kraken is already registered (it appears as unregistered but providing credentials and clicking "setup" returns the "Exchange kraken is already registered" error).

This appears to happen because the API result from Kraken is malformed during the initial API connection from the backend, as it provides a response of `{'error': []}` which the backend doesn't know how to handle. As a result a 500 internal error occurs, no credentials are registered, but the exchange does appear to be registered (doing a manual GET call to the `/exchanges` endpoint will show this). Following this, the user must perform a manual DELETE call to the `/exchanges` endpoint to delete the erroneous `kraken` exchange before they can create a new connection via the client UI.



```
27/04/2020 17:06:16 W. Europe Daylight Time -- INFO:rotkehlchen.exchanges.exchange:Initialized kraken exchange
27/04/2020 17:06:16 W. Europe Daylight Time -- DEBUG:rotkehlchen.exchanges.kraken:Kraken API query method=Balance, data=None, call_counter=0
27/04/2020 17:06:16 W. Europe Daylight Time -- ERROR:rotkehlchen.api.server:Exception on /api/1/exchanges [PUT]
Traceback (most recent call last):
  File "C:\Users\Gebruiker\Envs\rotkehlchen\lib\site-packages\flask\app.py", line 1949, in full_dispatch_request
    rv = self.dispatch_request()
  File "C:\Users\Gebruiker\Envs\rotkehlchen\lib\site-packages\flask\app.py", line 1935, in dispatch_request
    return self.view_functions[rule.endpoint](**req.view_args)
  File "C:\Users\Gebruiker\Envs\rotkehlchen\lib\site-packages\flask_restful\__init__.py", line 458, in wrapper
    resp = resource(*args, **kwargs)
  File "C:\Users\Gebruiker\Envs\rotkehlchen\lib\site-packages\flask\views.py", line 89, in view
    return self.dispatch_request(*args, **kwargs)
  File "C:\Users\Gebruiker\Envs\rotkehlchen\lib\site-packages\flask_restful\__init__.py", line 573, in dispatch_request
    resp = meth(*args, **kwargs)
  File "C:\Users\Gebruiker\Envs\rotkehlchen\lib\site-packages\webargs\core.py", line 366, in wrapper
    return func(*args, **kwargs)
  File "c:\users\gebruiker\source\repos\rotki\rotkehlchen\api\v1\resources.py", line 215, in put
    return self.rest_api.setup_exchange(name, api_key, api_secret, passphrase)
  File "c:\users\gebruiker\source\repos\rotki\rotkehlchen\api\rest.py", line 109, in wrapper
    return f(wrappingobj, *args, **kwargs)
  File "c:\users\gebruiker\source\repos\rotki\rotkehlchen\api\rest.py", line 377, in setup_exchange
    result, message = self.rotkehlchen.setup_exchange(name, api_key, api_secret, passphrase)
  File "c:\users\gebruiker\source\repos\rotki\rotkehlchen\rotkehlchen.py", line 635, in setup_exchange
    passphrase=passphrase,
  File "c:\users\gebruiker\source\repos\rotki\rotkehlchen\exchanges\manager.py", line 78, in setup_exchange
    result, message = exchange.validate_api_key()
  File "c:\users\gebruiker\source\repos\rotki\rotkehlchen\exchanges\kraken.py", line 318, in validate_api_key
    valid, msg = self._validate_single_api_key_action('Balance')
  File "c:\users\gebruiker\source\repos\rotki\rotkehlchen\exchanges\kraken.py", line 341, in _validate_single_api_key_action
    self.api_query(method_str, req)
  File "c:\users\gebruiker\source\repos\rotki\rotkehlchen\exchanges\kraken.py", line 419, in api_query
    result = query_method(method, req)
  File "c:\users\gebruiker\source\repos\rotki\rotkehlchen\exchanges\kraken.py", line 471, in _query_private
    return _check_and_get_response(response, method)
  File "c:\users\gebruiker\source\repos\rotki\rotkehlchen\exchanges\kraken.py", line 246, in _check_and_get_response
    return result['result']
KeyError: 'result'

... (and later if i try to register it again I get this)

27/04/2020 17:06:33 W. Europe Daylight Time -- INFO:rotkehlchen.api.server.pywsgi:127.0.0.1 - - [2020-04-27 17:06:33] "OPTIONS /api/1/exchanges HTTP/1.1" 200 327 0.001009
27/04/2020 17:06:33 W. Europe Daylight Time -- DEBUG:rotkehlchen.api.rest:Request successful response={"result": null, "message": "Exchange kraken is already registered"}, status_code=409
```



Discussion:

Resolving patch:
diff --git a/rotkehlchen/exchanges/kraken.py b/rotkehlchen/exchanges/kraken.py
--- a/rotkehlchen/exchanges/kraken.py
+++ b/rotkehlchen/exchanges/kraken.py
@@ -3,6 +3,7 @@
 import base64
 import hashlib
 import hmac
+import json
 import logging
 import time
 from enum import Enum
@@ -230,20 +231,29 @@ def _check_and_get_response(response: Response, method: str) -> Union[str, Dict]
                 response.status_code,
             ))
 
-    result = rlk_jsonloads_dict(response.text)
-    if result['error']:
-        if isinstance(result['error'], list):
-            error = result['error'][0]
-        else:
-            error = result['error']
+    try:
+        decoded_json = rlk_jsonloads_dict(response.text)
+    except json.decoder.JSONDecodeError as e:
+        raise RemoteError(f'Invalid JSON in Kraken response. {e}')
 
-        if 'Rate limit exceeded' in error:
-            log.debug(f'Kraken: Got rate limit exceeded error: {error}')
-            return 'Rate limited exceeded'
-        else:
-            raise RemoteError(error)
+    try:
+        if decoded_json['error']:
+            if isinstance(decoded_json['error'], list):
+                error = decoded_json['error'][0]
+            else:
+                error = decoded_json['error']
+
+            if 'Rate limit exceeded' in error:
+                log.debug(f'Kraken: Got rate limit exceeded error: {error}')
+                return 'Rate limited exceeded'
+            else:
+                raise RemoteError(error)
+
+        result = decoded_json['result']
+    except KeyError as e:
+        raise RemoteError(f'Unexpected format of Kraken response. Missing key: {e}')
 
-    return result['result']
+    return result
 
 
 class KrakenAccountType(Enum):
diff --git a/rotkehlchen/exchanges/manager.py b/rotkehlchen/exchanges/manager.py
--- a/rotkehlchen/exchanges/manager.py
+++ b/rotkehlchen/exchanges/manager.py
@@ -31,7 +31,23 @@ def __init__(self, msg_aggregator: MessagesAggregator) -> None:
         self.connected_exchanges: Dict[str, ExchangeInterface] = {}
         self.msg_aggregator = msg_aggregator
 
-    def has_exchange(self, name: str) -> bool:
+    def has_exchange(self, name: str, database: Optional['DBHandler'] = None) -> bool:
+        """Check if an exchange is registered.
+
+        If the exchange manager can access the database (given as argument)
+        then it checks for credentials there. If not just relies on the mapping
+        """
+        credentials = database.get_exchange_credentials() if database else None
+        if credentials is not None and name not in credentials:
+            if name in self.connected_exchanges:
+                log.warning(
+                    f'{name} exchange had no credentials in the DB but was in the '
+                    f'connected exchanges mapping. Removing stale exchange from '
+                    f'mapping. This should not happen.',
+                )
+                self.connected_exchanges.pop(name)
+            return False
+
         return name in self.connected_exchanges
 
     def delete_exchange(self, name: str) -> None:
@@ -62,7 +78,7 @@ def setup_exchange(
         if name not in SUPPORTED_EXCHANGES:
             return False, 'Attempted to register unsupported exchange {}'.format(name)
 
-        if name in self.connected_exchanges:
+        if self.has_exchange(name, database=database):
             return False, 'Exchange {} is already registered'.format(name)
 
         credentials_dict = {}


Test patch:
diff --git a/rotkehlchen/tests/api/test_exchanges.py b/rotkehlchen/tests/api/test_exchanges.py
--- a/rotkehlchen/tests/api/test_exchanges.py
+++ b/rotkehlchen/tests/api/test_exchanges.py
@@ -25,11 +25,21 @@
     assert_poloniex_trades_result,
     mock_history_processing_and_exchanges,
 )
+from rotkehlchen.tests.utils.mock import MockResponse
+
+
+def mock_validate_api_key():
+    raise ValueError('BOOM 500 ERROR!')
+
 
 API_KEYPAIR_VALIDATION_PATCH = patch(
     'rotkehlchen.exchanges.kraken.Kraken.validate_api_key',
     return_value=(True, ''),
 )
+API_KEYPAIR_KRAKEN_VALIDATION_FAIL_PATCH = patch(
+    'rotkehlchen.exchanges.kraken.Kraken.validate_api_key',
+    side_effect=mock_validate_api_key,
+)
 API_KEYPAIR_COINBASEPRO_VALIDATION_PATCH = patch(
     'rotkehlchen.exchanges.coinbasepro.Coinbasepro.validate_api_key',
     return_value=(True, ''),
@@ -115,6 +125,86 @@ def test_setup_exchange(rotkehlchen_api_server):
     assert json_data['result'] == ['kraken', 'coinbasepro']
 
 
+@pytest.mark.parametrize('added_exchanges', [('kraken',)])
+def test_kraken_malformed_response(rotkehlchen_api_server_with_exchanges):
+    """Test that if Rotki gets a malformed response from Kraken it's handled properly
+
+    Regression test for the first part of https://github.com/rotki/rotki/issues/943
+    """
+    rotki = rotkehlchen_api_server_with_exchanges.rest_api.rotkehlchen
+    kraken = rotki.exchange_manager.get('kraken')
+    kraken.cache_ttl_secs = 0
+    kraken.use_original_kraken = True
+    response_data = '{"'
+
+    def mock_kraken_return(url, *args, **kwargs):  # pylint: disable=unused-argument
+        return MockResponse(200, response_data)
+    kraken_patch = patch.object(kraken.session, 'post', side_effect=mock_kraken_return)
+
+    # Test that invalid json is handled
+    with kraken_patch:
+        response = requests.get(
+            api_url_for(
+                rotkehlchen_api_server_with_exchanges,
+                "exchangebalancesresource",
+                name='kraken',
+            ),
+        )
+    assert_error_response(
+        response=response,
+        status_code=HTTPStatus.CONFLICT,
+        contained_in_msg='Could not reach kraken due to Invalid JSON in Kraken response',
+    )
+
+    # Test that the response missing result key seen in #943 is handled properly
+    response_data = '{"error": []}'
+    with kraken_patch:
+        response = requests.get(
+            api_url_for(
+                rotkehlchen_api_server_with_exchanges,
+                "exchangebalancesresource",
+                name='kraken',
+            ),
+        )
+    assert_error_response(
+        response=response,
+        status_code=HTTPStatus.CONFLICT,
+        contained_in_msg="Missing key: 'result'",
+    )
+
+
+def test_setup_exchange_does_not_stay_in_mapping_after_500_error(rotkehlchen_api_server):
+    """Test that if 500 error is returned during setup of an exchange and it's stuck
+    in the exchange mapping Rotki doesn't still think the exchange is registered.
+
+    Regression test for the second part of https://github.com/rotki/rotki/issues/943
+    """
+    data = {'name': 'kraken', 'api_key': 'ddddd', 'api_secret': 'fffffff'}
+    with API_KEYPAIR_KRAKEN_VALIDATION_FAIL_PATCH:
+        response = requests.put(
+            api_url_for(rotkehlchen_api_server, "exchangesresource"), json=data,
+        )
+    assert_error_response(
+        response=response,
+        status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
+    )
+
+    # Now try to register the exchange again
+    data = {'name': 'kraken', 'api_key': 'ddddd', 'api_secret': 'fffffff'}
+    with API_KEYPAIR_VALIDATION_PATCH:
+        response = requests.put(
+            api_url_for(rotkehlchen_api_server, "exchangesresource"), json=data,
+        )
+    assert_simple_ok_response(response)
+
+    # and check that kraken is now registered
+    response = requests.get(api_url_for(rotkehlchen_api_server, "exchangesresource"))
+    assert_proper_response(response)
+    json_data = response.json()
+    assert json_data['message'] == ''
+    assert json_data['result'] == ['kraken']
+
+
 def test_setup_exchange_errors(rotkehlchen_api_server):
     """Test errors and edge cases of setup_exchange endpoint"""
 
diff --git a/rotkehlchen/tests/utils/api.py b/rotkehlchen/tests/utils/api.py
--- a/rotkehlchen/tests/utils/api.py
+++ b/rotkehlchen/tests/utils/api.py
@@ -88,10 +88,11 @@ def assert_error_response(
         response.headers["Content-Type"] == "application/json"
     )
     response_data = response.json()
-    if result_exists:
-        assert response_data['result'] is not None
-    else:
-        assert response_data['result'] is None
+    if status_code != HTTPStatus.INTERNAL_SERVER_ERROR:
+        if result_exists:
+            assert response_data['result'] is not None
+        else:
+            assert response_data['result'] is None
     if contained_in_msg:
         if isinstance(contained_in_msg, str):
             assert contained_in_msg in response_data['message']
diff --git a/rotkehlchen/tests/utils/kraken.py b/rotkehlchen/tests/utils/kraken.py
--- a/rotkehlchen/tests/utils/kraken.py
+++ b/rotkehlchen/tests/utils/kraken.py
@@ -327,6 +327,7 @@ def __init__(
         self.random_balance_data = True
         self.random_ledgers_data = True
         self.remote_errors = False
+        self.use_original_kraken = False
 
         self.balance_data_return = {'XXBT': '5.0', 'XETH': '10.0', 'NOTAREALASSET': '15.0'}
         # Not required in the real Kraken instance but we use it in the tests
@@ -337,6 +338,9 @@ def api_query(self, method: str, req: Optional[dict] = None) -> dict:
         if self.remote_errors:
             raise RemoteError('Kraken remote error')
 
+        if self.use_original_kraken:
+            return super().api_query(method, req)
+
         if method == 'Balance':
             if self.random_balance_data:
                 return generate_random_kraken_balance_response()