iwa 0.0.59__py3-none-any.whl → 0.0.60__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 +89 -35
- iwa/core/chain/rate_limiter.py +35 -6
- {iwa-0.0.59.dist-info → iwa-0.0.60.dist-info}/METADATA +1 -1
- {iwa-0.0.59.dist-info → iwa-0.0.60.dist-info}/RECORD +12 -12
- tests/test_chain_interface.py +3 -3
- tests/test_rate_limiter.py +7 -5
- tests/test_rate_limiter_retry.py +33 -27
- tests/test_rpc_rate_limit.py +3 -3
- {iwa-0.0.59.dist-info → iwa-0.0.60.dist-info}/WHEEL +0 -0
- {iwa-0.0.59.dist-info → iwa-0.0.60.dist-info}/entry_points.txt +0 -0
- {iwa-0.0.59.dist-info → iwa-0.0.60.dist-info}/licenses/LICENSE +0 -0
- {iwa-0.0.59.dist-info → iwa-0.0.60.dist-info}/top_level.txt +0 -0
iwa/core/chain/interface.py
CHANGED
|
@@ -26,6 +26,11 @@ class ChainInterface:
|
|
|
26
26
|
DEFAULT_RETRY_DELAY = 1.0 # Base delay between retries (exponential backoff)
|
|
27
27
|
ROTATION_COOLDOWN_SECONDS = 2.0 # Minimum time between RPC rotations
|
|
28
28
|
|
|
29
|
+
# Per-error-type backoff durations (seconds) applied to the offending RPC.
|
|
30
|
+
RATE_LIMIT_BACKOFF = 10.0 # 429 Too Many Requests
|
|
31
|
+
QUOTA_EXCEEDED_BACKOFF = 300.0 # RPC quota exhausted (resets hourly/daily)
|
|
32
|
+
CONNECTION_ERROR_BACKOFF = 30.0 # Timeout / connection refused / DNS
|
|
33
|
+
|
|
29
34
|
chain: SupportedChain
|
|
30
35
|
|
|
31
36
|
def __init__(self, chain: Union[SupportedChain, str] = None):
|
|
@@ -36,10 +41,9 @@ class ChainInterface:
|
|
|
36
41
|
chain: SupportedChain = getattr(SupportedChains(), chain.lower())
|
|
37
42
|
|
|
38
43
|
self.chain = chain
|
|
39
|
-
|
|
40
|
-
self._rate_limiter = get_rate_limiter(chain.name, rate=1.0, burst=1)
|
|
44
|
+
self._rate_limiter = get_rate_limiter(chain.name, rate=5.0, burst=10)
|
|
41
45
|
self._current_rpc_index = 0
|
|
42
|
-
self.
|
|
46
|
+
self._rpc_backoff_until: Dict[int, float] = {} # index -> monotonic expiry
|
|
43
47
|
self._last_rotation_time = 0.0 # Monotonic timestamp of last rotation
|
|
44
48
|
|
|
45
49
|
if self.chain.rpc and self.chain.rpc.startswith("http://"):
|
|
@@ -229,6 +233,34 @@ class ChainInterface:
|
|
|
229
233
|
]
|
|
230
234
|
return any(signal in err_text for signal in gas_signals)
|
|
231
235
|
|
|
236
|
+
def _is_quota_exceeded_error(self, error: Exception) -> bool:
|
|
237
|
+
"""Check if the RPC's usage quota has been exhausted.
|
|
238
|
+
|
|
239
|
+
JSON-RPC code -32001 with messages like "Exceeded the quota usage"
|
|
240
|
+
indicates the provider's daily/hourly quota is spent. This is NOT
|
|
241
|
+
a transient 429 rate-limit; the RPC will reject ALL requests until
|
|
242
|
+
the quota resets, so it must be backed off for a long period.
|
|
243
|
+
"""
|
|
244
|
+
err_text = str(error).lower()
|
|
245
|
+
quota_signals = [
|
|
246
|
+
"exceeded the quota",
|
|
247
|
+
"exceeded quota",
|
|
248
|
+
"quota usage",
|
|
249
|
+
"quota exceeded",
|
|
250
|
+
"allowance exceeded",
|
|
251
|
+
]
|
|
252
|
+
return any(signal in err_text for signal in quota_signals)
|
|
253
|
+
|
|
254
|
+
# -- Per-RPC health tracking ------------------------------------------
|
|
255
|
+
|
|
256
|
+
def _mark_rpc_backoff(self, index: int, seconds: float) -> None:
|
|
257
|
+
"""Mark an RPC as temporarily unavailable for *seconds*."""
|
|
258
|
+
self._rpc_backoff_until[index] = time.monotonic() + seconds
|
|
259
|
+
|
|
260
|
+
def _is_rpc_healthy(self, index: int) -> bool:
|
|
261
|
+
"""Return True if the RPC at *index* is not in backoff."""
|
|
262
|
+
return time.monotonic() >= self._rpc_backoff_until.get(index, 0.0)
|
|
263
|
+
|
|
232
264
|
def _handle_rpc_error(self, error: Exception) -> Dict[str, Union[bool, int]]:
|
|
233
265
|
"""Handle RPC errors with smart rotation and retry logic."""
|
|
234
266
|
result: Dict[str, Union[bool, int]] = {
|
|
@@ -237,6 +269,7 @@ class ChainInterface:
|
|
|
237
269
|
"is_server_error": self._is_server_error(error),
|
|
238
270
|
"is_gas_error": self._is_gas_error(error),
|
|
239
271
|
"is_tenderly_quota": self._is_tenderly_quota_exceeded(error),
|
|
272
|
+
"is_quota_exceeded": self._is_quota_exceeded_error(error),
|
|
240
273
|
"rotated": False,
|
|
241
274
|
"should_retry": False,
|
|
242
275
|
}
|
|
@@ -251,19 +284,33 @@ class ChainInterface:
|
|
|
251
284
|
"Run 'uv run -m iwa.tools.reset_tenderly' to reset."
|
|
252
285
|
)
|
|
253
286
|
|
|
254
|
-
|
|
255
|
-
|
|
287
|
+
# Determine if we need to rotate and what backoff to apply.
|
|
288
|
+
should_rotate = (
|
|
289
|
+
result["is_rate_limit"]
|
|
290
|
+
or result["is_connection_error"]
|
|
291
|
+
or result["is_quota_exceeded"]
|
|
256
292
|
)
|
|
257
293
|
|
|
258
|
-
should_rotate = result["is_rate_limit"] or result["is_connection_error"]
|
|
259
|
-
|
|
260
294
|
if should_rotate:
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
295
|
+
failed_index = self._current_rpc_index
|
|
296
|
+
|
|
297
|
+
# Apply per-RPC backoff so smart rotation skips this RPC.
|
|
298
|
+
if result["is_quota_exceeded"]:
|
|
299
|
+
error_type = "quota exceeded"
|
|
300
|
+
self._mark_rpc_backoff(failed_index, self.QUOTA_EXCEEDED_BACKOFF)
|
|
301
|
+
elif result["is_rate_limit"]:
|
|
302
|
+
error_type = "rate limit"
|
|
303
|
+
self._mark_rpc_backoff(failed_index, self.RATE_LIMIT_BACKOFF)
|
|
304
|
+
# Brief global backoff so other threads don't immediately flood
|
|
305
|
+
# the same (now backed-off) RPC before rotation takes effect.
|
|
306
|
+
self._rate_limiter.trigger_backoff(seconds=2.0)
|
|
307
|
+
else:
|
|
308
|
+
error_type = "connection"
|
|
309
|
+
self._mark_rpc_backoff(failed_index, self.CONNECTION_ERROR_BACKOFF)
|
|
310
|
+
|
|
264
311
|
logger.warning(
|
|
265
312
|
f"RPC {error_type} error on {self.chain.name} "
|
|
266
|
-
f"(
|
|
313
|
+
f"(RPC #{failed_index}): {error}"
|
|
267
314
|
)
|
|
268
315
|
|
|
269
316
|
if self.rotate_rpc():
|
|
@@ -271,14 +318,11 @@ class ChainInterface:
|
|
|
271
318
|
result["should_retry"] = True
|
|
272
319
|
logger.info(f"Rotated to RPC #{self._current_rpc_index} for {self.chain.name}")
|
|
273
320
|
else:
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
logger.info(
|
|
280
|
-
f"RPC rotation skipped, retrying with current RPC #{self._current_rpc_index}"
|
|
281
|
-
)
|
|
321
|
+
# Rotation skipped (cooldown or single RPC) - still allow retry
|
|
322
|
+
result["should_retry"] = True
|
|
323
|
+
logger.info(
|
|
324
|
+
f"RPC rotation skipped, retrying with current RPC #{self._current_rpc_index}"
|
|
325
|
+
)
|
|
282
326
|
|
|
283
327
|
elif result["is_server_error"]:
|
|
284
328
|
logger.warning(f"Server error on {self.chain.name}: {error}")
|
|
@@ -291,30 +335,40 @@ class ChainInterface:
|
|
|
291
335
|
return result
|
|
292
336
|
|
|
293
337
|
def rotate_rpc(self) -> bool:
|
|
294
|
-
"""Rotate to the next
|
|
338
|
+
"""Rotate to the next healthy RPC, skipping those in backoff."""
|
|
295
339
|
with self._rotation_lock:
|
|
296
|
-
|
|
340
|
+
n = len(self.chain.rpcs) if self.chain.rpcs else 0
|
|
341
|
+
if n <= 1:
|
|
297
342
|
return False
|
|
298
343
|
|
|
299
344
|
# Cooldown: prevent cascade rotations from in-flight requests
|
|
300
345
|
now = time.monotonic()
|
|
301
|
-
|
|
302
|
-
if elapsed < self.ROTATION_COOLDOWN_SECONDS:
|
|
303
|
-
logger.debug(
|
|
304
|
-
f"RPC rotation skipped for {self.chain.name} (cooldown active, "
|
|
305
|
-
f"{self.ROTATION_COOLDOWN_SECONDS - elapsed:.1f}s remaining)"
|
|
306
|
-
)
|
|
346
|
+
if now - self._last_rotation_time < self.ROTATION_COOLDOWN_SECONDS:
|
|
307
347
|
return False
|
|
308
348
|
|
|
309
|
-
#
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
349
|
+
# Try each other RPC in round-robin order, preferring healthy ones.
|
|
350
|
+
best: Optional[int] = None
|
|
351
|
+
for offset in range(1, n):
|
|
352
|
+
candidate = (self._current_rpc_index + offset) % n
|
|
353
|
+
if self._is_rpc_healthy(candidate):
|
|
354
|
+
best = candidate
|
|
355
|
+
break
|
|
356
|
+
|
|
357
|
+
if best is None:
|
|
358
|
+
# All RPCs are in backoff — pick the one whose backoff expires soonest.
|
|
359
|
+
best = min(
|
|
360
|
+
(i for i in range(n) if i != self._current_rpc_index),
|
|
361
|
+
key=lambda i: self._rpc_backoff_until.get(i, 0.0),
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
self._current_rpc_index = best
|
|
313
365
|
self._init_web3_under_lock()
|
|
314
366
|
self._last_rotation_time = now
|
|
315
367
|
|
|
368
|
+
healthy_tag = "" if self._is_rpc_healthy(best) else " (still in backoff)"
|
|
316
369
|
logger.info(
|
|
317
|
-
f"Rotated RPC for {self.chain.name} to index {
|
|
370
|
+
f"Rotated RPC for {self.chain.name} to index {best}: "
|
|
371
|
+
f"{self.chain.rpcs[best]}{healthy_tag}"
|
|
318
372
|
)
|
|
319
373
|
return True
|
|
320
374
|
|
|
@@ -583,6 +637,6 @@ class ChainInterface:
|
|
|
583
637
|
return self.chain.contracts.get(contract_name)
|
|
584
638
|
|
|
585
639
|
def reset_rpc_failure_counts(self):
|
|
586
|
-
"""Reset RPC
|
|
587
|
-
self.
|
|
588
|
-
logger.debug("Reset RPC
|
|
640
|
+
"""Reset RPC backoff tracking. Call periodically to allow retrying backed-off RPCs."""
|
|
641
|
+
self._rpc_backoff_until.clear()
|
|
642
|
+
logger.debug("Reset RPC backoff tracking")
|
iwa/core/chain/rate_limiter.py
CHANGED
|
@@ -128,9 +128,22 @@ class RateLimitedEth:
|
|
|
128
128
|
# Helper sets for efficient lookup
|
|
129
129
|
RPC_METHODS = READ_METHODS | WRITE_METHODS
|
|
130
130
|
|
|
131
|
-
DEFAULT_READ_RETRIES =
|
|
131
|
+
DEFAULT_READ_RETRIES = 1 # Keep low; ChainInterface.with_retry handles cross-RPC retries
|
|
132
132
|
DEFAULT_READ_RETRY_DELAY = 0.5
|
|
133
133
|
|
|
134
|
+
# Only retry errors that are clearly transient network issues.
|
|
135
|
+
# Rate-limit / quota / server errors propagate up to with_retry for rotation.
|
|
136
|
+
TRANSIENT_SIGNALS = (
|
|
137
|
+
"timeout",
|
|
138
|
+
"timed out",
|
|
139
|
+
"connection reset",
|
|
140
|
+
"connection refused",
|
|
141
|
+
"connection aborted",
|
|
142
|
+
"broken pipe",
|
|
143
|
+
"eof",
|
|
144
|
+
"remote end closed",
|
|
145
|
+
)
|
|
146
|
+
|
|
134
147
|
def __init__(self, web3_eth, rate_limiter: RPCRateLimiter, chain_interface: "ChainInterface"):
|
|
135
148
|
"""Initialize RateLimitedEth wrapper."""
|
|
136
149
|
object.__setattr__(self, "_eth", web3_eth)
|
|
@@ -187,20 +200,36 @@ class RateLimitedEth:
|
|
|
187
200
|
return wrapper
|
|
188
201
|
|
|
189
202
|
def _execute_with_retry(self, method, method_name, *args, **kwargs):
|
|
190
|
-
"""Execute read operation with retry
|
|
203
|
+
"""Execute a read operation with limited retry for transient errors.
|
|
204
|
+
|
|
205
|
+
Only connection-level failures (timeout, reset, broken pipe) are
|
|
206
|
+
retried here. Rate-limit, quota, and server errors propagate up
|
|
207
|
+
to ``ChainInterface.with_retry`` which handles RPC rotation.
|
|
208
|
+
This avoids the double-retry amplification that previously caused
|
|
209
|
+
up to 4x7 = 28 RPC requests per logical call.
|
|
210
|
+
"""
|
|
191
211
|
for attempt in range(self.DEFAULT_READ_RETRIES + 1):
|
|
192
212
|
try:
|
|
193
213
|
return method(*args, **kwargs)
|
|
194
214
|
except Exception as e:
|
|
195
|
-
|
|
196
|
-
|
|
215
|
+
if attempt >= self.DEFAULT_READ_RETRIES:
|
|
216
|
+
raise
|
|
197
217
|
|
|
198
|
-
|
|
218
|
+
# Only retry clearly transient network errors.
|
|
219
|
+
err_text = str(e).lower()
|
|
220
|
+
if not any(signal in err_text for signal in self.TRANSIENT_SIGNALS):
|
|
199
221
|
raise
|
|
200
222
|
|
|
223
|
+
# Re-acquire a rate-limiter token before retrying.
|
|
224
|
+
if not self._rate_limiter.acquire(timeout=30.0):
|
|
225
|
+
raise TimeoutError(
|
|
226
|
+
f"Rate limit timeout for retry of {method_name}"
|
|
227
|
+
) from e
|
|
228
|
+
|
|
201
229
|
delay = self.DEFAULT_READ_RETRY_DELAY * (2**attempt)
|
|
202
230
|
logger.debug(
|
|
203
|
-
f"{method_name} attempt {attempt + 1} failed,
|
|
231
|
+
f"{method_name} attempt {attempt + 1} failed (transient), "
|
|
232
|
+
f"retrying in {delay:.1f}s..."
|
|
204
233
|
)
|
|
205
234
|
time.sleep(delay)
|
|
206
235
|
|
|
@@ -23,10 +23,10 @@ iwa/core/utils.py,sha256=FTYpIdQ1wnugD4lYU4TQ7d7_TlDs4CTUIhEpHGEJph4,4281
|
|
|
23
23
|
iwa/core/wallet.py,sha256=xSGFOK5Wzh-ctLGhBMK1BySlXN0Ircpztyk1an21QiQ,13129
|
|
24
24
|
iwa/core/chain/__init__.py,sha256=XJMmn0ed-_aVkY2iEMKpuTxPgIKBd41dexSVmEZTa-o,1604
|
|
25
25
|
iwa/core/chain/errors.py,sha256=9SEbhxZ-qASPkzt-DoI51qq0GRJVqRgqgL720gO7a64,1275
|
|
26
|
-
iwa/core/chain/interface.py,sha256=
|
|
26
|
+
iwa/core/chain/interface.py,sha256=ww779Wek8qeIxu5t0v3hcmwXq7dMaxp0TjpW4Eikg8Y,25924
|
|
27
27
|
iwa/core/chain/manager.py,sha256=cFEzh6pK5OyVhjhpeMAqhc9RnRDQR1DjIGiGKp-FXBI,1159
|
|
28
28
|
iwa/core/chain/models.py,sha256=WUhAighMKcFdbAUkPU_3dkGbWyAUpRJqXMHLcWFC1xg,5261
|
|
29
|
-
iwa/core/chain/rate_limiter.py,sha256=
|
|
29
|
+
iwa/core/chain/rate_limiter.py,sha256=Ps1MrR4HHtylxgUAawe6DoC9tuqKagjQdKulqcJD2gs,9093
|
|
30
30
|
iwa/core/contracts/__init__.py,sha256=P5GFY_pnuI02teqVY2U0t98bn1_SSPAbcAzRMpCdTi4,34
|
|
31
31
|
iwa/core/contracts/cache.py,sha256=vN7ArNhNsSDr1rYHDMWsMm6VbSszBt4Xej9MeI-rkgc,4452
|
|
32
32
|
iwa/core/contracts/contract.py,sha256=TLZGF7BtMl2fr92B80Gp3ttnP4hJsdAG-raaFZiNLO8,13255
|
|
@@ -166,7 +166,7 @@ iwa/web/tests/test_web_endpoints.py,sha256=vA25YghHNB23sbmhD4ciesn_f_okSq0tjlkrS
|
|
|
166
166
|
iwa/web/tests/test_web_olas.py,sha256=0CVSsrncOeJ3x0ECV7mVLQV_CXZRrOqGiVjgLIi6hZ8,16308
|
|
167
167
|
iwa/web/tests/test_web_swap.py,sha256=7A4gBJFL01kIXPtW1E1J17SCsVc_0DmUn-R8kKrnnVA,2974
|
|
168
168
|
iwa/web/tests/test_web_swap_coverage.py,sha256=zGNrzlhZ_vWDCvWmLcoUwFgqxnrp_ACbo49AtWBS_Kw,5584
|
|
169
|
-
iwa-0.0.
|
|
169
|
+
iwa-0.0.60.dist-info/licenses/LICENSE,sha256=eIubm_IlBHPYRQlLNZKbBNKhJUUP3JH0A2miZUhAVfI,1078
|
|
170
170
|
tests/legacy_cow.py,sha256=oOkZvIxL70ReEoD9oHQbOD5GpjIr6AGNHcOCgfPlerU,8389
|
|
171
171
|
tests/legacy_safe.py,sha256=AssM2g13E74dNGODu_H0Q0y412lgqsrYnEzI97nm_Ts,2972
|
|
172
172
|
tests/legacy_transaction_retry_logic.py,sha256=D9RqZ7DBu61Xr2djBAodU2p9UE939LL-DnQXswX5iQk,1497
|
|
@@ -176,7 +176,7 @@ tests/legacy_web.py,sha256=q2ERIriaDHT3Q8axG2N3ucO7f2VSvV_WkuPR00DVko4,8577
|
|
|
176
176
|
tests/test_account_service.py,sha256=g_AIVT2jhlvUtbFTaCd-d15x4CmXJQaV66tlAgnaXwY,3745
|
|
177
177
|
tests/test_balance_service.py,sha256=wcuCOVszxPy8nPkldAVcEiygcOK3BuQt797fqAJvbp4,4979
|
|
178
178
|
tests/test_chain.py,sha256=VZoidSojWyt1y4mQdZdoZsjuuDZjLC6neTC-2SF_Q7I,13957
|
|
179
|
-
tests/test_chain_interface.py,sha256=
|
|
179
|
+
tests/test_chain_interface.py,sha256=bgqGM8wJGZjc-BOX6i0K4sh06KCJl-6UAvrwl8x24lA,8324
|
|
180
180
|
tests/test_chain_interface_coverage.py,sha256=fvrVvw8-DMwdsSFKQHUhpbfutrVRxnnTc-tjB7Bb-jo,3327
|
|
181
181
|
tests/test_cli.py,sha256=Pl4RC2xp1omiJUnL3Dza6pCmIoO29LJ0vGw33_ZpT5c,3980
|
|
182
182
|
tests/test_contract.py,sha256=tApHAxsfKGawYJWA9PhTNrOZUE0VVAq79ruIe3KxeWY,14412
|
|
@@ -194,11 +194,11 @@ tests/test_models.py,sha256=1bEfPiDVgEdtwFEzwecSPAHjCF8kjOPSMeQExJ7eCJ4,7107
|
|
|
194
194
|
tests/test_monitor.py,sha256=dRVS6EkTwfvGEOg7t0dVhs6M3oEZExBH7iBZe6hmk4M,7261
|
|
195
195
|
tests/test_multisend.py,sha256=IvXpwnC5xSDRCyCDGcMdO3L-eQegvdjAzHZB0FoVFUI,2685
|
|
196
196
|
tests/test_plugin_service.py,sha256=ZEe37kV_sv4Eb04032O1hZIoo9yf5gJo83ks7Grzrng,3767
|
|
197
|
-
tests/test_rate_limiter.py,sha256=
|
|
198
|
-
tests/test_rate_limiter_retry.py,sha256=
|
|
197
|
+
tests/test_rate_limiter.py,sha256=XDN22HWs85OicBpQ9zgHRnoJ1VMola_AOkobqp83dfs,7444
|
|
198
|
+
tests/test_rate_limiter_retry.py,sha256=Yq7Ik2r8VIYgPdlSN2tYbdA0ngrB37ZPimfkZkh9Cvk,4568
|
|
199
199
|
tests/test_reset_tenderly.py,sha256=GVoqbDT3n4_GnlKF5Lx-8ew15jT8I2hIPdTulQDb6dI,7215
|
|
200
200
|
tests/test_rpc_efficiency.py,sha256=mNuCoa5r6lSEyTqcRX98oz-huoKMTUlKM2UcOHlTQ6M,3745
|
|
201
|
-
tests/test_rpc_rate_limit.py,sha256=
|
|
201
|
+
tests/test_rpc_rate_limit.py,sha256=3P_Nd9voFmz-4r_Et-vw8W-Esbq5elSYmRBSOtJGx1Y,1014
|
|
202
202
|
tests/test_rpc_rotation.py,sha256=a1cFKsf0fo-73_MSDnTuU6Zpv7bJHjrCVu3ANe8PXDU,12541
|
|
203
203
|
tests/test_rpc_view.py,sha256=sgZ53KEHl8VGb7WKYa0VI7Cdxbf8JH1SdroHYbWHjfQ,2031
|
|
204
204
|
tests/test_safe_coverage.py,sha256=KBxKz64XkK8CgN0N0LTNVKakf8Wg8EpghcBlLmDFmLs,6119
|
|
@@ -222,8 +222,8 @@ tests/test_utils.py,sha256=vkP49rYNI8BRzLpWR3WnKdDr8upeZjZcs7Rx0pjbQMo,1292
|
|
|
222
222
|
tests/test_workers.py,sha256=MInwdkFY5LdmFB3o1odIaSD7AQZb3263hNafO1De5PE,2793
|
|
223
223
|
tools/create_and_stake_service.py,sha256=1xwy_bJQI1j9yIQ968Oc9Db_F6mk1659LuuZntTASDE,3742
|
|
224
224
|
tools/verify_drain.py,sha256=PkMjblyOOAuQge88FwfEzRtCYeEtJxXhPBmtQYCoQ-8,6743
|
|
225
|
-
iwa-0.0.
|
|
226
|
-
iwa-0.0.
|
|
227
|
-
iwa-0.0.
|
|
228
|
-
iwa-0.0.
|
|
229
|
-
iwa-0.0.
|
|
225
|
+
iwa-0.0.60.dist-info/METADATA,sha256=BpkWW6DmPQhaX7fFo7LtVfNYg3thQWSMiMmR_yGApCc,7337
|
|
226
|
+
iwa-0.0.60.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
227
|
+
iwa-0.0.60.dist-info/entry_points.txt,sha256=nwB6kscrfA7M00pYmL2j-sBH6eF6h2ga9IK1BZxdiyQ,241
|
|
228
|
+
iwa-0.0.60.dist-info/top_level.txt,sha256=kedS9cRUbm4JE2wYeabIXilhHjN8KCw0IGbqqqsw0Bs,16
|
|
229
|
+
iwa-0.0.60.dist-info/RECORD,,
|
tests/test_chain_interface.py
CHANGED
|
@@ -201,10 +201,10 @@ def test_is_tenderly_property():
|
|
|
201
201
|
|
|
202
202
|
|
|
203
203
|
def test_reset_rpc_failure_counts(mock_chain_interface):
|
|
204
|
-
"""Test resetting
|
|
204
|
+
"""Test resetting backoff tracking."""
|
|
205
205
|
interface, _ = mock_chain_interface
|
|
206
|
-
interface.
|
|
206
|
+
interface._rpc_backoff_until = {0: 99999.0, 1: 99999.0}
|
|
207
207
|
|
|
208
208
|
interface.reset_rpc_failure_counts()
|
|
209
209
|
|
|
210
|
-
assert interface.
|
|
210
|
+
assert interface._rpc_backoff_until == {}
|
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}"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|