Skip to content

Commit 0860039

Browse files
committed
fix(rpc): implement global backoff and normalized rate limits
1 parent 9053b6d commit 0860039

4 files changed

Lines changed: 98 additions & 65 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "iwa"
3-
version = "0.0.37"
3+
version = "0.0.38"
44
description = "A secure, modular, and plugin-based framework for crypto agents and ops"
55
readme = "README.md"
66
requires-python = ">=3.12,<4.0"

src/iwa/core/chain/interface.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ def __init__(self, chain: Union[SupportedChain, str] = None):
3535

3636
self.chain = chain
3737
# Enforce strict 1.0 RPS limit to prevent synchronization issues
38-
self._rate_limiter = get_rate_limiter(chain.name, rate=1.0, burst=1)
38+
# Use lowercase chain name for consistent file locking
39+
self._rate_limiter = get_rate_limiter(chain.name.lower(), rate=1.0, burst=1)
3940
self._current_rpc_index = 0
4041
self._rpc_failure_counts: Dict[int, int] = {}
4142

@@ -248,15 +249,21 @@ def _handle_rpc_error(self, error: Exception) -> Dict[str, Union[bool, int]]:
248249
f"(RPC #{self._current_rpc_index}): {error}"
249250
)
250251

252+
# If it's a rate limit error, trigger global backoff immediately
253+
# BEFORE rotating, to tell other processes "hey, cool it!"
254+
if result["is_rate_limit"]:
255+
# Backoff for 5 seconds locally and globally
256+
self._rate_limiter.trigger_backoff(seconds=5.0)
257+
251258
if self.rotate_rpc():
252259
result["rotated"] = True
253260
result["should_retry"] = True
254261
logger.info(f"Rotated to RPC #{self._current_rpc_index} for {self.chain.name}")
255262
else:
256263
if result["is_rate_limit"]:
257-
self._rate_limiter.trigger_backoff(seconds=5.0)
264+
# Already backed off above, but ensure retry flag is set
258265
result["should_retry"] = True
259-
logger.warning("No other RPCs available, triggered backoff")
266+
logger.warning("No other RPCs available, relying on backoff")
260267

261268
elif result["is_server_error"]:
262269
logger.warning(f"Server error on {self.chain.name}: {error}")
@@ -291,8 +298,13 @@ def _init_web3_under_lock(self):
291298
rpc_url = self.chain.rpcs[self._current_rpc_index] if self.chain.rpcs else ""
292299
raw_web3 = Web3(Web3.HTTPProvider(rpc_url, request_kwargs={"timeout": DEFAULT_RPC_TIMEOUT}))
293300

301+
# Ensure rate limiter is up to date (e.g. if we want to enforce parameters again)
302+
# normalize to lowercase
303+
self._rate_limiter = get_rate_limiter(self.chain.name.lower(), rate=1.0, burst=1)
304+
294305
# Use duck typing to check if current web3 is a RateLimitedWeb3 wrapper
295306
if hasattr(self, "web3") and hasattr(self.web3, "set_backend"):
307+
self.web3._rate_limiter = self._rate_limiter # Update limiter ref
296308
self.web3.set_backend(raw_web3)
297309
else:
298310
self.web3 = RateLimitedWeb3(raw_web3, self._rate_limiter, self)

src/iwa/core/chain/rate_limiter.py

Lines changed: 37 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -38,23 +38,22 @@ def __init__(
3838
rate: Maximum requests per second (refill rate)
3939
burst: Maximum tokens (bucket size)
4040
"""
41-
self.chain_name = chain_name
41+
# Normalize chain name to lowercase to prevent split buckets
42+
self.chain_name = chain_name.lower().strip()
4243
self.rate = rate
4344
self.burst = burst
4445

4546
# Ensure cache directory exists
4647
CACHE_DIR.mkdir(parents=True, exist_ok=True)
4748

4849
# Sanitize chain name for filename
49-
safe_name = "".join(c for c in chain_name if c.isalnum() or c in ('-', '_'))
50+
safe_name = "".join(c for c in self.chain_name if c.isalnum() or c in ('-', '_'))
5051
self.state_file: Path = CACHE_DIR / f"rate_limit_{safe_name}.json"
5152

5253
# Use a string path for FileLock to ensure compatibility
5354
lock_path = CACHE_DIR / f"rate_limit_{safe_name}.lock"
5455
self._lock = FileLock(str(lock_path))
5556

56-
self._backoff_until = 0.0
57-
5857
def _load_state(self) -> dict:
5958
"""Load state from file, initializing if necessary."""
6059
try:
@@ -68,6 +67,7 @@ def _load_state(self) -> dict:
6867
return {
6968
"tokens": float(self.burst),
7069
"last_update": time.time(),
70+
"backoff_until": 0.0,
7171
}
7272

7373
def _save_state(self, state: dict):
@@ -86,18 +86,18 @@ def acquire(self, timeout: float = 30.0) -> bool:
8686
try:
8787
# Acquire file lock to read/write state safely across processes
8888
with self._lock.acquire(timeout=timeout):
89+
state = self._load_state()
8990
now = time.time()
9091

91-
# Check backoff (in-memory only for now, could be persisted if needed globally)
92-
if now < self._backoff_until:
93-
wait_time = self._backoff_until - now
92+
# Check global persisted backoff
93+
backoff_until = state.get("backoff_until", 0.0)
94+
if now < backoff_until:
95+
wait_time = backoff_until - now
9496
if now + wait_time > deadline:
9597
return False
9698
else:
97-
state = self._load_state()
98-
99-
tokens = state["tokens"]
100-
last_update = state["last_update"]
99+
tokens = state.get("tokens", float(self.burst))
100+
last_update = state.get("last_update", now)
101101

102102
# Refill tokens based on elapsed time
103103
elapsed = now - last_update
@@ -136,20 +136,31 @@ def acquire(self, timeout: float = 30.0) -> bool:
136136
time.sleep(0.1)
137137

138138
def trigger_backoff(self, seconds: float = 5.0):
139-
"""Trigger rate limit backoff."""
140-
# Simple in-memory backoff for now
141-
self._backoff_until = time.time() + seconds
142-
logger.warning(f"RPC rate limit triggered, backing off for {seconds}s")
139+
"""Trigger rate limit backoff globally."""
140+
backoff_until = time.time() + seconds
141+
logger.warning(f"RPC rate limit triggered for {self.chain_name}, backing off for {seconds}s (GLOBAL)")
142+
143+
try:
144+
with self._lock.acquire(timeout=2.0):
145+
state = self._load_state()
146+
# Only update if our backoff is further in future
147+
current_backoff = state.get("backoff_until", 0.0)
148+
if backoff_until > current_backoff:
149+
state["backoff_until"] = backoff_until
150+
self._save_state(state)
151+
except Exception as e:
152+
logger.error(f"Failed to persist backoff state: {e}")
143153

144154
def get_status(self) -> dict:
145155
"""Get current rate limiter status."""
146156
try:
147157
with self._lock.acquire(timeout=1.0):
148158
state = self._load_state()
149159
return {
150-
"tokens": state["tokens"],
160+
"tokens": state.get("tokens", 0),
151161
"rate": self.rate,
152162
"burst": self.burst,
163+
"backoff_until": state.get("backoff_until", 0.0),
153164
}
154165
except TimeoutError:
155166
return {"error": "Could not acquire lock to read status"}
@@ -165,35 +176,35 @@ def get_rate_limiter(chain_name: str, rate: float = None, burst: int = None) ->
165176
166177
If the limiter already exists but with different parameters, update it.
167178
"""
179+
# Normalize chain name for cache lookup
180+
normalized_name = chain_name.lower().strip()
181+
168182
with _rate_limiters_lock:
169-
if chain_name not in _rate_limiters:
170-
_rate_limiters[chain_name] = RPCRateLimiter(
171-
chain_name=chain_name,
183+
if normalized_name not in _rate_limiters:
184+
_rate_limiters[normalized_name] = RPCRateLimiter(
185+
chain_name=normalized_name,
172186
rate=rate or RPCRateLimiter.DEFAULT_RATE,
173187
burst=burst or RPCRateLimiter.DEFAULT_BURST,
174188
)
175189
else:
176190
# Update existing limiter if stricter parameters are requested
177-
limiter = _rate_limiters[chain_name]
191+
limiter = _rate_limiters[normalized_name]
178192

179193
if rate is not None and rate != limiter.rate:
180194
logger.info(
181-
f"Updating rate limiter for {chain_name}: "
195+
f"Updating rate limiter for {normalized_name}: "
182196
f"rate {limiter.rate}->{rate}"
183197
)
184198
limiter.rate = rate
185199

186200
if burst is not None and burst != limiter.burst:
187201
logger.info(
188-
f"Updating rate limiter for {chain_name}: "
202+
f"Updating rate limiter for {normalized_name}: "
189203
f"burst {limiter.burst}->{burst}"
190204
)
191205
limiter.burst = burst
192206

193-
# If parameters updated, we might want to force a state save/refresh,
194-
# but next acquire will handle it naturally.
195-
196-
return _rate_limiters[chain_name]
207+
return _rate_limiters[normalized_name]
197208

198209

199210
class RateLimitedEth:

src/tests/test_rpc_rate_limit.py

Lines changed: 45 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
12
import json
23
import time
34
from pathlib import Path
@@ -13,54 +14,63 @@ def clean_rate_limiters():
1314
yield
1415
_rate_limiters.clear()
1516

16-
def test_get_rate_limiter_updates_existing_instance(clean_rate_limiters):
17-
"""Verify that get_rate_limiter updates configuration if instance exists."""
18-
chain_name = "UpdateTest"
17+
def test_rate_limiter_name_normalization(clean_rate_limiters):
18+
"""Verify that different casings map to the same file."""
1919

20-
# Mock file operations to prevent real file creation
2120
with patch("iwa.core.chain.rate_limiter.FileLock"), \
2221
patch("iwa.core.chain.rate_limiter.Path.mkdir"), \
2322
patch("iwa.core.chain.rate_limiter.Path.open"), \
24-
patch("json.load", return_value={"tokens": 50.0, "last_update": time.time()}), \
23+
patch("iwa.core.chain.rate_limiter.Path.exists", return_value=True), \
24+
patch("json.load", return_value={"tokens": 50.0, "last_update": time.time(), "backoff_until": 0.0}), \
2525
patch("json.dump"):
2626

27-
# 1. Create with default values (25.0)
28-
limiter1 = get_rate_limiter(chain_name)
29-
assert limiter1.rate == 25.0
30-
assert limiter1.burst == 50
27+
l1 = get_rate_limiter("Gnosis")
28+
l2 = get_rate_limiter("gnosis")
3129

32-
# 2. Request again with stricter values
33-
limiter2 = get_rate_limiter(chain_name, rate=1.0, burst=1)
30+
# Should be same object reference because key is normalized
31+
assert l1 is l2
32+
assert l1.chain_name == "gnosis"
3433

35-
# 3. Verify it is the same instance
36-
assert limiter2 is limiter1
34+
def test_rate_limiter_backoff(clean_rate_limiters):
35+
"""Verify backoff triggers and is respected."""
36+
chain_name = "BackoffTest"
3737

38-
# 4. Verify parameters were updated
39-
assert limiter2.rate == 1.0, f"Rate should be updated to 1.0, got {limiter2.rate}"
40-
assert limiter2.burst == 1, f"Burst should be updated to 1, got {limiter2.burst}"
38+
with patch("iwa.core.chain.rate_limiter.FileLock"), \
39+
patch("iwa.core.chain.rate_limiter.Path.mkdir") as mock_mkdir, \
40+
patch("iwa.core.chain.rate_limiter.Path.open") as mock_open, \
41+
patch("iwa.core.chain.rate_limiter.Path.exists", return_value=True), \
42+
patch("json.dump") as mock_dump:
4143

44+
# Setup mock read to return NO backoff initially
45+
mock_read_state = {"tokens": 10.0, "last_update": time.time(), "backoff_until": 0.0}
4246

43-
def test_rate_limiter_uses_file_lock(clean_rate_limiters):
44-
"""Verify that RPCRateLimiter uses file locks."""
45-
chain_name = "LockTest"
47+
with patch("json.load", return_value=mock_read_state) as mock_load:
48+
limiter = get_rate_limiter(chain_name)
4649

47-
with patch("iwa.core.chain.rate_limiter.FileLock") as MockLock, \
48-
patch("iwa.core.chain.rate_limiter.Path.mkdir"), \
49-
patch("iwa.core.chain.rate_limiter.Path.open") as mock_open, \
50-
patch("json.load", return_value={"tokens": 1.0, "last_update": time.time() - 10}), \
51-
patch("json.dump"):
50+
# 1. Acquire should succeed
51+
assert limiter.acquire() is True
52+
53+
# 2. Trigger Backoff
54+
limiter.trigger_backoff(seconds=5.0)
55+
56+
# Verify we tried to save new state with backoff
57+
args, _ = mock_dump.call_args
58+
saved_state = args[0]
59+
assert saved_state["backoff_until"] > time.time()
5260

53-
limiter = RPCRateLimiter(chain_name, rate=1.0, burst=1)
61+
# 3. Simulate another process/call seeing the backoff
62+
# Update read mock to return the backed-off state
63+
future_backoff = time.time() + 5.0
64+
mock_read_state["backoff_until"] = future_backoff
5465

55-
# Call acquire
56-
limiter.acquire()
66+
# Acquire should now fail (or block, but we expect False if it exceeds timeout,
67+
# here we assume immediate return False if backoff > timeout,
68+
# but wait, acquire blocks. Let's mock a short timeout)
5769

58-
# Verify lock was initialized with correct file path
59-
# Note: In the implementation we convert Path to str for FileLock
60-
MockLock.assert_called_once()
70+
# Our implementation blocks until backoff expires OR timeout.
71+
# If we pass timeout=0.1, and backoff is 5s, it should return False
72+
start = time.time()
73+
result = limiter.acquire(timeout=0.1)
74+
duration = time.time() - start
6175

62-
# Verify lock was acquired
63-
lock_instance = MockLock.return_value
64-
# Note: implementation uses ctx manager `with self._lock.acquire():` which calls __enter__
65-
# Or directly `with self._lock:`
66-
assert lock_instance.acquire.called or lock_instance.__enter__.called
76+
assert result is False, "Should fail to acquire during backoff"

0 commit comments

Comments
 (0)