Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion protocols/safe/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,21 @@ def get_safe_transactions(
}

for attempt in range(max_retries):
response = requests.get(endpoint, params=params, headers=headers)
try:
response = requests.get(endpoint, params=params, headers=headers, timeout=10)
except requests.exceptions.RequestException as e:
# Transient transport failure (connection reset, read timeout, DNS).
# Retry with backoff instead of letting it bubble up and crash the run.
wait_time = 2**attempt
logger.warning(
"Request error talking to Safe API (%s), waiting %ss before retry (attempt %s/%s)...",
e,
wait_time,
attempt + 1,
max_retries,
)
time.sleep(wait_time)
continue

if response.status_code == 200:
return response.json()["results"]
Expand Down
43 changes: 42 additions & 1 deletion tests/test_safe_main.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import importlib
import os
import unittest
from unittest.mock import patch
from unittest.mock import Mock, patch

import requests


class TestSafePendingTransactions(unittest.TestCase):
Expand Down Expand Up @@ -34,6 +36,45 @@ def test_current_nonce_advances_nonce_cache(self):
mock_write.assert_called_once_with(safe_address, 22)
self.assertEqual(pending, [{"nonce": 23}])

def test_get_safe_transactions_retries_on_connection_error(self):
safe_main = self._import_safe_main()

ok_response = Mock(status_code=200)
ok_response.json.return_value = {"results": [{"nonce": 5}]}

with (
patch.object(
safe_main.requests,
"get",
side_effect=[
requests.exceptions.ConnectionError("Connection reset by peer"),
ok_response,
],
) as mock_get,
patch.object(safe_main.time, "sleep") as mock_sleep,
):
result = safe_main.get_safe_transactions("0xSafe", "arbitrum-main")

self.assertEqual(result, [{"nonce": 5}])
self.assertEqual(mock_get.call_count, 2)
mock_sleep.assert_called_once()

def test_get_safe_transactions_returns_empty_after_exhausting_retries(self):
safe_main = self._import_safe_main()

with (
patch.object(
safe_main.requests,
"get",
side_effect=requests.exceptions.ConnectionError("Connection reset by peer"),
) as mock_get,
patch.object(safe_main.time, "sleep"),
):
result = safe_main.get_safe_transactions("0xSafe", "arbitrum-main", max_retries=3)

self.assertEqual(result, [])
self.assertEqual(mock_get.call_count, 3)

def test_executed_rows_are_filtered_even_when_api_returns_them(self):
safe_main = self._import_safe_main()
safe_address = "0xSafe"
Expand Down