diff --git a/pairing.py b/pairing.py index a751e1d..c372117 100644 --- a/pairing.py +++ b/pairing.py @@ -42,6 +42,7 @@ with nothing else provisioned. See bitspire packages/nostr-client/src/seed.ts. from __future__ import annotations +import asyncio import base64 import json import re @@ -143,6 +144,28 @@ def _recover_token(tokens: list[dict], client_name: str) -> str: return token +async def _get_key_tokens_with_retry( + admin_client: NsecBunkerAdminClient, + key_name: str, + *, + attempts: int = 5, + delay: float = 0.4, +) -> list[dict]: + """`get_key_tokens` can race a just-issued token: when the bunker is briefly + slow (a `nip44_decrypt`/`create_new_token` under load), listing tokens + milliseconds later can return an empty set before the write lands, and + pairing fails with 'no tokens'. Retry a few times with a short backoff + before giving up (bitspire#70).""" + tokens: list[dict] = [] + for i in range(attempts): + tokens = await admin_client.get_key_tokens(key_name) + if tokens: + return tokens + if i < attempts - 1: + await asyncio.sleep(delay) + return tokens + + def build_seed_url( *, spire_npub: str, @@ -299,7 +322,7 @@ async def pair_spire( await admin_client.create_new_token( key_name, client_name, policy_id, duration_hours=duration_hours ) - tokens = await admin_client.get_key_tokens(key_name) + tokens = await _get_key_tokens_with_retry(admin_client, key_name) except NsecBunkerNotConfiguredError as exc: raise PairingError(f"nsecbunkerd is not configured: {exc}") from exc except NsecBunkerError as exc: diff --git a/tests/test_pairing.py b/tests/test_pairing.py index 15334fa..1f9207f 100644 --- a/tests/test_pairing.py +++ b/tests/test_pairing.py @@ -248,6 +248,50 @@ def test_malformed_token_raises(): _pair(bunker) +def test_get_key_tokens_retries_past_a_transient_empty(monkeypatch): + # A slow bunker can list no tokens right after create_new_token; retry. + import spirekeeper.pairing as pairing_mod + + async def _no_sleep(*_a, **_k): + pass + + monkeypatch.setattr(pairing_mod.asyncio, "sleep", _no_sleep) + + bunker = FakeBunker(token_secret="s") # pragma: allowlist secret + real = bunker.get_key_tokens + calls = {"n": 0} + + async def flaky(key_name): + calls["n"] += 1 + return [] if calls["n"] == 1 else await real(key_name) + + bunker.get_key_tokens = flaky + result = _pair(bunker) + assert calls["n"] == 2 # empty once, then the token + assert result.spire_npub == _SPIRE_NPUB + + +def test_pair_raises_when_tokens_stay_empty(monkeypatch): + import spirekeeper.pairing as pairing_mod + + async def _no_sleep(*_a, **_k): + pass + + monkeypatch.setattr(pairing_mod.asyncio, "sleep", _no_sleep) + + bunker = FakeBunker() + calls = {"n": 0} + + async def always_empty(key_name): + calls["n"] += 1 + return [] + + bunker.get_key_tokens = always_empty + with pytest.raises(PairingError, match="no tokens"): + _pair(bunker) + assert calls["n"] == 5 # exhausted all attempts + + def test_bunker_relay_defaults_to_spire_event_relay(): """No explicit bunker_relay -> the relay baked into bunker_url is the spire's own public event relay (relays[0]), NOT lnbits's internal bunker URL. This