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.
@@ -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
- # Enforce strict 1.0 RPS limit to prevent synchronization issues
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._rpc_failure_counts: Dict[int, int] = {}
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
- self._rpc_failure_counts[self._current_rpc_index] = (
255
- self._rpc_failure_counts.get(self._current_rpc_index, 0) + 1
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
- error_type = "rate limit" if result["is_rate_limit"] else "connection"
262
- # Extract the original URL from the error message for clarity
263
- error_msg = str(error)
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"(current RPC #{self._current_rpc_index}): {error_msg}"
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
- if result["is_rate_limit"]:
275
- # Rotation was skipped (cooldown or single RPC) - still allow retry with current RPC
276
- # We don't trigger backoff here because that would block ALL threads.
277
- # Instead, we let the individual thread retry (which has its own exponential backoff).
278
- result["should_retry"] = True
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 available RPC."""
338
+ """Rotate to the next healthy RPC, skipping those in backoff."""
295
339
  with self._rotation_lock:
296
- if not self.chain.rpcs or len(self.chain.rpcs) <= 1:
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
- elapsed = now - self._last_rotation_time
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
- # Simple Round Robin rotation
310
- self._current_rpc_index = (self._current_rpc_index + 1) % len(self.chain.rpcs)
311
- # Internal call to _init_web3 already expects to be under lock if called from here,
312
- # but _init_web3 itself doesn't have a lock. Let's make it consistent.
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 {self._current_rpc_index}: {self.chain.rpcs[self._current_rpc_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 failure tracking. Call periodically to allow retrying failed RPCs."""
587
- self._rpc_failure_counts.clear()
588
- logger.debug("Reset RPC failure counts")
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")
@@ -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 = 3
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 logic."""
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
- # Use chain interface to handle error (logging, rotation, etc.)
196
- result = self._chain_interface._handle_rpc_error(e)
215
+ if attempt >= self.DEFAULT_READ_RETRIES:
216
+ raise
197
217
 
198
- if not result["should_retry"] or attempt >= self.DEFAULT_READ_RETRIES:
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, retrying in {delay:.1f}s..."
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
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: iwa
3
- Version: 0.0.59
3
+ Version: 0.0.60
4
4
  Summary: A secure, modular, and plugin-based framework for crypto agents and ops
5
5
  Requires-Python: <4.0,>=3.12
6
6
  Description-Content-Type: text/markdown
@@ -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=FNb_M1Hl1FlgyC3FUPKH91RW7tF-wKJXQP4iOI14vGg,23798
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=6XnaB6i3Tvf-6YD4L-YBeJnKjAIuAxBGDvTmm1dQDTM,7924
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.59.dist-info/licenses/LICENSE,sha256=eIubm_IlBHPYRQlLNZKbBNKhJUUP3JH0A2miZUhAVfI,1078
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=Wu0q0sREtmYBp7YvWrBIrrSTtqeQj18oJp2VmMUEMec,8312
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=gC-mVsTCqGbBoUxAllY_WS9kl12rBHQv7MNr1zdUdGQ,7334
198
- tests/test_rate_limiter_retry.py,sha256=gU4AJk1s39HvdcbGeIoIrr1M7p6WMba_n2y8eFNSXc8,4261
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=Eo-Nr_7p8jERtouCtKuEmcSzACeiVUb33kt7BeTo4uA,1011
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.59.dist-info/METADATA,sha256=FZrh67solKvQcjzWzdns2HPQGMwPZs0B6_Z2rK7hLJA,7337
226
- iwa-0.0.59.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
227
- iwa-0.0.59.dist-info/entry_points.txt,sha256=nwB6kscrfA7M00pYmL2j-sBH6eF6h2ga9IK1BZxdiyQ,241
228
- iwa-0.0.59.dist-info/top_level.txt,sha256=kedS9cRUbm4JE2wYeabIXilhHjN8KCw0IGbqqqsw0Bs,16
229
- iwa-0.0.59.dist-info/RECORD,,
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,,
@@ -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 failure counts."""
204
+ """Test resetting backoff tracking."""
205
205
  interface, _ = mock_chain_interface
206
- interface._rpc_failure_counts = {0: 5, 1: 3}
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._rpc_failure_counts == {}
210
+ assert interface._rpc_backoff_until == {}
@@ -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}"
File without changes