Skip to content

Commit 7da3abf

Browse files
echonet: refactor l1 client, add init, share retry func
1 parent faf4e55 commit 7da3abf

File tree

2 files changed

+105
-100
lines changed

2 files changed

+105
-100
lines changed

echonet/l1_client.py

Lines changed: 86 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
11
from dataclasses import dataclass
22
from datetime import datetime, timezone
3-
from typing import List, Optional
3+
from typing import Any, Callable, Dict, List, Optional
44

55
import logging
66
import requests
77

8-
logger = logging.getLogger(__name__)
9-
108

119
class L1Client:
1210
L1_MAINNET_URL = "https://eth-mainnet.g.alchemy.com/v2/{api_key}"
@@ -19,7 +17,6 @@ class L1Client:
1917
)
2018
# Taken from ethereum_base_layer_contracts.rs
2119
STARKNET_L1_CONTRACT_ADDRESS = "0xc662c410C0ECf747543f5bA90660f6ABeBD9C8c4"
22-
RETRIES_COUNT = 2
2320

2421
@dataclass(frozen=True)
2522
class Log:
@@ -38,52 +35,82 @@ class Log:
3835
removed: bool
3936
block_timestamp: int
4037

41-
@staticmethod
42-
def get_logs(from_block: int, to_block: int, api_key: str) -> List["L1Client.Log"]:
38+
def __init__(
39+
self,
40+
api_key: str,
41+
logger: logging.Logger,
42+
timeout: int = 10,
43+
retries_count: int = 2,
44+
):
45+
self.api_key = api_key
46+
self.logger = logger
47+
self.timeout = timeout
48+
self.retries_count = retries_count
49+
self.rpc_url = self.L1_MAINNET_URL.format(api_key=api_key)
50+
self.data_api_url = self.DATA_BLOCKS_BY_TIMESTAMP_URL_FMT.format(api_key=api_key)
51+
52+
def _run_request_with_retry(
53+
self,
54+
request_func: Callable[[], Dict],
55+
method_name_for_logs: str,
56+
additional_log_context: Dict[str, Any],
57+
) -> Optional[Dict]:
58+
for attempt in range(self.retries_count):
59+
try:
60+
result = request_func()
61+
self.logger.debug(
62+
f"{method_name_for_logs} succeeded on attempt {attempt + 1}",
63+
extra=additional_log_context,
64+
)
65+
return result
66+
except (requests.RequestException, ValueError):
67+
self.logger.debug(
68+
f"{method_name_for_logs} attempt {attempt + 1}/{self.retries_count} failed",
69+
extra=additional_log_context,
70+
exc_info=True,
71+
)
72+
73+
self.logger.error(
74+
f"{method_name_for_logs} failed after {self.retries_count} attempts, returning None",
75+
extra=additional_log_context,
76+
)
77+
78+
return None
79+
80+
def get_logs(self, from_block: int, to_block: int) -> List["L1Client.Log"]:
4381
"""
4482
Get logs from Ethereum using eth_getLogs RPC method.
45-
Tries up to RETRIES_COUNT times. On failure, logs an error and returns [].
83+
Tries up to retries_count times. On failure, logs an error and returns [].
4684
"""
4785
if from_block > to_block:
4886
raise ValueError("from_block must be less than or equal to to_block")
4987

50-
rpc_url = L1Client.L1_MAINNET_URL.format(api_key=api_key)
51-
5288
payload = {
5389
"jsonrpc": "2.0",
5490
"method": "eth_getLogs",
5591
"params": [
5692
{
5793
"fromBlock": hex(from_block),
5894
"toBlock": hex(to_block),
59-
"address": L1Client.STARKNET_L1_CONTRACT_ADDRESS,
60-
"topics": [L1Client.LOG_MESSAGE_TO_L2_EVENT_SIGNATURE],
95+
"address": self.STARKNET_L1_CONTRACT_ADDRESS,
96+
"topics": [self.LOG_MESSAGE_TO_L2_EVENT_SIGNATURE],
6197
}
6298
],
6399
"id": 1,
64100
}
65101

66-
for attempt in range(L1Client.RETRIES_COUNT):
67-
try:
68-
response = requests.post(rpc_url, json=payload, timeout=10)
69-
response.raise_for_status()
70-
data = response.json()
71-
logger.debug(
72-
f"get_logs succeeded on attempt {attempt + 1}",
73-
extra={"url": rpc_url, "from_block": from_block, "to_block": to_block},
74-
)
75-
break
76-
except (requests.RequestException, ValueError):
77-
logger.debug(
78-
f"get_logs attempt {attempt + 1}/{L1Client.RETRIES_COUNT} failed",
79-
extra={"url": rpc_url, "from_block": from_block, "to_block": to_block},
80-
exc_info=True,
81-
)
82-
else:
83-
logger.error(
84-
f"get_logs failed after {L1Client.RETRIES_COUNT} attempts, returning []",
85-
extra={"url": rpc_url, "from_block": from_block, "to_block": to_block},
86-
)
102+
def request_func():
103+
response = requests.post(self.rpc_url, json=payload, timeout=self.timeout)
104+
response.raise_for_status()
105+
return response.json()
106+
107+
data = self._run_request_with_retry(
108+
request_func=request_func,
109+
method_name_for_logs="get_logs",
110+
additional_log_context={"url": self.rpc_url, "from_block": from_block, "to_block": to_block},
111+
)
112+
113+
if data is None:
87114
return []
88115

89116
results = data.get("result", [])
@@ -104,43 +131,30 @@ def get_logs(from_block: int, to_block: int, api_key: str) -> List["L1Client.Log
104131
for result in results
105132
]
106133

107-
@staticmethod
108-
def get_timestamp_of_block(block_number: int, api_key: str) -> Optional[int]:
134+
def get_timestamp_of_block(self, block_number: int) -> Optional[int]:
109135
"""
110136
Get block timestamp by block number using eth_getBlockByNumber RPC method.
111-
Tries up to RETRIES_COUNT times. On failure, logs an error and returns None.
137+
Tries up to retries_count times. On failure, logs an error and returns None.
112138
"""
113-
rpc_url = L1Client.L1_MAINNET_URL.format(api_key=api_key)
114-
115139
payload = {
116140
"jsonrpc": "2.0",
117141
"method": "eth_getBlockByNumber",
118142
"params": [hex(block_number), False],
119143
"id": 1,
120144
}
121145

122-
for attempt in range(L1Client.RETRIES_COUNT):
123-
try:
124-
response = requests.post(rpc_url, json=payload, timeout=10)
125-
response.raise_for_status()
126-
result = response.json()
127-
logger.debug(
128-
f"get_timestamp_of_block succeeded on attempt {attempt + 1}",
129-
extra={"url": rpc_url, "block_number": block_number},
130-
)
131-
break # success -> exit loop
132-
except (requests.RequestException, ValueError) as exc:
133-
logger.debug(
134-
f"get_timestamp_of_block attempt {attempt + 1}/{L1Client.RETRIES_COUNT} failed",
135-
extra={"url": rpc_url, "block_number": block_number},
136-
exc_info=True,
137-
)
146+
def request_func():
147+
response = requests.post(self.rpc_url, json=payload, timeout=self.timeout)
148+
response.raise_for_status()
149+
return response.json()
138150

139-
else:
140-
logger.error(
141-
f"get_timestamp_of_block failed after {L1Client.RETRIES_COUNT} attempts, returning None",
142-
extra={"url": rpc_url, "block_number": block_number},
143-
)
151+
result = self._run_request_with_retry(
152+
request_func=request_func,
153+
method_name_for_logs="get_timestamp_of_block",
154+
additional_log_context={"url": self.rpc_url, "block_number": block_number},
155+
)
156+
157+
if result is None:
144158
return None
145159

146160
block = result.get("result")
@@ -151,14 +165,11 @@ def get_timestamp_of_block(block_number: int, api_key: str) -> Optional[int]:
151165
# Timestamp is hex string, convert to int.
152166
return int(block["timestamp"], 16)
153167

154-
@staticmethod
155-
def get_block_number_by_timestamp(timestamp: int, api_key: str) -> Optional[int]:
168+
def get_block_number_by_timestamp(self, timestamp: int) -> Optional[int]:
156169
"""
157170
Get the block number at/after a given timestamp using blocks-by-timestamp API.
158-
Tries up to RETRIES_COUNT times. On failure, logs an error and returns None.
171+
Tries up to retries_count times. On failure, logs an error and returns None.
159172
"""
160-
rpc_url = L1Client.DATA_BLOCKS_BY_TIMESTAMP_URL_FMT.format(api_key=api_key)
161-
162173
timestamp_iso = (
163174
datetime.fromtimestamp(timestamp, tz=timezone.utc).isoformat().replace("+00:00", "Z")
164175
)
@@ -169,27 +180,18 @@ def get_block_number_by_timestamp(timestamp: int, api_key: str) -> Optional[int]
169180
"direction": "AFTER",
170181
}
171182

172-
for attempt in range(L1Client.RETRIES_COUNT):
173-
try:
174-
response = requests.get(rpc_url, params=params, timeout=10)
175-
response.raise_for_status()
176-
data = response.json()
177-
logger.debug(
178-
f"get_block_number_by_timestamp succeeded on attempt {attempt + 1}",
179-
extra={"url": rpc_url, "timestamp": timestamp},
180-
)
181-
break # success -> exit loop
182-
except (requests.RequestException, ValueError) as exc:
183-
logger.debug(
184-
f"get_block_number_by_timestamp attempt {attempt + 1}/{L1Client.RETRIES_COUNT} failed",
185-
extra={"url": rpc_url, "timestamp": timestamp},
186-
exc_info=True,
187-
)
188-
else:
189-
logger.error(
190-
f"get_block_number_by_timestamp failed after {L1Client.RETRIES_COUNT} attempts, returning None",
191-
extra={"url": rpc_url, "timestamp": timestamp},
192-
)
183+
def request_func():
184+
response = requests.get(self.data_api_url, params=params, timeout=self.timeout)
185+
response.raise_for_status()
186+
return response.json()
187+
188+
data = self._run_request_with_retry(
189+
request_func=request_func,
190+
method_name_for_logs="get_block_number_by_timestamp",
191+
additional_log_context={"url": self.data_api_url, "timestamp": timestamp},
192+
)
193+
194+
if data is None:
193195
return None
194196

195197
items = data.get("data", [])

echonet/tests/test_l1_client.py

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,14 @@
33

44
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
55

6+
import logging
67
import requests
78
import unittest
89
from l1_client import L1Client
910
from unittest.mock import Mock, patch
1011

12+
logger = logging.getLogger("l1 client tests")
13+
1114

1215
class TestL1Client(unittest.TestCase):
1316
BLOCK_NUMBER_SAMPLE = 20_861_344 # 0x13e51a0
@@ -61,24 +64,24 @@ def test_get_logs_retries_after_exception_and_succeeds_on_second_attempt(self, m
6164

6265
mock_post.side_effect = [request_exception, successful_response]
6366

64-
logs = L1Client.get_logs(
67+
client = L1Client(api_key="api_key", logger=logger)
68+
logs = client.get_logs(
6569
from_block=self.BLOCK_NUMBER_SAMPLE,
6670
to_block=self.BLOCK_NUMBER_SAMPLE,
67-
api_key="api_key",
6871
)
6972

7073
self.assertEqual(mock_post.call_count, 2)
7174
self.assertEqual(logs, [self.EXPECTED_LOG_SAMPLE])
7275

7376
def test_get_logs_raises_on_invalid_block_range(self):
77+
client = L1Client(api_key="api_key", logger=logger)
7478
with self.assertRaisesRegex(
7579
ValueError,
7680
"from_block must be less than or equal to to_block",
7781
):
78-
L1Client.get_logs(
82+
client.get_logs(
7983
from_block=11,
8084
to_block=10,
81-
api_key="api_key",
8285
)
8386

8487
@patch("l1_client.requests.post")
@@ -116,10 +119,10 @@ def test_get_logs_parses_several_results(self, mock_post):
116119

117120
mock_post.return_value = response_ok
118121

119-
logs = L1Client.get_logs(
122+
client = L1Client(api_key="api_key", logger=logger)
123+
logs = client.get_logs(
120124
from_block=1,
121125
to_block=2,
122-
api_key="api_key",
123126
)
124127

125128
self.assertEqual(mock_post.call_count, 1)
@@ -161,10 +164,10 @@ def test_get_logs_when_rpc_result_is_empty(self, mock_post):
161164

162165
mock_post.return_value = response_ok
163166

164-
logs = L1Client.get_logs(
167+
client = L1Client(api_key="api_key", logger=logger)
168+
logs = client.get_logs(
165169
from_block=1,
166170
to_block=1,
167-
api_key="api_key",
168171
)
169172

170173
self.assertEqual(mock_post.call_count, 1)
@@ -180,9 +183,9 @@ def test_get_timestamp_of_block_retries_after_failure_and_succeeds(self, mock_po
180183

181184
mock_post.side_effect = [request_exception, successful_response]
182185

183-
result = L1Client.get_timestamp_of_block(
186+
client = L1Client(api_key="api_key", logger=logger)
187+
result = client.get_timestamp_of_block(
184188
block_number=123,
185-
api_key="api_key",
186189
)
187190

188191
self.assertEqual(mock_post.call_count, 2)
@@ -196,9 +199,9 @@ def test_get_timestamp_of_block_returns_none_when_rpc_result_is_empty(self, mock
196199

197200
mock_post.return_value = response_ok
198201

199-
result = L1Client.get_timestamp_of_block(
202+
client = L1Client(api_key="api_key", logger=logger)
203+
result = client.get_timestamp_of_block(
200204
block_number=123,
201-
api_key="api_key",
202205
)
203206

204207
self.assertEqual(mock_post.call_count, 1)
@@ -216,9 +219,9 @@ def test_get_block_number_by_timestamp_retries_after_failure_and_succeeds(self,
216219

217220
mock_get.side_effect = [request_exception, successful_response]
218221

219-
result = L1Client.get_block_number_by_timestamp(
222+
client = L1Client(api_key="api_key", logger=logger)
223+
result = client.get_block_number_by_timestamp(
220224
timestamp=1_600_000_000,
221-
api_key="api_key",
222225
)
223226

224227
self.assertEqual(mock_get.call_count, 2)
@@ -232,9 +235,9 @@ def test_get_block_number_by_timestamp_returns_none_when_rpc_result_is_empty(sel
232235

233236
mock_get.return_value = response_ok
234237

235-
result = L1Client.get_block_number_by_timestamp(
238+
client = L1Client(api_key="api_key", logger=logger)
239+
result = client.get_block_number_by_timestamp(
236240
timestamp=1_600_000_000,
237-
api_key="api_key",
238241
)
239242

240243
self.assertEqual(mock_get.call_count, 1)

0 commit comments

Comments
 (0)