From a261c53ed744ca91124e5d6d0f5cc5d198843578 Mon Sep 17 00:00:00 2001
From: Holger Nahrstaedt <holger@nahrstaedt.de>
Date: Thu, 24 May 2018 14:41:37 +0200
Subject: [PATCH] New nodes class for better node url handling

Wallet
* getKeysForAccount added
* getOwnerKeysForAccount, getActiveKeysForAccount, getPostingKeysForAccount added
beemapi
* WorkingNodeMissing is raised when no working node could be found
GrapheneRPC
* cycle([urls]) is replaced by the nodes class
Nodes
* Node handling and management of url and error_counts is performed by the nodes class
* sleep_and_check_retries is moved to the nodes class
* Websocket, steemnodrpc were adpapted to the changes
Unit tests
* new tests for the nodes class
* tests adapted for websocket and rpcutils
---
 beem/notify.py                  |   2 +-
 beem/wallet.py                  |  51 +++++++++++
 beemapi/exceptions.py           |   4 +
 beemapi/graphenerpc.py          |  93 ++++++++++---------
 beemapi/node.py                 | 156 ++++++++++++++++++++++++++++++++
 beemapi/rpcutils.py             |  28 +-----
 beemapi/steemnoderpc.py         |  18 ++--
 beemapi/websocket.py            |  20 ++--
 tests/beemapi/test_node.py      |  56 ++++++++++++
 tests/beemapi/test_rpcutils.py  |  10 +-
 tests/beemapi/test_websocket.py |   4 +-
 11 files changed, 337 insertions(+), 105 deletions(-)
 create mode 100644 beemapi/node.py
 create mode 100644 tests/beemapi/test_node.py

diff --git a/beem/notify.py b/beem/notify.py
index 9dbddc8d..652dc307 100644
--- a/beem/notify.py
+++ b/beem/notify.py
@@ -61,7 +61,7 @@ class Notify(Events):
 
         # Open the websocket
         self.websocket = SteemWebsocket(
-            urls=self.steem.rpc.urls,
+            urls=self.steem.rpc.nodes,
             user=self.steem.rpc.user,
             password=self.steem.rpc.password,
             only_block_id=only_block_id,
diff --git a/beem/wallet.py b/beem/wallet.py
index 42f9ed6d..cea4562c 100644
--- a/beem/wallet.py
+++ b/beem/wallet.py
@@ -386,6 +386,42 @@ class Wallet(object):
                     raise MissingKeyError("No private key for {} found".format(name))
             return
 
+    def getKeysForAccount(self, name, key_type):
+        """ Obtain a List of `key_type` Private Keys for an account from the wallet database
+        """
+        if key_type not in ["owner", "active", "posting", "memo"]:
+            raise AssertionError("Wrong key type")
+        if key_type in Wallet.keyMap:
+            return Wallet.keyMap.get(key_type)
+        else:
+            if self.rpc.get_use_appbase():
+                account = self.rpc.find_accounts({'accounts': [name]}, api="database")['accounts']
+            else:
+                account = self.rpc.get_account(name)
+            if not account:
+                return
+            if len(account) == 0:
+                return
+            if key_type == "memo":
+                key = self.getPrivateKeyForPublicKey(
+                    account[0]["memo_key"])
+                if key:
+                    return [key]
+            else:
+                keys = []
+                key = None
+                for authority in account[0][key_type]["key_auths"]:
+                    try:
+                        key = self.getPrivateKeyForPublicKey(authority[0])
+                        if key:
+                            keys.append(key)
+                    except MissingKeyError:
+                        key = None
+                if key is None:
+                    raise MissingKeyError("No private key for {} found".format(name))
+                return keys
+            return
+
     def getOwnerKeyForAccount(self, name):
         """ Obtain owner Private Key for an account from the wallet database
         """
@@ -406,6 +442,21 @@ class Wallet(object):
         """
         return self.getKeyForAccount(name, "posting")
 
+    def getOwnerKeysForAccount(self, name):
+        """ Obtain list of all owner Private Keys for an account from the wallet database
+        """
+        return self.getKeysForAccount(name, "owner")
+
+    def getActiveKeysForAccount(self, name):
+        """ Obtain list of all owner Active Keys for an account from the wallet database
+        """
+        return self.getKeysForAccount(name, "active")
+
+    def getPostingKeysForAccount(self, name):
+        """ Obtain list of all owner Posting Keys for an account from the wallet database
+        """
+        return self.getKeysForAccount(name, "posting")
+
     def getAccountFromPrivateKey(self, wif):
         """ Obtain account name from private key
         """
diff --git a/beemapi/exceptions.py b/beemapi/exceptions.py
index 50a16c6d..58a9f215 100644
--- a/beemapi/exceptions.py
+++ b/beemapi/exceptions.py
@@ -94,3 +94,7 @@ class InvalidEndpointUrl(Exception):
 
 class UnnecessarySignatureDetected(Exception):
     pass
+
+
+class WorkingNodeMissing(Exception):
+    pass
diff --git a/beemapi/graphenerpc.py b/beemapi/graphenerpc.py
index 9722287a..99b5574e 100644
--- a/beemapi/graphenerpc.py
+++ b/beemapi/graphenerpc.py
@@ -16,12 +16,13 @@ import re
 import time
 import warnings
 from .exceptions import (
-    UnauthorizedError, RPCConnection, RPCError, RPCErrorDoRetry, NumRetriesReached, CallRetriesReached
+    UnauthorizedError, RPCConnection, RPCError, RPCErrorDoRetry, NumRetriesReached, CallRetriesReached, WorkingNodeMissing
 )
 from .rpcutils import (
-    is_network_appbase_ready, sleep_and_check_retries,
+    is_network_appbase_ready,
     get_api_name, get_query
 )
+from .node import Nodes
 from beemgraphenebase.version import version as beem_version
 from beemgraphenebase.chains import known_chains
 
@@ -123,38 +124,39 @@ class GrapheneRPC(object):
         self.rpc_methods = {'offline': -1, 'ws': 0, 'jsonrpc': 1, 'wsappbase': 2, 'appbase': 3}
         self.current_rpc = self.rpc_methods["ws"]
         self._request_id = 0
-        if isinstance(urls, str):
-            url_list = re.split(r",|;", urls)
-            self.n_urls = len(url_list)
-            self.urls = cycle(url_list)
-            if self.urls is None:
-                self.n_urls = 1
-                self.urls = cycle([urls])
-        elif isinstance(urls, (list, tuple, set)):
-            self.n_urls = len(urls)
-            self.urls = cycle(urls)
-        elif urls is not None:
-            self.n_urls = 1
-            self.urls = cycle([urls])
-        else:
-            self.n_urls = 0
-            self.urls = None
+        self.timeout = kwargs.get('timeout', 60)
+        num_retries = kwargs.get("num_retries", -1)
+        num_retries_call = kwargs.get("num_retries_call", 5)
+        self.use_condenser = kwargs.get("use_condenser", False)
+        self.nodes = Nodes(urls, num_retries, num_retries_call)
+        if self.nodes.working_nodes_count == 0:
             self.current_rpc = self.rpc_methods["offline"]
+
         self.user = user
         self.password = password
         self.ws = None
         self.url = None
         self.session = None
         self.rpc_queue = []
-        self.timeout = kwargs.get('timeout', 60)
-        self.num_retries = kwargs.get("num_retries", -1)
-        self.use_condenser = kwargs.get("use_condenser", False)
-        self.error_cnt = {}
-        self.num_retries_call = kwargs.get("num_retries_call", 5)
-        self.error_cnt_call = 0
         if kwargs.get("autoconnect", True):
             self.rpcconnect()
 
+    @property
+    def num_retries(self):
+        return self.nodes.num_retries
+
+    @property
+    def num_retries_call(self):
+        return self.nodes.num_retries_call
+
+    @property
+    def error_cnt_call(self):
+        return self.nodes.error_cnt_call
+
+    @property
+    def error_cnt(self):
+        return self.nodes.error_cnt
+
     def get_request_id(self):
         """Get request id."""
         self._request_id += 1
@@ -179,14 +181,12 @@ class GrapheneRPC(object):
 
     def rpcconnect(self, next_url=True):
         """Connect to next url in a loop."""
-        if self.urls is None:
+        if self.nodes.working_nodes_count == 0:
             return
         while True:
             if next_url:
-                self.url = next(self.urls)
-                self.error_cnt_call = 0
-                if self.url not in self.error_cnt:
-                    self.error_cnt[self.url] = 0
+                self.url = next(self.nodes)
+                self.nodes.reset_error_cnt_call()
                 log.debug("Trying to connect to node %s" % self.url)
                 if self.url[:3] == "wss":
                     self.ws = create_ws_instance(use_ssl=True)
@@ -226,9 +226,9 @@ class GrapheneRPC(object):
             except KeyboardInterrupt:
                 raise
             except Exception as e:
-                self.error_cnt[self.url] += 1
-                do_sleep = not next_url or (next_url and self.n_urls == 1)
-                sleep_and_check_retries(self.num_retries, self.error_cnt[self.url], self.url, str(e), sleep=do_sleep)
+                self.nodes.increase_error_cnt()
+                do_sleep = not next_url or (next_url and self.nodes.working_nodes_count == 1)
+                self.nodes.sleep_and_check_retries(str(e), sleep=do_sleep)
                 next_url = True
 
     def rpclogin(self, user, password):
@@ -322,11 +322,13 @@ class GrapheneRPC(object):
         :raises RPCError: if the server returns an error
         """
         log.debug(json.dumps(payload))
+        if self.nodes.working_nodes_count == 0:
+            raise WorkingNodeMissing
         if self.url is None:
             self.rpcconnect()
         reply = {}
         while True:
-            self.error_cnt_call += 1
+            self.nodes.increase_error_cnt_call()
             try:
                 if self.current_rpc == 0 or self.current_rpc == 2:
                     reply = self.ws_send(json.dumps(payload, ensure_ascii=False).encode('utf8'))
@@ -334,10 +336,10 @@ class GrapheneRPC(object):
                     reply = self.request_send(json.dumps(payload, ensure_ascii=False).encode('utf8'))
                 if not bool(reply):
                     try:
-                        sleep_and_check_retries(self.num_retries_call, self.error_cnt_call, self.url, "Empty Reply", call_retry=True)
+                        self.nodes.sleep_and_check_retries("Empty Reply", call_retry=True)
                     except CallRetriesReached:
-                        self.error_cnt[self.url] += 1
-                        sleep_and_check_retries(self.num_retries, self.error_cnt[self.url], self.url, "Empty Reply", sleep=False, call_retry=False)
+                        self.nodes.increase_error_cnt()
+                        self.nodes.sleep_and_check_retries("Empty Reply", sleep=False, call_retry=False)
                         self.rpcconnect()
                 else:
                     break
@@ -347,12 +349,12 @@ class GrapheneRPC(object):
                 # self.error_cnt[self.url] += 1
                 self.rpcconnect(next_url=False)
             except ConnectionError as e:
-                self.error_cnt[self.url] += 1
-                sleep_and_check_retries(self.num_retries, self.error_cnt[self.url], self.url, str(e), sleep=False, call_retry=False)
+                self.nodes.increase_error_cnt()
+                self.nodes.sleep_and_check_retries(str(e), sleep=False, call_retry=False)
                 self.rpcconnect()
             except Exception as e:
-                self.error_cnt[self.url] += 1
-                sleep_and_check_retries(self.num_retries, self.error_cnt[self.url], self.url, str(e), sleep=False, call_retry=False)
+                self.nodes.increase_error_cnt()
+                self.nodes.sleep_and_check_retries(str(e), sleep=False, call_retry=False)
                 self.rpcconnect()
 
         ret = {}
@@ -381,15 +383,15 @@ class GrapheneRPC(object):
                         ret_list.append(r["result"])
                     else:
                         ret_list.append(r)
-                self.error_cnt_call = 0
+                self.nodes.reset_error_cnt_call()
                 return ret_list
             elif isinstance(ret, dict) and "result" in ret:
-                self.error_cnt_call = 0
+                self.nodes.reset_error_cnt_call()
                 return ret["result"]
             elif isinstance(ret, int):
                 raise RPCError("Client returned invalid format. Expected JSON! Output: %s" % (str(ret)))
             else:
-                self.error_cnt_call = 0
+                self.nodes.reset_error_cnt_call()
                 return ret
         return ret
 
@@ -404,16 +406,19 @@ class GrapheneRPC(object):
                 api_name = "condenser_api"
 
             # let's be able to define the num_retries per query
-            self.num_retries_call = kwargs.get("num_retries_call", self.num_retries_call)
+            stored_num_retries_call = self.nodes.num_retries_call
+            self.nodes.num_retries_call = kwargs.get("num_retries_call", stored_num_retries_call)
             add_to_queue = kwargs.get("add_to_queue", False)
             query = get_query(self.is_appbase_ready() and not self.use_condenser, self.get_request_id(), api_name, name, args)
             if add_to_queue:
                 self.rpc_queue.append(query)
+                self.nodes.num_retries_call = stored_num_retries_call
                 return None
             elif len(self.rpc_queue) > 0:
                 self.rpc_queue.append(query)
                 query = self.rpc_queue
                 self.rpc_queue = []
             r = self.rpcexec(query)
+            self.nodes.num_retries_call = stored_num_retries_call
             return r
         return method
diff --git a/beemapi/node.py b/beemapi/node.py
new file mode 100644
index 00000000..ad64638e
--- /dev/null
+++ b/beemapi/node.py
@@ -0,0 +1,156 @@
+# This Python file uses the following encoding: utf-8
+from __future__ import absolute_import
+from __future__ import division
+from __future__ import print_function
+from __future__ import unicode_literals
+from builtins import str
+import json
+import re
+import time
+import logging
+from .exceptions import (
+    UnauthorizedError, RPCConnection, RPCError, NumRetriesReached, CallRetriesReached
+)
+log = logging.getLogger(__name__)
+
+
+class Node(object):
+    def __init__(
+        self,
+        url
+    ):
+        self.url = url
+        self.error_cnt = 0
+        self.error_cnt_call = 0
+
+    def __repr__(self):
+        return self.url
+
+
+class Nodes(list):
+    """Stores Node URLs and error counts"""
+    def __init__(self, urls, num_retries, num_retries_call):
+        if isinstance(urls, str):
+            url_list = re.split(r",|;", urls)
+            if url_list is None:
+                url_list = [urls]
+        elif isinstance(urls, Nodes):
+            url_list = [urls[i].url for i in range(len(urls))]
+        elif isinstance(urls, (list, tuple, set)):
+            url_list = urls
+        elif urls is not None:
+            url_list = [urls]
+        else:
+            url_list = []
+        super(Nodes, self).__init__([Node(x) for x in url_list])
+        self.num_retries = num_retries
+        self.num_retries_call = num_retries_call
+        self.current_node_index = -1
+
+    def __iter__(self):
+        return self
+
+    def __next__(self):
+        next_node_count = 0
+        while next_node_count == 0 and (self.num_retries < 0 or self.node.error_cnt < self.num_retries):
+            self.current_node_index += 1
+            if self.current_node_index >= self.working_nodes_count:
+                self.current_node_index = 0
+            next_node_count += 1
+            if next_node_count > self.working_nodes_count + 1:
+                raise StopIteration
+
+        return self.url
+
+    def __repr__(self):
+        nodes_list = []
+        for i in range(len(self)):
+            if self.num_retries < 0 or self[i].error_cnt <= self.num_retries:
+                nodes_list.append(self[i].url)
+        return str(nodes_list)
+
+    @property
+    def working_nodes_count(self):
+        n = 0
+        for i in range(len(self)):
+            if self.num_retries < 0 or self[i].error_cnt <= self.num_retries:
+                n += 1
+        return n
+
+    @property
+    def url(self):
+        if self.node is None:
+            return ''
+        return self.node.url
+
+    @property
+    def node(self):
+        if self.current_node_index < 0:
+            return self[0]
+        return self[self.current_node_index]
+
+    @property
+    def error_cnt(self):
+        if self.node is None:
+            return 0
+        return self.node.error_cnt
+
+    @property
+    def error_cnt_call(self):
+        if self.node is None:
+            return 0
+        return self.node.error_cnt_call
+
+    @property
+    def num_retries_call_reached(self):
+        return self.error_cnt_call >= self.num_retries_call
+
+    def increase_error_cnt(self):
+        """Increase node error count for current node"""
+        if self.node is not None:
+            self.node.error_cnt += 1
+
+    def increase_error_cnt_call(self):
+        """Increase call error count for current node"""
+        if self.node is not None:
+            self.node.error_cnt_call += 1
+
+    def reset_error_cnt_call(self):
+        """Set call error count for current node to zero"""
+        if self.node is not None:
+            self.node.error_cnt_call = 0
+
+    def reset_error_cnt(self):
+        """Set node error count for current node to zero"""
+        if self.node is not None:
+            self.node.error_cnt = 0
+
+    def sleep_and_check_retries(self, errorMsg=None, sleep=True, call_retry=False, showMsg=True):
+        """Sleep and check if num_retries is reached"""
+        if errorMsg:
+            log.warning("Error: {}".format(errorMsg))
+        if call_retry:
+            cnt = self.error_cnt_call
+            if (self.num_retries_call >= 0 and self.error_cnt_call > self.num_retries_call):
+                raise CallRetriesReached()
+        else:
+            cnt = self.error_cnt
+            if (self.num_retries >= 0 and self.error_cnt > self.num_retries):
+                raise NumRetriesReached()
+
+        if showMsg:
+            if call_retry:
+                log.warning("Retry RPC Call on node: %s (%d/%d) \n" % (self.url, cnt, self.num_retries_call))
+            else:
+                log.warning("Lost connection or internal error on node: %s (%d/%d) \n" % (self.url, cnt, self.num_retries))
+        if not sleep:
+            return
+        if cnt < 1:
+            sleeptime = 0
+        elif cnt < 10:
+            sleeptime = (cnt - 1) * 1.5 + 0.5
+        else:
+            sleeptime = 10
+        if sleeptime:
+            log.warning("Retrying in %d seconds\n" % sleeptime)
+            time.sleep(sleeptime)
diff --git a/beemapi/rpcutils.py b/beemapi/rpcutils.py
index 65575cab..cc0dde51 100644
--- a/beemapi/rpcutils.py
+++ b/beemapi/rpcutils.py
@@ -9,6 +9,7 @@ import logging
 from .exceptions import (
     UnauthorizedError, RPCConnection, RPCError, NumRetriesReached, CallRetriesReached
 )
+from .node import Nodes
 
 log = logging.getLogger(__name__)
 
@@ -81,30 +82,3 @@ def get_api_name(appbase, *args, **kwargs):
         else:
             api_name = "condenser_api"
     return api_name
-
-
-def sleep_and_check_retries(num_retries, cnt, url, errorMsg=None, sleep=True, call_retry=False, showMsg=True):
-    """Sleep and check if num_retries is reached"""
-    if errorMsg:
-        log.warning("Error: {}".format(errorMsg))
-    if (num_retries >= 0 and cnt > num_retries):
-        if not call_retry:
-            raise NumRetriesReached()
-        else:
-            raise CallRetriesReached()
-    if showMsg:
-        if call_retry:
-            log.warning("Retry RPC Call on node: %s (%d/%d) \n" % (url, cnt, num_retries))
-        else:
-            log.warning("Lost connection or internal error on node: %s (%d/%d) \n" % (url, cnt, num_retries))
-    if not sleep:
-        return
-    if cnt < 1:
-        sleeptime = 0
-    elif cnt < 10:
-        sleeptime = (cnt - 1) * 1.5 + 0.5
-    else:
-        sleeptime = 10
-    if sleeptime:
-        log.warning("Retrying in %d seconds\n" % sleeptime)
-        time.sleep(sleeptime)
diff --git a/beemapi/steemnoderpc.py b/beemapi/steemnoderpc.py
index 26f786d2..575cdae5 100644
--- a/beemapi/steemnoderpc.py
+++ b/beemapi/steemnoderpc.py
@@ -6,7 +6,6 @@ from builtins import bytes, int, str
 import re
 import sys
 from .graphenerpc import GrapheneRPC
-from .rpcutils import sleep_and_check_retries
 from beemgraphenebase.chains import known_chains
 from . import exceptions
 import logging
@@ -72,7 +71,7 @@ class SteemNodeRPC(GrapheneRPC):
             except exceptions.RPCErrorDoRetry as e:
                 msg = exceptions.decodeRPCErrorMsg(e).strip()
                 try:
-                    sleep_and_check_retries(self.num_retries_call, self.error_cnt_call, self.url, str(msg), call_retry=True)
+                    self.nodes.sleep_and_check_retries(str(msg), call_retry=True)
                     doRetry = True
                 except exceptions.CallRetriesReached:
                     if self.n_urls > 1:
@@ -92,12 +91,11 @@ class SteemNodeRPC(GrapheneRPC):
                         raise exceptions.CallRetriesReached
             except Exception as e:
                 raise e
-            if self.error_cnt_call >= self.num_retries_call:
-                maxRetryCountReached = True
+            maxRetryCountReached = self.nodes.num_retries_call_reached
 
     def _retry_on_next_node(self, error_msg):
-        self.error_cnt[self.url] += 1
-        sleep_and_check_retries(self.num_retries, self.error_cnt[self.url], self.url, error_msg, sleep=False, call_retry=False)
+        self.nodes.increase_error_cnt()
+        self.nodes.sleep_and_check_retries(error_msg, sleep=False, call_retry=False)
         self.next()
 
     def _check_error_message(self, e, cnt):
@@ -122,16 +120,16 @@ class SteemNodeRPC(GrapheneRPC):
         elif re.search("WinError", msg):
             raise exceptions.RPCError(msg)
         elif re.search("Unable to acquire database lock", msg):
-            sleep_and_check_retries(self.num_retries_call, cnt, self.url, str(msg), call_retry=True)
+            self.nodes.sleep_and_check_retries(str(msg), call_retry=True)
             doRetry = True
         elif re.search("Internal Error", msg) or re.search("Unknown exception", msg):
-            sleep_and_check_retries(self.num_retries_call, cnt, self.url, str(msg), call_retry=True)
+            self.nodes.sleep_and_check_retries(str(msg), call_retry=True)
             doRetry = True
         elif re.search("!check_max_block_age", str(e)):
             if self.n_urls == 1:
                 raise exceptions.UnhandledRPCError(msg)
-            self.error_cnt[self.url] += 1
-            sleep_and_check_retries(self.num_retries, self.error_cnt[self.url], self.url, str(msg), sleep=False)
+            self.nodes.increase_error_cnt()
+            self.nodes.sleep_and_check_retries(str(msg), sleep=False)
             self.next()
             doRetry = True
         elif re.search("out_of_rangeEEEE: unknown key", msg) or re.search("unknown key:unknown key", msg):
diff --git a/beemapi/websocket.py b/beemapi/websocket.py
index 7ad5558c..998e35cf 100644
--- a/beemapi/websocket.py
+++ b/beemapi/websocket.py
@@ -14,10 +14,11 @@ import websocket
 from itertools import cycle
 from threading import Thread
 from beemapi.rpcutils import (
-    is_network_appbase_ready, sleep_and_check_retries,
+    is_network_appbase_ready,
     get_api_name, get_query, UnauthorizedError,
     RPCConnection, RPCError, NumRetriesReached
 )
+from beemapi.node import Nodes
 from events import Events
 
 log = logging.getLogger(__name__)
@@ -75,12 +76,7 @@ class SteemWebsocket(Events):
         self.keep_alive = keep_alive
         self.run_event = threading.Event()
         self.only_block_id = only_block_id
-        if isinstance(urls, cycle):
-            self.urls = urls
-        elif isinstance(urls, list):
-            self.urls = cycle(urls)
-        else:
-            self.urls = cycle([urls])
+        self.nodes = Nodes(urls, num_retries, 5)
 
         # Instanciate Events
         Events.__init__(self)
@@ -232,10 +228,8 @@ class SteemWebsocket(Events):
             It will execute callbacks as defined and try to stay
             connected with the provided APIs
         """
-        cnt = 0
         while not self.run_event.is_set():
-            cnt += 1
-            self.url = next(self.urls)
+            self.url = next(self.nodes)
             log.debug("Trying to connect to node %s" % self.url)
             try:
                 # websocket.enableTrace(True)
@@ -248,9 +242,11 @@ class SteemWebsocket(Events):
                 )
                 self.ws.run_forever()
             except websocket.WebSocketException as exc:
-                sleep_and_check_retries(self.num_retries, cnt, self.url)
+                self.nodes.increase_error_cnt()
+                self.nodes.sleep_and_check_retries()
             except websocket.WebSocketTimeoutException as exc:
-                sleep_and_check_retries(self.num_retries, cnt, self.url)
+                self.nodes.increase_error_cnt()
+                self.nodes.sleep_and_check_retries()
             except KeyboardInterrupt:
                 self.ws.keep_running = False
                 raise
diff --git a/tests/beemapi/test_node.py b/tests/beemapi/test_node.py
new file mode 100644
index 00000000..fd69de93
--- /dev/null
+++ b/tests/beemapi/test_node.py
@@ -0,0 +1,56 @@
+# This Python file uses the following encoding: utf-8
+from __future__ import absolute_import
+from __future__ import division
+from __future__ import print_function
+from __future__ import unicode_literals
+import pytest
+import unittest
+from beemapi.node import Nodes
+from beemapi.rpcutils import (
+    is_network_appbase_ready,
+    get_api_name, get_query, UnauthorizedError,
+    RPCConnection, RPCError, NumRetriesReached
+)
+
+
+class Testcases(unittest.TestCase):
+    def test_sleep_and_check_retries(self):
+        nodes = Nodes("test", -1, 5)
+        nodes.sleep_and_check_retries("error")
+        nodes = Nodes("test", 1, 5)
+        nodes.increase_error_cnt()
+        nodes.increase_error_cnt()
+        with self.assertRaises(
+            NumRetriesReached
+        ):
+            nodes.sleep_and_check_retries()
+
+    def test_next(self):
+        nodes = Nodes(["a", "b", "c"], -1, -1)
+        self.assertEqual(nodes.working_nodes_count, len(nodes))
+        self.assertEqual(nodes.url, nodes[0].url)
+        next(nodes)
+        self.assertEqual(nodes.url, nodes[0].url)
+        next(nodes)
+        self.assertEqual(nodes.url, nodes[1].url)
+        next(nodes)
+        self.assertEqual(nodes.url, nodes[2].url)
+        next(nodes)
+        self.assertEqual(nodes.url, nodes[0].url)
+
+        nodes = Nodes("a,b,c", 5, 5)
+        self.assertEqual(nodes.working_nodes_count, len(nodes))
+        self.assertEqual(nodes.url, nodes[0].url)
+        next(nodes)
+        self.assertEqual(nodes.url, nodes[0].url)
+        next(nodes)
+        self.assertEqual(nodes.url, nodes[1].url)
+        next(nodes)
+        self.assertEqual(nodes.url, nodes[2].url)
+        next(nodes)
+        self.assertEqual(nodes.url, nodes[0].url)
+
+    def test_init(self):
+        nodes = Nodes(["a", "b", "c"], 5, 5)
+        nodes2 = Nodes(nodes, 5, 5)
+        self.assertEqual(nodes.url, nodes2.url)
diff --git a/tests/beemapi/test_rpcutils.py b/tests/beemapi/test_rpcutils.py
index 5f64d59f..7c610b30 100644
--- a/tests/beemapi/test_rpcutils.py
+++ b/tests/beemapi/test_rpcutils.py
@@ -6,7 +6,7 @@ from __future__ import unicode_literals
 import pytest
 import unittest
 from beemapi.rpcutils import (
-    is_network_appbase_ready, sleep_and_check_retries,
+    is_network_appbase_ready,
     get_api_name, get_query, UnauthorizedError,
     RPCConnection, RPCError, NumRetriesReached
 )
@@ -81,11 +81,3 @@ class Testcases(unittest.TestCase):
         self.assertEqual(query["id"], 1)
         self.assertTrue(isinstance(query["params"], list))
         self.assertEqual(query["params"], ["test_api", "test", ["b"]])
-
-    def test_sleep_and_check_retries(self):
-        sleep_and_check_retries(-1, 0, "test", "error")
-        sleep_and_check_retries(-1, -1, "test")
-        with self.assertRaises(
-            NumRetriesReached
-        ):
-            sleep_and_check_retries(1, 2, "test")
diff --git a/tests/beemapi/test_websocket.py b/tests/beemapi/test_websocket.py
index 82a3a060..a57f8717 100644
--- a/tests/beemapi/test_websocket.py
+++ b/tests/beemapi/test_websocket.py
@@ -29,10 +29,10 @@ class Testcases(unittest.TestCase):
         stm = Steem(node=get_node_list(appbase=False))
 
         self.ws = SteemWebsocket(
-            urls=stm.rpc.urls,
+            urls=stm.rpc.nodes,
             num_retries=10
         )
 
     def test_connect(self):
         ws = self.ws
-        self.assertTrue(len(next(ws.urls)) > 0)
+        self.assertTrue(len(next(ws.nodes)) > 0)
-- 
GitLab