From 8e7224dfd2ac3d33596d8bf7371c6b25a407ae41 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 24 Jul 2025 18:53:45 -0400 Subject: [PATCH] Add configurable Nostr retry backoff --- src/constants.py | 8 ++++--- src/nostr/client.py | 32 ++++++++++++++++++---------- src/seedpass/core/config_manager.py | 14 ++++++------ src/tests/test_nostr_client.py | 29 +++++++++++++++++++++++++ src/tests/test_nostr_dummy_client.py | 14 +++++++++++- 5 files changed, 75 insertions(+), 22 deletions(-) diff --git a/src/constants.py b/src/constants.py index 7d99552..e221288 100644 --- a/src/constants.py +++ b/src/constants.py @@ -9,9 +9,11 @@ logger = logging.getLogger(__name__) # ----------------------------------- # Nostr Relay Connection Settings # ----------------------------------- -# Retry fewer times with a shorter wait by default -MAX_RETRIES = 2 # Maximum number of retries for relay connections -RETRY_DELAY = 1 # Seconds to wait before retrying a failed connection +# Retry fewer times with a shorter wait by default. These values +# act as defaults that can be overridden via ``ConfigManager`` +# entries ``nostr_max_retries`` and ``nostr_retry_delay``. +MAX_RETRIES = 2 # Default maximum number of retry attempts +RETRY_DELAY = 1 # Default seconds to wait before retrying MIN_HEALTHY_RELAYS = 2 # Minimum relays that should return data on startup # ----------------------------------- diff --git a/src/nostr/client.py b/src/nostr/client.py index 1096071..eccb088 100644 --- a/src/nostr/client.py +++ b/src/nostr/client.py @@ -313,8 +313,7 @@ class NostrClient: self.connect() self.last_error = None - attempt = 0 - while True: + for attempt in range(retries): try: result = asyncio.run(self._retrieve_json_from_nostr()) if result is not None: @@ -322,10 +321,9 @@ class NostrClient: except Exception as e: self.last_error = str(e) logger.error("Failed to retrieve events from Nostr: %s", e) - if attempt >= retries: - break - attempt += 1 - time.sleep(delay) + if attempt < retries - 1: + sleep_time = delay * (2**attempt) + time.sleep(sleep_time) return None async def _retrieve_json_from_nostr(self) -> Optional[bytes]: @@ -434,11 +432,24 @@ class NostrClient: except Exception: return None + if self.config_manager is None: + from seedpass.core.config_manager import ConfigManager + from seedpass.core.vault import Vault + + cfg_mgr = ConfigManager( + Vault(self.encryption_manager, self.fingerprint_dir), + self.fingerprint_dir, + ) + else: + cfg_mgr = self.config_manager + cfg = cfg_mgr.load_config(require_pin=False) + max_retries = int(cfg.get("nostr_max_retries", MAX_RETRIES)) + delay = float(cfg.get("nostr_retry_delay", RETRY_DELAY)) + chunks: list[bytes] = [] for meta in manifest.chunks: - attempt = 0 chunk_bytes: bytes | None = None - while attempt < MAX_RETRIES: + for attempt in range(max_retries): cf = Filter().author(pubkey).kind(Kind(KIND_SNAPSHOT_CHUNK)) if meta.event_id: cf = cf.id(EventId.parse(meta.event_id)) @@ -451,9 +462,8 @@ class NostrClient: if hashlib.sha256(candidate).hexdigest() == meta.hash: chunk_bytes = candidate break - attempt += 1 - if attempt < MAX_RETRIES: - await asyncio.sleep(RETRY_DELAY) + if attempt < max_retries - 1: + await asyncio.sleep(delay * (2**attempt)) if chunk_bytes is None: return None chunks.append(chunk_bytes) diff --git a/src/seedpass/core/config_manager.py b/src/seedpass/core/config_manager.py index f0312ac..a474277 100644 --- a/src/seedpass/core/config_manager.py +++ b/src/seedpass/core/config_manager.py @@ -13,7 +13,7 @@ import bcrypt from .vault import Vault from nostr.client import DEFAULT_RELAYS as DEFAULT_NOSTR_RELAYS -from constants import INACTIVITY_TIMEOUT +from constants import INACTIVITY_TIMEOUT, MAX_RETRIES, RETRY_DELAY logger = logging.getLogger(__name__) @@ -52,8 +52,8 @@ class ConfigManager: "secret_mode_enabled": False, "clipboard_clear_delay": 45, "quick_unlock": False, - "nostr_max_retries": 2, - "nostr_retry_delay": 1.0, + "nostr_max_retries": MAX_RETRIES, + "nostr_retry_delay": float(RETRY_DELAY), "min_uppercase": 2, "min_lowercase": 2, "min_digits": 2, @@ -77,8 +77,8 @@ class ConfigManager: data.setdefault("secret_mode_enabled", False) data.setdefault("clipboard_clear_delay", 45) data.setdefault("quick_unlock", False) - data.setdefault("nostr_max_retries", 2) - data.setdefault("nostr_retry_delay", 1.0) + data.setdefault("nostr_max_retries", MAX_RETRIES) + data.setdefault("nostr_retry_delay", float(RETRY_DELAY)) data.setdefault("min_uppercase", 2) data.setdefault("min_lowercase", 2) data.setdefault("min_digits", 2) @@ -303,7 +303,7 @@ class ConfigManager: def get_nostr_max_retries(self) -> int: """Retrieve the configured Nostr retry count.""" cfg = self.load_config(require_pin=False) - return int(cfg.get("nostr_max_retries", 2)) + return int(cfg.get("nostr_max_retries", MAX_RETRIES)) def set_nostr_retry_delay(self, delay: float) -> None: """Persist the delay between Nostr retry attempts.""" @@ -316,7 +316,7 @@ class ConfigManager: def get_nostr_retry_delay(self) -> float: """Retrieve the delay in seconds between Nostr retries.""" cfg = self.load_config(require_pin=False) - return float(cfg.get("nostr_retry_delay", 1.0)) + return float(cfg.get("nostr_retry_delay", float(RETRY_DELAY))) def set_verbose_timing(self, enabled: bool) -> None: cfg = self.load_config(require_pin=False) diff --git a/src/tests/test_nostr_client.py b/src/tests/test_nostr_client.py index 508eb79..1aa998f 100644 --- a/src/tests/test_nostr_client.py +++ b/src/tests/test_nostr_client.py @@ -12,6 +12,7 @@ sys.path.append(str(Path(__file__).resolve().parents[1])) from seedpass.core.encryption import EncryptionManager from nostr.client import NostrClient import nostr.client as nostr_client +import constants def test_nostr_client_uses_custom_relays(): @@ -151,3 +152,31 @@ def test_update_relays_reinitializes_pool(tmp_path, monkeypatch): assert called["ran"] is True assert isinstance(client.client, FakeAddRelaysClient) assert client.relays == new_relays + + +def test_retrieve_json_sync_backoff(tmp_path, monkeypatch): + client = _setup_client(tmp_path, FakeAddRelayClient) + + monkeypatch.setattr("nostr.client.MAX_RETRIES", 3) + monkeypatch.setattr("nostr.client.RETRY_DELAY", 1) + monkeypatch.setattr("constants.MAX_RETRIES", 3) + monkeypatch.setattr("constants.RETRY_DELAY", 1) + monkeypatch.setattr("seedpass.core.config_manager.MAX_RETRIES", 3) + monkeypatch.setattr("seedpass.core.config_manager.RETRY_DELAY", 1) + + sleeps: list[float] = [] + + def fake_sleep(d): + sleeps.append(d) + + monkeypatch.setattr(nostr_client.time, "sleep", fake_sleep) + + async def fake_async(self): + return None + + monkeypatch.setattr(NostrClient, "_retrieve_json_from_nostr", fake_async) + + result = client.retrieve_json_from_nostr_sync() + + assert result is None + assert sleeps == [1, 2] diff --git a/src/tests/test_nostr_dummy_client.py b/src/tests/test_nostr_dummy_client.py index 9f3acca..6b24237 100644 --- a/src/tests/test_nostr_dummy_client.py +++ b/src/tests/test_nostr_dummy_client.py @@ -8,6 +8,7 @@ from seedpass.core.backup import BackupManager from seedpass.core.config_manager import ConfigManager from nostr.client import prepare_snapshot from nostr.backup_models import KIND_SNAPSHOT_CHUNK +import constants def test_manifest_generation(tmp_path): @@ -73,7 +74,17 @@ def test_fetch_snapshot_fallback_on_missing_chunk(dummy_nostr_client, monkeypatc client, relay = dummy_nostr_client monkeypatch.setattr("nostr.client.MAX_RETRIES", 3) - monkeypatch.setattr("nostr.client.RETRY_DELAY", 0) + monkeypatch.setattr("nostr.client.RETRY_DELAY", 1) + monkeypatch.setattr("constants.MAX_RETRIES", 3) + monkeypatch.setattr("constants.RETRY_DELAY", 1) + monkeypatch.setattr("seedpass.core.config_manager.MAX_RETRIES", 3) + monkeypatch.setattr("seedpass.core.config_manager.RETRY_DELAY", 1) + delays: list[float] = [] + + async def fake_sleep(d): + delays.append(d) + + monkeypatch.setattr("nostr.client.asyncio.sleep", fake_sleep) data1 = os.urandom(60000) manifest1, _ = asyncio.run(client.publish_snapshot(data1)) @@ -102,6 +113,7 @@ def test_fetch_snapshot_fallback_on_missing_chunk(dummy_nostr_client, monkeypatc ) ) assert attempts == 3 + assert delays == [1, 2] def test_fetch_snapshot_uses_event_ids(dummy_nostr_client):