Skip to content
This repository was archived by the owner on May 20, 2024. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,20 @@ out = account.function_call(contract_id, "counter_set", args)
print(out)
```

## Logging control

```python
import logging

# To hide NEAR warnings and info messages
logging.getLogger("near_api").setLevel(logging.ERROR)

# To log all information about RPC requests
logging.getLogger("urllib3.connectionpool").setLevel(logging.DEBUG).addHandler(handler)

# To log only information about RPC retries
logging.getLogger("urllib3.connectionpool").setLevel(logging.WARNING).addHandler(handler)
```

# Contribution

Expand Down
36 changes: 32 additions & 4 deletions near_api/account.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import itertools
import json
import logging
import time
from typing import Optional

import base58

import near_api
from near_api import transactions
from near_api import transactions, providers

log = logging.getLogger(__name__)

# Amount of gas attached by default 1e14.
DEFAULT_ATTACHED_GAS = 100_000_000_000_000
Expand All @@ -25,16 +29,40 @@ def __init__(
self,
provider: 'near_api.providers.JsonProvider',
signer: 'near_api.signer.Signer',
account_id: Optional[str] = None
account_id: Optional[str] = None,
tx_nonce_retry_number: int = 12,
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I copied these default number of retries from near-api-js, that's why they're so huge... (~30 retries in total with delay from 1.5s to 1min, theoretically a request can take ~3 minutes).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the current code request can take up to 6 minutes. Note that JS code multiplies delay by 500 milliseconds which is not done here on line 63.

tx_nonce_retry_backoff_factor: float = 1.5,
):
self._provider = provider
self._signer = signer
self._account_id = account_id or self._signer.account_id
self._account: dict = provider.get_account(self._account_id)
self._access_key: dict = provider.get_access_key(self._account_id, self._signer.key_pair.encoded_public_key())
# print(account_id, self._account, self._access_key)

self._tx_nonce_retry_number = tx_nonce_retry_number
self._tx_nonce_retry_backoff_factor = tx_nonce_retry_backoff_factor

def _sign_and_submit_tx(self, receiver_id: str, actions: list['transactions.Action']) -> dict:
attempt = 0
while True:
try:
return self._sign_and_submit_tx_once(receiver_id, actions)
except providers.JsonProviderError as e:
if attempt >= self._tx_nonce_retry_number:
raise
attempt += 1

if e.is_invalid_nonce_tx_error():
log.warning("Retrying transaction with new nonce: %s", e)
self.fetch_state()
elif e.is_expired_tx_error():
log.warning("Retrying transaction due to expired block hash: %s", e)
else:
raise

time.sleep(self._tx_nonce_retry_backoff_factor ** attempt)

def _sign_and_submit_tx_once(self, receiver_id: str, actions: list['transactions.Action']) -> dict:
self._access_key['nonce'] += 1
block_hash = self._provider.get_status()['sync_info']['latest_block_hash']
block_hash = base58.b58decode(block_hash.encode('utf8'))
Expand All @@ -43,7 +71,7 @@ def _sign_and_submit_tx(self, receiver_id: str, actions: list['transactions.Acti
result: dict = self._provider.send_tx_and_wait(serialized_tx, 10)
for outcome in itertools.chain([result['transaction_outcome']], result['receipts_outcome']):
for log in outcome['outcome']['logs']:
print("Log:", log)
log.info("Log %s: %s", receiver_id, log)
if 'Failure' in result['status']:
raise TransactionError(result['status']['Failure'])
return result
Expand Down
91 changes: 86 additions & 5 deletions near_api/providers.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import base64
import json
from typing import Union, Tuple, Any
import logging
import time
from typing import Union, Tuple, Any, Optional

import requests
import urllib3.util

log = logging.getLogger(__name__)

TimeoutType = Union[float, Tuple[float, float]]
""" The type used as "timeout" argument when sending requests. Quantities are in seconds.
Expand All @@ -18,28 +23,104 @@ class FinalityTypes:


class JsonProviderError(Exception):
pass
def get_type(self) -> Optional[str]:
try:
return self.args[0]["name"]
except (IndexError, KeyError):
return None

def get_cause(self) -> Optional[str]:
try:
return self.args[0]["cause"]["name"]
except (IndexError, KeyError):
return None

def is_invalid_nonce_tx_error(self) -> bool:
try:
return (
self.get_type() == "HANDLER_ERROR"
and self.get_cause() == "INVALID_TRANSACTION"
and "InvalidNonce" in self.args[0]["data"]["TxExecutionError"]["InvalidTxError"]
)
except (IndexError, KeyError):
return False

def is_expired_tx_error(self) -> bool:
try:
return (
self.get_type() == "HANDLER_ERROR"
and self.get_cause() == "INVALID_TRANSACTION"
and self.args[0]["data"]["TxExecutionError"]["InvalidTxError"] == "Expired"
)
except (IndexError, KeyError):
return False
Copy link
Copy Markdown
Author

@teqwve teqwve Aug 15, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here it would probably be better to do the same thing as near-api-js does, but I gave up the moment I looked at code responsible for that (traversing whole returned object tree and comparing with json scheme just to get the error name?) I don't feel competent enough to implement this in python since I've found nothing about this in docs.

Although If you think it would be much better to use this json schema in python as well (and maybe can point me at some docs or example error responses) I can implement this here.

[1] https://github.com/near/near-api-js/blob/master/packages/near-api-js/src/utils/rpc_errors.ts#L55
[2] https://github.com/near/near-api-js/blob/master/packages/near-api-js/src/generated/rpc_error_schema.json



class JsonProvider(object):
def __init__(self, rpc_addr, proxies=None):
def __init__(
self,
rpc_addr,
proxies=None,
tx_timeout_retry_number: int = 12,
tx_timeout_retry_backoff_factor: float = 1.5,
http_retry_number: int = 10,
http_retry_backoff_factor: float = 1.5,
session: Optional[requests.Session] = None,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this argument ever used? I’d rather we get rid of it. With it, the prototype of this constructor is a bit weird. If session is given, majority of the arguments are just ignored.

Copy link
Copy Markdown

@teqwve-cosmose teqwve-cosmose Aug 16, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since you prefer to not use built-in retry mechanism then it's not very useful at the moment. I'll remove it from this PR.


The main reason for adding this here was to make it possible to configure HTTP connection pooling(*) which is very useful when doing more than couple of requests to near api per second.

I intended to do something like:

session = requests.Session()
sessions.mount(
  "https://",
  requests.HTTPAdapter(
    pool_maxsize=1,
    pool_connections=10,
    pool_block=True,
    ...
  )
)

provider = near_api.providers.JsonProvider(..., session=session)

I'll remove this argument here and create a separate PR which will allow me to do:

provider = near_api.providers.JsonProvider(..., http_pool_maxsize=1, http_pool_block=...)

under the hood it will create session with relevant pool size and retries=0. So the client won't be staring a new TLS connection each time and client will be handling retires itself. Sounds ok?

(*) relevant docs: https://requests.readthedocs.io/en/latest/api/#requests.adapters.HTTPAdapter

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mean it’s fine to have optional session argument. My issue was about having mutually exclusive set of arguments.

) -> None:
if isinstance(rpc_addr, tuple):
self._rpc_addr = "http://%s:%s" % rpc_addr
else:
self._rpc_addr = rpc_addr
self.proxies = proxies

if session is None:
# Loosely based on https://findwork.dev/blog/advanced-usage-python-requests-timeouts-retries-hooks/
adapter = requests.adapters.HTTPAdapter(
max_retries=urllib3.util.Retry(
total=http_retry_number,
backoff_factor=http_retry_backoff_factor,
status_forcelist=[502, 503],
allowed_methods=["GET", "POST"],
),
)

session = requests.Session()
session.mount("https://", adapter)
session.mount("http://", adapter)
Comment on lines +76 to +89
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I used sessions instead of manual retrying because a built-in mechanism seem more straightforward to me and it allows great customisation (user can pass their own Session object).

If you prefer I can change it to manual exception catching and handling.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a bit specious to me. There are now three nested retry loops. We have

  1. a loop in _sign_and_submit_tx which retries up to 12 times if we had wrong nonce or block,
  2. another loop here in json_rpc which retries up to 10 times if we had a timeout and
  3. final loop in the Retry adapter which retries up to 10 times on server errors.

It may be somewhat contrived, but in pathological case we may end up retrying 1200 times. I’d say we should at least get rid of the Retry adapter and to the looping logic for 5xx errors in json_rpc. Something like:

        while True:
            try:
                return self._json_rpc_once(method, params, timeout)
            except HTTPError as e:  # whatever the exception type is
                if attempt >= self._tx_timeout_retry_number:
                    raise
                if e.status_code // 100 == 5:
                    log.warning("Retrying request to %s as it returned server error: %s", method, e)
                else:
                    raise
            except JsonProviderError as e:
                if attempt >= self._tx_timeout_retry_number:
                    raise
                if e.get_type() == "HANDLER_ERROR" and e.get_cause() == "TIMEOUT_ERROR":
                    log.warning("Retrying request to %s as it has timed out: %s", method, e)
                else:
                    raise
            attempt += 1
            time.sleep(self._tx_timeout_retry_backoff_factor ** attempt * 0.5)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, that's what is done by near-api-js ;p

Ok, I'll refactor this to have only two levels of retries

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I’ve noticed. I’d argue it’s a bug in JS implementation.


self._session = session

self._tx_timeout_retry_number = tx_timeout_retry_number
self._tx_timeout_retry_backoff_factor = tx_timeout_retry_backoff_factor

def rpc_addr(self) -> str:
return self._rpc_addr

def json_rpc(self, method: str, params: Union[dict, list, str], timeout: 'TimeoutType' = 2.0) -> dict:
attempt = 0
while True:
try:
return self._json_rpc_once(method, params, timeout)
except JsonProviderError as e:
if attempt >= self._tx_timeout_retry_number:
raise
attempt += 1

if e.get_type() == "HANDLER_ERROR" and e.get_cause() == "TIMEOUT_ERROR":
log.warning("Retrying request to %s as it has timed out: %s", method, e)
else:
raise

time.sleep(self._tx_timeout_retry_backoff_factor ** attempt)

def _json_rpc_once(self, method: str, params: Union[dict, list, str], timeout: 'TimeoutType' = 2.0) -> dict:
j = {
'method': method,
'params': params,
'id': "dontcare",
'jsonrpc': "2.0"
}
r = requests.post(self.rpc_addr(), json=j, timeout=timeout, proxies=self.proxies)
r = self._session.post(self.rpc_addr(), json=j, timeout=timeout, proxies=self.proxies)
r.raise_for_status()
content = json.loads(r.content)
if "error" in content:
Expand All @@ -56,7 +137,7 @@ def send_tx_and_wait(self, signed_tx: bytes, timeout: 'TimeoutType') -> dict:
timeout=timeout)

def get_status(self, timeout: 'TimeoutType' = 2.0) -> dict:
r = requests.get("%s/status" % self.rpc_addr(), timeout=timeout)
r = self._session.get("%s/status" % self.rpc_addr(), timeout=timeout)
Comment on lines -59 to +140
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should go through the retry loop as well. Currently this only retries on server errors but I don’t see why we wouldn’t want to retry on timeouts if we’re retrying other requests on timeout.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you mean the same TIMEOUT_ERROR that is handled in json_rpc? Is it possible to get one here? In docs it among broadcast_tx_commit errors and it's not mentioned among /stats endpoint errors (at least here).

But yeah, since we'll be handling all timeouts ourselves then retry code might be the same and we can add handling of these error here as well :)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I see. It’s a different timeout that what I was thinking of. What happens if requsets.get’s timeout runs out?

Also, at this point maybe we should move TIMEOUT_ERROR handling to _sign_and_submit_tx? JsonProvider would handle 5xx and Account would handle TIMEOUT_ERROR and any other errors of this kind?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens if requsets.get’s timeout runs out?

I guess that requests's retry logic triggers and once number of attempts is exceeded an exception like requests.Timeout is thrown.

Also, at this point maybe ...

Sounds great, I'll also be able to stick with built-in mechanisms for network/http error retries.

r.raise_for_status()
return json.loads(r.content)

Expand Down