fix(pairing): retry all bunker admin RPCs past transient timeouts (#38)
Some checks failed
ci.yml / fix(pairing): retry all bunker admin RPCs past transient timeouts (#38) (pull_request) Failing after 0s

The get_key_tokens retry only covered an empty token list; a transient
NsecBunkerTimeoutError on any admin RPC still failed pairing with a 502 (seen
live on aio-demo: create_new_key and get_key_tokens both 15s-timed-out, then a
manual retry succeeded). Generalise to `_bunker_retry`, wrapping every admin
call in the pair_spire chain (create_new_key, ensure_policy, create_new_token,
get_key_tokens): a NsecBunkerTimeoutError (and, for get_key_tokens, an empty
list) is transient → retry with backoff; a NsecBunkerRpcError rejection or
misconfig is terminal → fail fast. create_new_key is replace-by-name and
ensure_policy reconciles idempotently, so retrying on timeout is safe.

Also: _validate_relay now rejects a host-less ws:// (mint was laxer than the
consumer's parseSpireSeed), and stale "nostrclient" comments in the pair UI +
a test are corrected to "transport relay".

229 tests pass (incl. timeout-retry + fail-fast-rejection coverage).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-07-02 15:30:55 +02:00
commit 2b90590104
4 changed files with 93 additions and 32 deletions

View file

@ -52,6 +52,7 @@ from lnbits.core.services.nsec_bunker import (
NsecBunkerAdminClient,
NsecBunkerError,
NsecBunkerNotConfiguredError,
NsecBunkerTimeoutError,
npub_to_hex,
)
from lnbits.core.signers.remote_bunker import ensure_policy
@ -144,26 +145,27 @@ 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:
async def _bunker_retry(call, *, attempts: int = 5, delay: float = 0.4, retry_empty: bool = False):
"""Retry a bunker admin RPC past a TRANSIENT failure — a `NsecBunkerTimeoutError`
(the nsecbunkerd briefly slow / unresponsive) and, when `retry_empty`, an empty
result (a just-issued token not yet listed). Both are seen intermittently on a
healthy bunker (bitspire#70 / spirekeeper#38). A real rejection
(`NsecBunkerRpcError`) or misconfig (`NsecBunkerNotConfiguredError`) is terminal
and propagates immediately. `call` is a zero-arg coroutine factory."""
for attempt in range(attempts):
last = attempt == attempts - 1
try:
result = await call()
except NsecBunkerTimeoutError:
if last:
raise
await asyncio.sleep(delay)
return tokens
continue
if retry_empty and not result and not last:
await asyncio.sleep(delay)
continue
return result
raise AssertionError("unreachable: the final attempt returns or raises")
def build_seed_url(
@ -243,6 +245,8 @@ def _validate_relay(url: str, field: str) -> None:
if not isinstance(url, str) or not _WS_SCHEME_RE.match(url):
raise PairingError(f"{field} must be a ws:// or wss:// URL (got {url!r})")
host = (urlparse(url).hostname or "").lower()
if not host:
raise PairingError(f"{field} has no host ({url!r})")
if host in _LOCAL_HOSTS or host.endswith(".localhost"):
raise PairingError(
f"{field} points at localhost ({url!r}). A pairing seed is redeemed by "
@ -327,18 +331,26 @@ async def pair_spire(
client_name = f"spire-client-{machine.id}"
try:
spire_npub = await admin_client.create_new_key(key_name, passphrase)
# Each admin RPC is retried past a transient bunker timeout (#38);
# create_new_key is replace-by-name so re-issuing on a timeout is safe,
# and ensure_policy reconciles idempotently (get_policies → reuse/create).
spire_npub = await _bunker_retry(lambda: admin_client.create_new_key(key_name, passphrase))
spire_pubkey_hex = npub_to_hex(spire_npub)
policy_id = await ensure_policy(
admin_client,
name=SPIRE_POLICY_NAME,
rules=SPIRE_POLICY_RULES,
methods_no_kind=SPIRE_POLICY_METHODS_NO_KIND,
policy_id = await _bunker_retry(
lambda: ensure_policy(
admin_client,
name=SPIRE_POLICY_NAME,
rules=SPIRE_POLICY_RULES,
methods_no_kind=SPIRE_POLICY_METHODS_NO_KIND,
)
)
await admin_client.create_new_token(
key_name, client_name, policy_id, duration_hours=duration_hours
await _bunker_retry(
lambda: admin_client.create_new_token(
key_name, client_name, policy_id, duration_hours=duration_hours
)
)
tokens = await _get_key_tokens_with_retry(admin_client, key_name)
# retry_empty: a just-issued token can list empty before the write lands.
tokens = await _bunker_retry(lambda: admin_client.get_key_tokens(key_name), retry_empty=True)
except NsecBunkerNotConfiguredError as exc:
raise PairingError(f"nsecbunkerd is not configured: {exc}") from exc
except NsecBunkerError as exc: