Skip to content

Commit bdc7117

Browse files
committed
feat: add api keys rotating
1 parent 02ba4dd commit bdc7117

File tree

6 files changed

+72
-13
lines changed

6 files changed

+72
-13
lines changed

aioetherscan/client.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from asyncio import AbstractEventLoop
2-
from typing import AsyncContextManager
2+
from typing import AsyncContextManager, Union
33

44
from aiohttp import ClientTimeout
55
from aiohttp_retry import RetryOptionsBase
@@ -20,7 +20,7 @@
2020
class Client:
2121
def __init__(
2222
self,
23-
api_key: str,
23+
api_key: Union[str, list[str]],
2424
api_kind: str = 'eth',
2525
network: str = 'main',
2626
loop: AbstractEventLoop = None,
@@ -29,7 +29,8 @@ def __init__(
2929
throttler: AsyncContextManager = None,
3030
retry_options: RetryOptionsBase = None,
3131
) -> None:
32-
self._url_builder = UrlBuilder(api_key, api_kind, network)
32+
api_keys = [api_key] if isinstance(api_key, str) else api_key
33+
self._url_builder = UrlBuilder(api_keys, api_kind, network)
3334
self._http = Network(self._url_builder, loop, timeout, proxy, throttler, retry_options)
3435

3536
self.account = Account(self)

aioetherscan/exceptions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ def __str__(self):
2020
return f'[{self.message}] {self.result}'
2121

2222

23+
class EtherscanClientApiRateLimitError(EtherscanClientApiError):
24+
pass
25+
26+
2327
class EtherscanClientProxyError(EtherscanClientError):
2428
"""JSON-RPC 2.0 Specification
2529

aioetherscan/network.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import asyncio
22
import logging
33
from asyncio import AbstractEventLoop
4+
from functools import wraps
45
from typing import Union, AsyncContextManager, Optional
56

67
import aiohttp
@@ -15,10 +16,29 @@
1516
EtherscanClientError,
1617
EtherscanClientApiError,
1718
EtherscanClientProxyError,
19+
EtherscanClientApiRateLimitError,
1820
)
1921
from aioetherscan.url_builder import UrlBuilder
2022

2123

24+
def retry_limit_attempt(f):
25+
@wraps(f)
26+
async def inner(self, *args, **kwargs):
27+
attempt = 1
28+
max_attempts = self._url_builder.keys_count
29+
while True:
30+
try:
31+
return await f(self, *args, **kwargs)
32+
except EtherscanClientApiRateLimitError as e:
33+
self._logger.warning(f'Key daily limit exceeded, {attempt=}: {e}')
34+
if attempt >= max_attempts:
35+
raise e
36+
await asyncio.sleep(0.01)
37+
self._url_builder.rotate_api_key()
38+
39+
return inner
40+
41+
2242
class Network:
2343
def __init__(
2444
self,
@@ -48,9 +68,11 @@ async def close(self):
4868
if self._retry_client is not None:
4969
await self._retry_client.close()
5070

71+
@retry_limit_attempt
5172
async def get(self, params: dict = None) -> Union[dict, list, str]:
5273
return await self._request(METH_GET, params=self._url_builder.filter_and_sign(params))
5374

75+
@retry_limit_attempt
5476
async def post(self, data: dict = None) -> Union[dict, list, str]:
5577
return await self._request(METH_POST, data=self._url_builder.filter_and_sign(data))
5678

@@ -68,6 +90,7 @@ async def _request(
6890
if self._retry_client is None:
6991
self._retry_client = self._get_retry_client()
7092
session_method = getattr(self._retry_client, method.lower())
93+
7194
async with self._throttler:
7295
async with session_method(
7396
self._url_builder.API_URL, params=params, data=data, proxy=self._proxy
@@ -93,6 +116,10 @@ async def _handle_response(self, response: aiohttp.ClientResponse) -> Union[dict
93116
def _raise_if_error(response_json: dict):
94117
if 'status' in response_json and response_json['status'] != '1':
95118
message, result = response_json.get('message'), response_json.get('result')
119+
120+
if 'max daily rate limit reached' in result.lower():
121+
raise EtherscanClientApiRateLimitError(message, result)
122+
96123
raise EtherscanClientApiError(message, result)
97124

98125
if 'error' in response_json:

aioetherscan/url_builder.py

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import logging
2+
from itertools import cycle
13
from typing import Optional
24
from urllib.parse import urlunsplit, urljoin
35

@@ -19,15 +21,19 @@ class UrlBuilder:
1921
BASE_URL: str = None
2022
API_URL: str = None
2123

22-
def __init__(self, api_key: str, api_kind: str, network: str) -> None:
23-
self._API_KEY = api_key
24+
def __init__(self, api_keys: list[str], api_kind: str, network: str) -> None:
25+
self._api_keys = api_keys
26+
self._api_keys_cycle = cycle(self._api_keys)
27+
self._api_key = self._get_next_api_key()
2428

2529
self._set_api_kind(api_kind)
2630
self._network = network.lower().strip()
2731

2832
self.API_URL = self._get_api_url()
2933
self.BASE_URL = self._get_base_url()
3034

35+
self._logger = logging.getLogger(__name__)
36+
3137
def _set_api_kind(self, api_kind: str) -> None:
3238
api_kind = api_kind.lower().strip()
3339
if api_kind not in self._API_KINDS:
@@ -87,9 +93,30 @@ def filter_and_sign(self, params: dict):
8793
def _sign(self, params: dict) -> dict:
8894
if not params:
8995
params = {}
90-
params['apikey'] = self._API_KEY
96+
params['apikey'] = self._api_key
9197
return params
9298

9399
@staticmethod
94100
def _filter_params(params: dict) -> dict:
95101
return {k: v for k, v in params.items() if v is not None}
102+
103+
def _get_next_api_key(self) -> str:
104+
return next(self._api_keys_cycle)
105+
106+
def rotate_api_key(self) -> None:
107+
prev_api_key = self._api_key
108+
next_api_key = self._get_next_api_key()
109+
110+
self._logger.info(
111+
f'Rotating API key from {self._mask_api_key(prev_api_key)} to {self._mask_api_key(next_api_key)}'
112+
)
113+
114+
self._api_key = next_api_key
115+
116+
@staticmethod
117+
def _mask_api_key(api_key: str, masked_chars_count: int = 4) -> str:
118+
return '*' * (len(api_key) - masked_chars_count) + api_key[-masked_chars_count:]
119+
120+
@property
121+
def keys_count(self) -> int:
122+
return len(self._api_keys)

tests/test_network.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -86,25 +86,25 @@ def test_no_loop(ub):
8686
async def test_get(nw):
8787
with patch('aioetherscan.network.Network._request', new=AsyncMock()) as mock:
8888
await nw.get()
89-
mock.assert_called_once_with(METH_GET, params={'apikey': nw._url_builder._API_KEY})
89+
mock.assert_called_once_with(METH_GET, params={'apikey': nw._url_builder._api_key})
9090

9191

9292
@pytest.mark.asyncio
9393
async def test_post(nw):
9494
with patch('aioetherscan.network.Network._request', new=AsyncMock()) as mock:
9595
await nw.post()
96-
mock.assert_called_once_with(METH_POST, data={'apikey': nw._url_builder._API_KEY})
96+
mock.assert_called_once_with(METH_POST, data={'apikey': nw._url_builder._api_key})
9797

9898
with patch('aioetherscan.network.Network._request', new=AsyncMock()) as mock:
9999
await nw.post({'some': 'data'})
100100
mock.assert_called_once_with(
101-
METH_POST, data={'apikey': nw._url_builder._API_KEY, 'some': 'data'}
101+
METH_POST, data={'apikey': nw._url_builder._api_key, 'some': 'data'}
102102
)
103103

104104
with patch('aioetherscan.network.Network._request', new=AsyncMock()) as mock:
105105
await nw.post({'some': 'data', 'null': None})
106106
mock.assert_called_once_with(
107-
METH_POST, data={'apikey': nw._url_builder._API_KEY, 'some': 'data'}
107+
METH_POST, data={'apikey': nw._url_builder._api_key, 'some': 'data'}
108108
)
109109

110110

tests/test_url_builder.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88

99
def apikey():
10-
return 'test_api_key'
10+
return ['test_api_key']
1111

1212

1313
@pytest_asyncio.fixture
@@ -17,8 +17,8 @@ async def ub():
1717

1818

1919
def test_sign(ub):
20-
assert ub._sign({}) == {'apikey': ub._API_KEY}
21-
assert ub._sign({'something': 'something'}) == {'something': 'something', 'apikey': ub._API_KEY}
20+
assert ub._sign({}) == {'apikey': ub._api_key}
21+
assert ub._sign({'something': 'something'}) == {'something': 'something', 'apikey': ub._api_key}
2222

2323

2424
def test_filter_params(ub):

0 commit comments

Comments
 (0)