fix(pairing): retry get_key_tokens past a transient empty result
Some checks failed
ci.yml / fix(pairing): retry get_key_tokens past a transient empty result (pull_request) Failing after 0s
Some checks failed
ci.yml / fix(pairing): retry get_key_tokens past a transient empty result (pull_request) Failing after 0s
pair_spire failed with "bunker returned no tokens after create_new_token" when the nsecbunkerd was briefly slow — get_key_tokens listed nothing in the few ms after create_new_token before the write landed. Observed live: a nip44_decrypt timeout, then a 502 on pair, then the identical call 25s later succeeding. Retry get_key_tokens up to 5x with a 0.4s backoff before giving up, so pairing survives a sluggish bunker instead of flaking. Tests cover the transient-empty recovery and the exhausted-attempts failure. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
4e36b98534
commit
765b07737b
2 changed files with 68 additions and 1 deletions
25
pairing.py
25
pairing.py
|
|
@ -42,6 +42,7 @@ with nothing else provisioned. See bitspire packages/nostr-client/src/seed.ts.
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
|
|
@ -143,6 +144,28 @@ def _recover_token(tokens: list[dict], client_name: str) -> str:
|
||||||
return token
|
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(
|
def build_seed_url(
|
||||||
*,
|
*,
|
||||||
spire_npub: str,
|
spire_npub: str,
|
||||||
|
|
@ -299,7 +322,7 @@ async def pair_spire(
|
||||||
await admin_client.create_new_token(
|
await admin_client.create_new_token(
|
||||||
key_name, client_name, policy_id, duration_hours=duration_hours
|
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:
|
except NsecBunkerNotConfiguredError as exc:
|
||||||
raise PairingError(f"nsecbunkerd is not configured: {exc}") from exc
|
raise PairingError(f"nsecbunkerd is not configured: {exc}") from exc
|
||||||
except NsecBunkerError as exc:
|
except NsecBunkerError as exc:
|
||||||
|
|
|
||||||
|
|
@ -248,6 +248,50 @@ def test_malformed_token_raises():
|
||||||
_pair(bunker)
|
_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():
|
def test_bunker_relay_defaults_to_spire_event_relay():
|
||||||
"""No explicit bunker_relay -> the relay baked into bunker_url is the spire's
|
"""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
|
own public event relay (relays[0]), NOT lnbits's internal bunker URL. This
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue