Skip to content

Commit 32541ea

Browse files
committed
fix(blockchain): stop failover loop retrying forever when pool is dead
The loop decided it had tried every provider when the provider index got back to where it started, but reconnection skips providers that refuse connections, so the index could step over that value forever. Counting failed providers guarantees the loop stops.
1 parent 74796f8 commit 32541ea

2 files changed

Lines changed: 33 additions & 4 deletions

File tree

src/models/blockchain_client.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ def _execute_rpc_call(self, func: Callable, *args: Any, **kwargs: Any) -> Any:
196196
Execute func(*args, **kwargs) with retries and provider failover, rotating
197197
through the RPC pool and raising ConnectionError once every provider has failed.
198198
"""
199-
initial_index = self.current_rpc_index
199+
providers_tried = 0
200200
while True:
201201
try:
202202
# Add retry logic with backoff for the specific function call
@@ -210,17 +210,19 @@ def do_call():
210210

211211
# If we get an exception after all retries, log the error and switch to the next RPC provider
212212
except RPC_FAILOVER_EXCEPTIONS as e:
213+
providers_tried += 1
213214
current_provider = self.rpc_providers[self.current_rpc_index]
214215
logger.warning(
215216
f"RPC call failed with provider at index {self.current_rpc_index} ({current_provider}): {e}"
216217
)
217-
self._get_next_rpc_provider()
218218

219-
# If we have tried all RPC providers, log the error and raise an exception
220-
if self.current_rpc_index == initial_index:
219+
# Once every provider in the pool has failed, log the error and raise an exception
220+
if providers_tried >= len(self.rpc_providers):
221221
logger.error("All RPC providers failed. Cannot proceed.")
222222
raise ConnectionError("All RPC providers are unreachable.") from e
223223

224+
self._get_next_rpc_provider()
225+
224226
# If we get an unexpected exception, log the error and raise the exception
225227
except Exception as e:
226228
logger.error(f"An unexpected error occurred during RPC call: {e}")

tests/test_blockchain_client.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,33 @@ def test_execute_rpc_call_reraises_unexpected_exception(self, blockchain_client:
239239
blockchain_client.slack_notifier.send_info_notification.assert_not_called()
240240

241241

242+
def test_execute_rpc_call_raises_after_trying_every_provider_even_if_index_skips(
243+
self, blockchain_client: BlockchainClient, mocker: MockerFixture
244+
):
245+
"""
246+
Tests the failover loop terminates once every provider has been tried, even when
247+
reconnection skips over an unreachable provider so the RPC index never returns to
248+
its starting value. Previously this exact situation looped forever.
249+
"""
250+
# Arrange
251+
mocker.patch("tenacity.nap.time") # Skip retry backoff sleeps
252+
mock_func = MagicMock(side_effect=requests.exceptions.ConnectionError("RPC down"))
253+
254+
255+
def rotate_skipping_primary():
256+
# Simulate _connect_to_rpc skipping the unreachable primary and landing on the backup
257+
blockchain_client.current_rpc_index = 1
258+
259+
mocker.patch.object(blockchain_client, "_get_next_rpc_provider", side_effect=rotate_skipping_primary)
260+
261+
# Act & Assert
262+
with pytest.raises(requests.exceptions.ConnectionError, match="All RPC providers are unreachable."):
263+
blockchain_client._execute_rpc_call(mock_func)
264+
265+
# Each of the 2 providers gets 3 retry attempts before the pool is exhausted
266+
assert mock_func.call_count == 6
267+
268+
242269
def test_init_fails_with_empty_rpc_list(self, mock_w3, mock_slack):
243270
"""
244271
Tests that BlockchainClient raises an exception if initialized with an empty list of RPC providers.

0 commit comments

Comments
 (0)