Merge pull request #672 from PR0M3TH3AN/codex/add-configurable-max-retries-and-retry-delay

Implement Nostr retry backoff
This commit is contained in:
thePR0M3TH3AN
2025-07-24 19:00:20 -04:00
committed by GitHub
5 changed files with 75 additions and 22 deletions

View File

@@ -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
# -----------------------------------

View File

@@ -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)

View File

@@ -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)

View File

@@ -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]

View File

@@ -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):