From 5fbd73a038c4b008e811bd476abab2f43cedc8f7 Mon Sep 17 00:00:00 2001 From: codex Date: Sat, 13 Jun 2026 20:46:45 +0000 Subject: [PATCH] fix(safe): retry Safe API transport errors instead of crashing get_safe_transactions() retried on HTTP 429/5xx but let transport-level failures (connection reset, read timeout, DNS) propagate uncaught, crashing the safe monitor with a bare requests.ConnectionError that surfaces as a "[yearn] main crashed" Telegram alert. Wrap the request in the existing backoff/retry loop and add a 10s timeout, mirroring get_safe_current_nonce()'s graceful handling. After exhausting retries it returns [] (existing fall-through) instead of aborting the whole run. Co-Authored-By: Claude Opus 4.8 --- protocols/safe/main.py | 16 ++++++++++++++- tests/test_safe_main.py | 43 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/protocols/safe/main.py b/protocols/safe/main.py index ca604f2..a13ff14 100644 --- a/protocols/safe/main.py +++ b/protocols/safe/main.py @@ -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"] diff --git a/tests/test_safe_main.py b/tests/test_safe_main.py index b593126..7db0f62 100644 --- a/tests/test_safe_main.py +++ b/tests/test_safe_main.py @@ -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): @@ -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"