iwa 0.0.59__py3-none-any.whl → 0.0.61__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- iwa/core/chain/interface.py +151 -36
- iwa/core/chain/manager.py +8 -0
- iwa/core/chain/rate_limiter.py +35 -6
- iwa/core/chainlist.py +183 -5
- {iwa-0.0.59.dist-info → iwa-0.0.61.dist-info}/METADATA +1 -1
- {iwa-0.0.59.dist-info → iwa-0.0.61.dist-info}/RECORD +17 -16
- tests/test_chain_interface.py +3 -3
- tests/test_chainlist_enrichment.py +354 -0
- tests/test_rate_limiter.py +7 -5
- tests/test_rate_limiter_retry.py +33 -27
- tests/test_rpc_rate_limit.py +3 -3
- tests/test_safe_executor.py +208 -0
- tests/test_transaction_service.py +178 -2
- {iwa-0.0.59.dist-info → iwa-0.0.61.dist-info}/WHEEL +0 -0
- {iwa-0.0.59.dist-info → iwa-0.0.61.dist-info}/entry_points.txt +0 -0
- {iwa-0.0.59.dist-info → iwa-0.0.61.dist-info}/licenses/LICENSE +0 -0
- {iwa-0.0.59.dist-info → iwa-0.0.61.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
"""Tests for ChainList RPC enrichment and quality probing."""
|
|
2
|
+
|
|
3
|
+
from unittest.mock import MagicMock, patch
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
import requests
|
|
7
|
+
|
|
8
|
+
from iwa.core.chainlist import (
|
|
9
|
+
ChainlistRPC,
|
|
10
|
+
RPCNode,
|
|
11
|
+
_is_template_url,
|
|
12
|
+
_normalize_url,
|
|
13
|
+
probe_rpc,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.fixture(autouse=True)
|
|
18
|
+
def mock_chainlist_enrichment():
|
|
19
|
+
"""Override conftest — allow real enrichment calls in this test file."""
|
|
20
|
+
yield
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TestNormalizeUrl:
|
|
24
|
+
"""Test URL normalization for deduplication."""
|
|
25
|
+
|
|
26
|
+
def test_strips_trailing_slash(self):
|
|
27
|
+
assert _normalize_url("https://rpc.example.com/") == "https://rpc.example.com"
|
|
28
|
+
|
|
29
|
+
def test_lowercases(self):
|
|
30
|
+
assert _normalize_url("https://RPC.Example.COM") == "https://rpc.example.com"
|
|
31
|
+
|
|
32
|
+
def test_no_change_needed(self):
|
|
33
|
+
assert _normalize_url("https://rpc.example.com") == "https://rpc.example.com"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class TestIsTemplateUrl:
|
|
37
|
+
"""Test template URL detection."""
|
|
38
|
+
|
|
39
|
+
def test_dollar_brace(self):
|
|
40
|
+
assert _is_template_url("https://rpc.example.com/${API_KEY}") is True
|
|
41
|
+
|
|
42
|
+
def test_plain_brace(self):
|
|
43
|
+
assert _is_template_url("https://rpc.example.com/{api_key}") is True
|
|
44
|
+
|
|
45
|
+
def test_no_template(self):
|
|
46
|
+
assert _is_template_url("https://rpc.example.com") is False
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class TestProbeRpc:
|
|
50
|
+
"""Test single RPC probing."""
|
|
51
|
+
|
|
52
|
+
@pytest.fixture
|
|
53
|
+
def mock_session(self):
|
|
54
|
+
"""Create a mock session for probe_rpc tests."""
|
|
55
|
+
with patch("iwa.core.chainlist.requests.Session") as mock_session_cls:
|
|
56
|
+
mock_session = MagicMock()
|
|
57
|
+
mock_session_cls.return_value = mock_session
|
|
58
|
+
# Make it work as context manager too
|
|
59
|
+
mock_session.__enter__ = MagicMock(return_value=mock_session)
|
|
60
|
+
mock_session.__exit__ = MagicMock(return_value=False)
|
|
61
|
+
yield mock_session
|
|
62
|
+
|
|
63
|
+
def test_success(self, mock_session):
|
|
64
|
+
mock_resp = MagicMock()
|
|
65
|
+
mock_resp.json.return_value = {"jsonrpc": "2.0", "result": "0x1A4B5C", "id": 1}
|
|
66
|
+
mock_session.post.return_value = mock_resp
|
|
67
|
+
|
|
68
|
+
result = probe_rpc("https://rpc.example.com")
|
|
69
|
+
|
|
70
|
+
assert result is not None
|
|
71
|
+
url, latency, block = result
|
|
72
|
+
assert url == "https://rpc.example.com"
|
|
73
|
+
assert latency > 0
|
|
74
|
+
assert block == 0x1A4B5C
|
|
75
|
+
# Verify session was closed
|
|
76
|
+
mock_session.close.assert_called_once()
|
|
77
|
+
|
|
78
|
+
def test_timeout_returns_none(self, mock_session):
|
|
79
|
+
mock_session.post.side_effect = requests.exceptions.Timeout("timed out")
|
|
80
|
+
|
|
81
|
+
result = probe_rpc("https://slow.example.com")
|
|
82
|
+
assert result is None
|
|
83
|
+
# Session still closed on error
|
|
84
|
+
mock_session.close.assert_called_once()
|
|
85
|
+
|
|
86
|
+
def test_connection_error_returns_none(self, mock_session):
|
|
87
|
+
mock_session.post.side_effect = requests.exceptions.ConnectionError("refused")
|
|
88
|
+
|
|
89
|
+
result = probe_rpc("https://dead.example.com")
|
|
90
|
+
assert result is None
|
|
91
|
+
mock_session.close.assert_called_once()
|
|
92
|
+
|
|
93
|
+
def test_zero_block_returns_none(self, mock_session):
|
|
94
|
+
mock_resp = MagicMock()
|
|
95
|
+
mock_resp.json.return_value = {"jsonrpc": "2.0", "result": "0x0", "id": 1}
|
|
96
|
+
mock_session.post.return_value = mock_resp
|
|
97
|
+
|
|
98
|
+
result = probe_rpc("https://rpc.example.com")
|
|
99
|
+
assert result is None
|
|
100
|
+
|
|
101
|
+
def test_null_result_returns_none(self, mock_session):
|
|
102
|
+
mock_resp = MagicMock()
|
|
103
|
+
mock_resp.json.return_value = {"jsonrpc": "2.0", "result": None, "id": 1}
|
|
104
|
+
mock_session.post.return_value = mock_resp
|
|
105
|
+
|
|
106
|
+
result = probe_rpc("https://rpc.example.com")
|
|
107
|
+
assert result is None
|
|
108
|
+
|
|
109
|
+
def test_error_response_returns_none(self, mock_session):
|
|
110
|
+
mock_resp = MagicMock()
|
|
111
|
+
mock_resp.json.return_value = {
|
|
112
|
+
"jsonrpc": "2.0",
|
|
113
|
+
"error": {"code": -32600, "message": "Invalid Request"},
|
|
114
|
+
"id": 1,
|
|
115
|
+
}
|
|
116
|
+
mock_session.post.return_value = mock_resp
|
|
117
|
+
|
|
118
|
+
result = probe_rpc("https://rpc.example.com")
|
|
119
|
+
assert result is None
|
|
120
|
+
|
|
121
|
+
def test_uses_provided_session(self):
|
|
122
|
+
"""When a session is provided, use it instead of creating one."""
|
|
123
|
+
provided_session = MagicMock()
|
|
124
|
+
mock_resp = MagicMock()
|
|
125
|
+
mock_resp.json.return_value = {"jsonrpc": "2.0", "result": "0x100", "id": 1}
|
|
126
|
+
provided_session.post.return_value = mock_resp
|
|
127
|
+
|
|
128
|
+
result = probe_rpc("https://rpc.example.com", session=provided_session)
|
|
129
|
+
|
|
130
|
+
assert result is not None
|
|
131
|
+
provided_session.post.assert_called_once()
|
|
132
|
+
# Should NOT close provided session (caller's responsibility)
|
|
133
|
+
provided_session.close.assert_not_called()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class TestGetValidatedRpcs:
|
|
137
|
+
"""Test ChainlistRPC.get_validated_rpcs()."""
|
|
138
|
+
|
|
139
|
+
def _make_node(self, url, tracking="none"):
|
|
140
|
+
return RPCNode(url=url, is_working=True, tracking=tracking)
|
|
141
|
+
|
|
142
|
+
@patch.object(ChainlistRPC, "get_rpcs")
|
|
143
|
+
@patch("iwa.core.chainlist.probe_rpc")
|
|
144
|
+
def test_filters_template_urls(self, mock_probe, mock_get_rpcs):
|
|
145
|
+
mock_get_rpcs.return_value = [
|
|
146
|
+
self._make_node("https://rpc.example.com/${API_KEY}"),
|
|
147
|
+
self._make_node("https://good.example.com"),
|
|
148
|
+
]
|
|
149
|
+
mock_probe.return_value = ("https://good.example.com", 50.0, 1000)
|
|
150
|
+
|
|
151
|
+
cl = ChainlistRPC()
|
|
152
|
+
result = cl.get_validated_rpcs(100, existing_rpcs=[])
|
|
153
|
+
|
|
154
|
+
assert result == ["https://good.example.com"]
|
|
155
|
+
|
|
156
|
+
@patch.object(ChainlistRPC, "get_rpcs")
|
|
157
|
+
@patch("iwa.core.chainlist.probe_rpc")
|
|
158
|
+
def test_filters_non_https(self, mock_probe, mock_get_rpcs):
|
|
159
|
+
mock_get_rpcs.return_value = [
|
|
160
|
+
self._make_node("http://insecure.example.com"),
|
|
161
|
+
self._make_node("wss://ws.example.com"),
|
|
162
|
+
self._make_node("https://good.example.com"),
|
|
163
|
+
]
|
|
164
|
+
mock_probe.return_value = ("https://good.example.com", 50.0, 1000)
|
|
165
|
+
|
|
166
|
+
cl = ChainlistRPC()
|
|
167
|
+
result = cl.get_validated_rpcs(100, existing_rpcs=[])
|
|
168
|
+
|
|
169
|
+
assert result == ["https://good.example.com"]
|
|
170
|
+
|
|
171
|
+
@patch.object(ChainlistRPC, "get_rpcs")
|
|
172
|
+
@patch("iwa.core.chainlist.probe_rpc")
|
|
173
|
+
def test_deduplicates_existing(self, mock_probe, mock_get_rpcs):
|
|
174
|
+
mock_get_rpcs.return_value = [
|
|
175
|
+
self._make_node("https://already.configured.com"),
|
|
176
|
+
self._make_node("https://new.example.com"),
|
|
177
|
+
]
|
|
178
|
+
mock_probe.return_value = ("https://new.example.com", 50.0, 1000)
|
|
179
|
+
|
|
180
|
+
cl = ChainlistRPC()
|
|
181
|
+
result = cl.get_validated_rpcs(
|
|
182
|
+
100, existing_rpcs=["https://already.configured.com/"]
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
assert result == ["https://new.example.com"]
|
|
186
|
+
|
|
187
|
+
@patch.object(ChainlistRPC, "get_rpcs")
|
|
188
|
+
@patch("iwa.core.chainlist.probe_rpc")
|
|
189
|
+
def test_filters_stale_rpcs(self, mock_probe, mock_get_rpcs):
|
|
190
|
+
mock_get_rpcs.return_value = [
|
|
191
|
+
self._make_node("https://fresh.example.com"),
|
|
192
|
+
self._make_node("https://stale.example.com"),
|
|
193
|
+
self._make_node("https://also-fresh.example.com"),
|
|
194
|
+
]
|
|
195
|
+
# fresh=1000, stale=900 (100 blocks behind), also-fresh=999
|
|
196
|
+
mock_probe.side_effect = [
|
|
197
|
+
("https://fresh.example.com", 50.0, 1000),
|
|
198
|
+
("https://stale.example.com", 30.0, 900),
|
|
199
|
+
("https://also-fresh.example.com", 60.0, 999),
|
|
200
|
+
]
|
|
201
|
+
|
|
202
|
+
cl = ChainlistRPC()
|
|
203
|
+
result = cl.get_validated_rpcs(100, existing_rpcs=[])
|
|
204
|
+
|
|
205
|
+
# Stale RPC (900) is 100 blocks behind median (999) > MAX_BLOCK_LAG
|
|
206
|
+
assert "https://stale.example.com" not in result
|
|
207
|
+
assert "https://fresh.example.com" in result
|
|
208
|
+
assert "https://also-fresh.example.com" in result
|
|
209
|
+
|
|
210
|
+
@patch.object(ChainlistRPC, "get_rpcs")
|
|
211
|
+
@patch("iwa.core.chainlist.probe_rpc")
|
|
212
|
+
def test_sorts_by_latency(self, mock_probe, mock_get_rpcs):
|
|
213
|
+
mock_get_rpcs.return_value = [
|
|
214
|
+
self._make_node("https://slow.example.com"),
|
|
215
|
+
self._make_node("https://fast.example.com"),
|
|
216
|
+
self._make_node("https://medium.example.com"),
|
|
217
|
+
]
|
|
218
|
+
mock_probe.side_effect = [
|
|
219
|
+
("https://slow.example.com", 200.0, 1000),
|
|
220
|
+
("https://fast.example.com", 10.0, 1000),
|
|
221
|
+
("https://medium.example.com", 80.0, 1000),
|
|
222
|
+
]
|
|
223
|
+
|
|
224
|
+
cl = ChainlistRPC()
|
|
225
|
+
result = cl.get_validated_rpcs(100, existing_rpcs=[])
|
|
226
|
+
|
|
227
|
+
assert result == [
|
|
228
|
+
"https://fast.example.com",
|
|
229
|
+
"https://medium.example.com",
|
|
230
|
+
"https://slow.example.com",
|
|
231
|
+
]
|
|
232
|
+
|
|
233
|
+
@patch.object(ChainlistRPC, "get_rpcs")
|
|
234
|
+
@patch("iwa.core.chainlist.probe_rpc")
|
|
235
|
+
def test_respects_max_results(self, mock_probe, mock_get_rpcs):
|
|
236
|
+
nodes = [self._make_node(f"https://rpc{i}.example.com") for i in range(10)]
|
|
237
|
+
mock_get_rpcs.return_value = nodes
|
|
238
|
+
mock_probe.side_effect = [
|
|
239
|
+
(f"https://rpc{i}.example.com", float(i * 10), 1000) for i in range(10)
|
|
240
|
+
]
|
|
241
|
+
|
|
242
|
+
cl = ChainlistRPC()
|
|
243
|
+
result = cl.get_validated_rpcs(100, existing_rpcs=[], max_results=3)
|
|
244
|
+
|
|
245
|
+
assert len(result) == 3
|
|
246
|
+
|
|
247
|
+
@patch.object(ChainlistRPC, "get_rpcs")
|
|
248
|
+
def test_returns_empty_on_no_rpcs(self, mock_get_rpcs):
|
|
249
|
+
mock_get_rpcs.return_value = []
|
|
250
|
+
|
|
251
|
+
cl = ChainlistRPC()
|
|
252
|
+
result = cl.get_validated_rpcs(100, existing_rpcs=[])
|
|
253
|
+
|
|
254
|
+
assert result == []
|
|
255
|
+
|
|
256
|
+
@patch.object(ChainlistRPC, "get_rpcs")
|
|
257
|
+
@patch("iwa.core.chainlist.probe_rpc")
|
|
258
|
+
def test_returns_empty_when_all_probes_fail(self, mock_probe, mock_get_rpcs):
|
|
259
|
+
mock_get_rpcs.return_value = [
|
|
260
|
+
self._make_node("https://dead1.example.com"),
|
|
261
|
+
self._make_node("https://dead2.example.com"),
|
|
262
|
+
]
|
|
263
|
+
mock_probe.return_value = None
|
|
264
|
+
|
|
265
|
+
cl = ChainlistRPC()
|
|
266
|
+
result = cl.get_validated_rpcs(100, existing_rpcs=[])
|
|
267
|
+
|
|
268
|
+
assert result == []
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
class TestEnrichFromChainlist:
|
|
272
|
+
"""Test ChainInterface._enrich_rpcs_from_chainlist().
|
|
273
|
+
|
|
274
|
+
The conftest fixture is overridden in this file so the real
|
|
275
|
+
enrichment method runs during __init__.
|
|
276
|
+
"""
|
|
277
|
+
|
|
278
|
+
@patch("iwa.core.chain.interface.Web3")
|
|
279
|
+
def test_skipped_for_tenderly(self, mock_web3):
|
|
280
|
+
from iwa.core.chain.interface import ChainInterface
|
|
281
|
+
from iwa.core.chain.models import SupportedChain
|
|
282
|
+
|
|
283
|
+
chain = MagicMock(spec=SupportedChain)
|
|
284
|
+
chain.name = "TestChain"
|
|
285
|
+
chain.rpcs = ["https://virtual.tenderly.co/test"]
|
|
286
|
+
chain.rpc = "https://virtual.tenderly.co/test"
|
|
287
|
+
chain.chain_id = 100
|
|
288
|
+
|
|
289
|
+
with patch("iwa.core.chainlist.ChainlistRPC") as mock_cl_cls:
|
|
290
|
+
ci = ChainInterface(chain)
|
|
291
|
+
|
|
292
|
+
# is_tenderly=True → enrichment skipped → ChainlistRPC never called
|
|
293
|
+
assert "tenderly" in ci.current_rpc.lower()
|
|
294
|
+
mock_cl_cls.assert_not_called()
|
|
295
|
+
|
|
296
|
+
@patch("iwa.core.chain.interface.Web3")
|
|
297
|
+
def test_enriches_non_tenderly(self, mock_web3):
|
|
298
|
+
from iwa.core.chain.interface import ChainInterface
|
|
299
|
+
from iwa.core.chain.models import SupportedChain
|
|
300
|
+
|
|
301
|
+
chain = MagicMock(spec=SupportedChain)
|
|
302
|
+
chain.name = "TestChain"
|
|
303
|
+
chain.rpcs = ["https://rpc1.example.com"]
|
|
304
|
+
chain.rpc = "https://rpc1.example.com"
|
|
305
|
+
chain.chain_id = 100
|
|
306
|
+
|
|
307
|
+
with patch("iwa.core.chainlist.ChainlistRPC") as mock_cl_cls:
|
|
308
|
+
mock_cl = mock_cl_cls.return_value
|
|
309
|
+
mock_cl.get_validated_rpcs.return_value = [
|
|
310
|
+
"https://extra1.example.com",
|
|
311
|
+
"https://extra2.example.com",
|
|
312
|
+
]
|
|
313
|
+
ChainInterface(chain)
|
|
314
|
+
|
|
315
|
+
assert len(chain.rpcs) == 3
|
|
316
|
+
assert "https://extra1.example.com" in chain.rpcs
|
|
317
|
+
assert "https://extra2.example.com" in chain.rpcs
|
|
318
|
+
# Original RPC stays first
|
|
319
|
+
assert chain.rpcs[0] == "https://rpc1.example.com"
|
|
320
|
+
|
|
321
|
+
@patch("iwa.core.chain.interface.Web3")
|
|
322
|
+
def test_survives_fetch_failure(self, mock_web3):
|
|
323
|
+
from iwa.core.chain.interface import ChainInterface
|
|
324
|
+
from iwa.core.chain.models import SupportedChain
|
|
325
|
+
|
|
326
|
+
chain = MagicMock(spec=SupportedChain)
|
|
327
|
+
chain.name = "TestChain"
|
|
328
|
+
chain.rpcs = ["https://rpc1.example.com"]
|
|
329
|
+
chain.rpc = "https://rpc1.example.com"
|
|
330
|
+
chain.chain_id = 100
|
|
331
|
+
|
|
332
|
+
with patch("iwa.core.chainlist.ChainlistRPC") as mock_cl_cls:
|
|
333
|
+
mock_cl_cls.side_effect = Exception("network error")
|
|
334
|
+
ChainInterface(chain)
|
|
335
|
+
|
|
336
|
+
# Should still work with original RPCs
|
|
337
|
+
assert chain.rpcs == ["https://rpc1.example.com"]
|
|
338
|
+
|
|
339
|
+
@patch("iwa.core.chain.interface.Web3")
|
|
340
|
+
def test_respects_max_rpcs(self, mock_web3):
|
|
341
|
+
from iwa.core.chain.interface import ChainInterface
|
|
342
|
+
from iwa.core.chain.models import SupportedChain
|
|
343
|
+
|
|
344
|
+
chain = MagicMock(spec=SupportedChain)
|
|
345
|
+
chain.name = "TestChain"
|
|
346
|
+
chain.rpcs = [f"https://rpc{i}.example.com" for i in range(10)]
|
|
347
|
+
chain.rpc = "https://rpc0.example.com"
|
|
348
|
+
chain.chain_id = 100
|
|
349
|
+
|
|
350
|
+
with patch("iwa.core.chainlist.ChainlistRPC") as mock_cl_cls:
|
|
351
|
+
ChainInterface(chain)
|
|
352
|
+
|
|
353
|
+
# Already at MAX_RPCS=10, ChainlistRPC should not be called
|
|
354
|
+
mock_cl_cls.assert_not_called()
|
tests/test_rate_limiter.py
CHANGED
|
@@ -146,7 +146,7 @@ class TestRateLimitRotationInterplay:
|
|
|
146
146
|
"""Test interaction between rate limiting and RPC rotation."""
|
|
147
147
|
|
|
148
148
|
def test_rate_limit_triggers_rotation_first(self):
|
|
149
|
-
"""Test that rate limit error triggers RPC rotation
|
|
149
|
+
"""Test that rate limit error triggers RPC rotation and global backoff."""
|
|
150
150
|
from unittest.mock import MagicMock, PropertyMock
|
|
151
151
|
|
|
152
152
|
from iwa.core.chain import ChainInterface, SupportedChain
|
|
@@ -171,8 +171,10 @@ class TestRateLimitRotationInterplay:
|
|
|
171
171
|
assert result["rotated"] is True
|
|
172
172
|
assert result["should_retry"] is True
|
|
173
173
|
assert ci._current_rpc_index != original_index
|
|
174
|
-
#
|
|
175
|
-
assert ci._rate_limiter.get_status()["in_backoff"] is
|
|
174
|
+
# Global backoff IS triggered to slow other threads briefly
|
|
175
|
+
assert ci._rate_limiter.get_status()["in_backoff"] is True
|
|
176
|
+
# The old RPC should be marked in per-RPC backoff
|
|
177
|
+
assert not ci._is_rpc_healthy(original_index)
|
|
176
178
|
|
|
177
179
|
def test_rate_limit_triggers_backoff_when_no_rotation(self):
|
|
178
180
|
"""Test that rate limit triggers backoff when no other RPCs available."""
|
|
@@ -193,7 +195,7 @@ class TestRateLimitRotationInterplay:
|
|
|
193
195
|
rate_limit_error = Exception("Error 429: Too Many Requests")
|
|
194
196
|
result = ci._handle_rpc_error(rate_limit_error)
|
|
195
197
|
|
|
196
|
-
# Should have triggered retry
|
|
198
|
+
# Should have triggered retry and global backoff
|
|
197
199
|
assert result["should_retry"] is True
|
|
198
200
|
assert result["rotated"] is False
|
|
199
|
-
assert ci._rate_limiter.get_status()["in_backoff"] is
|
|
201
|
+
assert ci._rate_limiter.get_status()["in_backoff"] is True
|
tests/test_rate_limiter_retry.py
CHANGED
|
@@ -19,28 +19,39 @@ class TestRateLimitedEthRetry:
|
|
|
19
19
|
chain_interface = MockChainInterface()
|
|
20
20
|
return web3_eth, rate_limiter, chain_interface
|
|
21
21
|
|
|
22
|
-
def
|
|
23
|
-
"""Verify that read methods
|
|
22
|
+
def test_read_method_retries_on_transient_failure(self, mock_deps):
|
|
23
|
+
"""Verify that read methods retry on transient (connection) errors."""
|
|
24
24
|
web3_eth, rate_limiter, chain_interface = mock_deps
|
|
25
25
|
eth_wrapper = RateLimitedEth(web3_eth, rate_limiter, chain_interface)
|
|
26
26
|
|
|
27
|
-
# Mock get_balance to fail
|
|
27
|
+
# Mock get_balance to fail once with transient error then succeed
|
|
28
28
|
web3_eth.get_balance.side_effect = [
|
|
29
|
-
ValueError("
|
|
30
|
-
ValueError("RPC error 2"),
|
|
29
|
+
ValueError("connection timeout"),
|
|
31
30
|
100, # Success
|
|
32
31
|
]
|
|
33
32
|
|
|
34
|
-
# Use patch to speed up sleep
|
|
35
33
|
with patch("time.sleep") as mock_sleep:
|
|
36
34
|
result = eth_wrapper.get_balance("0x123")
|
|
37
35
|
|
|
38
36
|
assert result == 100
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
assert mock_sleep.call_count ==
|
|
42
|
-
#
|
|
43
|
-
assert chain_interface._handle_rpc_error.call_count ==
|
|
37
|
+
# 1 initial + 1 retry = 2 calls (DEFAULT_READ_RETRIES=1)
|
|
38
|
+
assert web3_eth.get_balance.call_count == 2
|
|
39
|
+
assert mock_sleep.call_count == 1
|
|
40
|
+
# RateLimitedEth no longer calls _handle_rpc_error (that's for with_retry)
|
|
41
|
+
assert chain_interface._handle_rpc_error.call_count == 0
|
|
42
|
+
|
|
43
|
+
def test_read_method_raises_non_transient_immediately(self, mock_deps):
|
|
44
|
+
"""Verify non-transient errors (rate limit, quota) propagate immediately."""
|
|
45
|
+
web3_eth, rate_limiter, chain_interface = mock_deps
|
|
46
|
+
eth_wrapper = RateLimitedEth(web3_eth, rate_limiter, chain_interface)
|
|
47
|
+
|
|
48
|
+
web3_eth.get_balance.side_effect = ValueError("429 Too Many Requests")
|
|
49
|
+
|
|
50
|
+
with pytest.raises(ValueError, match="429"):
|
|
51
|
+
eth_wrapper.get_balance("0x123")
|
|
52
|
+
|
|
53
|
+
# Only 1 attempt, no retry for non-transient errors
|
|
54
|
+
assert web3_eth.get_balance.call_count == 1
|
|
44
55
|
|
|
45
56
|
def test_write_method_no_auto_retry(self, mock_deps):
|
|
46
57
|
"""Verify that write methods (send_raw_transaction) DO NOT auto-retry."""
|
|
@@ -60,23 +71,21 @@ class TestRateLimitedEthRetry:
|
|
|
60
71
|
# Should verify it was called only once
|
|
61
72
|
assert web3_eth.send_raw_transaction.call_count == 1
|
|
62
73
|
# Chain interface error handler should NOT be called by the wrapper itself
|
|
63
|
-
# (It might typically be called by the caller)
|
|
64
74
|
assert chain_interface._handle_rpc_error.call_count == 0
|
|
65
75
|
|
|
66
76
|
def test_retry_respects_max_attempts(self, mock_deps):
|
|
67
|
-
"""Verify that retry logic respects maximum attempts."""
|
|
77
|
+
"""Verify that retry logic respects maximum attempts for transient errors."""
|
|
68
78
|
web3_eth, rate_limiter, chain_interface = mock_deps
|
|
69
79
|
eth_wrapper = RateLimitedEth(web3_eth, rate_limiter, chain_interface)
|
|
70
80
|
|
|
71
|
-
# Override default retries
|
|
72
|
-
# Use object.__setattr__ because RateLimitedEth overrides __setattr__
|
|
81
|
+
# Override default retries
|
|
73
82
|
object.__setattr__(eth_wrapper, "DEFAULT_READ_RETRIES", 2)
|
|
74
83
|
|
|
75
|
-
# Mock always failing
|
|
76
|
-
web3_eth.get_code.side_effect = ValueError("
|
|
84
|
+
# Mock always failing with transient error
|
|
85
|
+
web3_eth.get_code.side_effect = ValueError("connection reset by peer")
|
|
77
86
|
|
|
78
87
|
with patch("time.sleep"):
|
|
79
|
-
with pytest.raises(ValueError, match="
|
|
88
|
+
with pytest.raises(ValueError, match="connection reset"):
|
|
80
89
|
eth_wrapper.get_code("0x123")
|
|
81
90
|
|
|
82
91
|
# Attempts: initial + 2 retries = 3 total calls
|
|
@@ -87,17 +96,14 @@ class TestRateLimitedEthRetry:
|
|
|
87
96
|
web3_eth, rate_limiter, chain_interface = mock_deps
|
|
88
97
|
eth_wrapper = RateLimitedEth(web3_eth, rate_limiter, chain_interface)
|
|
89
98
|
|
|
90
|
-
#
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
# We need to set side_effect on the PROPERTY of the mock
|
|
96
|
-
type(web3_eth).block_number = PropertyMock(side_effect=[ValueError("Fail 1"), 12345])
|
|
99
|
+
# block_number fails once with transient error then succeeds
|
|
100
|
+
type(web3_eth).block_number = PropertyMock(
|
|
101
|
+
side_effect=[ValueError("connection timeout"), 12345]
|
|
102
|
+
)
|
|
97
103
|
|
|
98
104
|
with patch("time.sleep"):
|
|
99
105
|
val = eth_wrapper.block_number
|
|
100
106
|
|
|
101
107
|
assert val == 12345
|
|
102
|
-
#
|
|
103
|
-
assert chain_interface._handle_rpc_error.call_count ==
|
|
108
|
+
# _handle_rpc_error is NOT called by RateLimitedEth anymore
|
|
109
|
+
assert chain_interface._handle_rpc_error.call_count == 0
|
tests/test_rpc_rate_limit.py
CHANGED
|
@@ -16,7 +16,7 @@ def clean_rate_limiters():
|
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
def test_chain_interface_initializes_strict_limiter(clean_rate_limiters):
|
|
19
|
-
"""Verify ChainInterface initializes with rate=
|
|
19
|
+
"""Verify ChainInterface initializes with rate=5.0 and burst=10."""
|
|
20
20
|
# Create a dummy chain
|
|
21
21
|
chain = MagicMock(spec=SupportedChain)
|
|
22
22
|
chain.name = "TestSlowChain"
|
|
@@ -30,5 +30,5 @@ def test_chain_interface_initializes_strict_limiter(clean_rate_limiters):
|
|
|
30
30
|
limiter = ci._rate_limiter
|
|
31
31
|
|
|
32
32
|
# Assert correct configuration
|
|
33
|
-
assert limiter.rate ==
|
|
34
|
-
assert limiter.burst ==
|
|
33
|
+
assert limiter.rate == 5.0, f"Expected rate 5.0, got {limiter.rate}"
|
|
34
|
+
assert limiter.burst == 10, f"Expected burst 10, got {limiter.burst}"
|