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.
@@ -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()
@@ -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 before backoff."""
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
- # Backoff should NOT be triggered since rotation succeeded
175
- assert ci._rate_limiter.get_status()["in_backoff"] is False
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 but NO backoff (skipped for single RPC)
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 False
201
+ assert ci._rate_limiter.get_status()["in_backoff"] is True
@@ -19,28 +19,39 @@ class TestRateLimitedEthRetry:
19
19
  chain_interface = MockChainInterface()
20
20
  return web3_eth, rate_limiter, chain_interface
21
21
 
22
- def test_read_method_retries_on_failure(self, mock_deps):
23
- """Verify that read methods automatically retry on failure."""
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 twice then succeed
27
+ # Mock get_balance to fail once with transient error then succeed
28
28
  web3_eth.get_balance.side_effect = [
29
- ValueError("RPC error 1"),
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
- assert web3_eth.get_balance.call_count == 3
40
- # Should have slept twice
41
- assert mock_sleep.call_count == 2
42
- # Verify handle_error was called
43
- assert chain_interface._handle_rpc_error.call_count == 2
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 for quicker test
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("Persistently failing")
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="Persistently failing"):
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
- # Mock property access: fail then succeed
91
- # Note: PropertyMock is needed if we were mocking a property on the CLASS,
92
- # but here we are mocking the instance attribute access which might be a method call or property.
93
- # web3.eth.block_number is a property.
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
- # Verify handle_error called
103
- assert chain_interface._handle_rpc_error.call_count == 1
108
+ # _handle_rpc_error is NOT called by RateLimitedEth anymore
109
+ assert chain_interface._handle_rpc_error.call_count == 0
@@ -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=1.0 and burst=1."""
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 == 1.0, f"Expected rate 1.0, got {limiter.rate}"
34
- assert limiter.burst == 1, f"Expected burst 1, got {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}"