fix(lnd): return a retryable status while the RPC server starts up

While LND is up but its RPC server is 'in the process of starting up, but
not yet ready to accept calls', every LND method returned a bare 500 -
the caller (e.g. the WebUI polling list-all-tx) got a generic server
error with no useful signal.

_check_if_locked only mapped the 'wallet locked' case; generalize it to
_check_transient_ln_error and also map the startup message to
425 TOO_EARLY (matching how bitcoind warmup is reported), with a clear
'try again shortly' detail. This covers all LND methods that share the
same error-handling path.

Fixes #247

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
fusion44 2026-07-11 10:02:12 +02:00
parent b8677cbbf1
commit 8646d966dc
No known key found for this signature in database
2 changed files with 86 additions and 16 deletions

View file

@ -41,15 +41,35 @@ from app.lightning.utils import alias_or_empty
@logger.catch(exclude=(HTTPException,))
def _check_if_locked(error):
logger.debug("logger._check_if_locked()")
def _check_transient_ln_error(error):
"""Map known transient LND gRPC errors to appropriate HTTP statuses.
if error.details() is not None and error.details().find("wallet locked") > -1:
Returns without raising for unknown errors, leaving the caller to turn
them into a 500.
"""
logger.debug("logger._check_transient_ln_error()")
details = error.details()
if details is None:
return
if details.find("wallet locked") > -1:
raise HTTPException(
status.HTTP_423_LOCKED,
detail="Wallet is locked. Unlock via /lightning/unlock-wallet",
)
if "the RPC server is in the process of starting up" in details:
# LND is up but its RPC server isn't ready yet; signal a retryable
# status instead of a generic 500 (blitz_api#247)
raise HTTPException(
status.HTTP_425_TOO_EARLY,
detail=(
"The Lightning RPC server is starting up and not yet ready. "
"Please try again shortly."
),
)
# Due to updated ECDSA generated tls.cert we need to let gprc know that
# we need to use that cipher suite otherwise there will be a handshake
@ -298,7 +318,7 @@ This will show more debug information.
return WalletBalance.from_lnd_grpc(onchain, channel)
except grpc.aio._call.AioRpcError as error:
_check_if_locked(error)
_check_transient_ln_error(error)
raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR, detail=error.details()
)
@ -379,7 +399,7 @@ This will show more debug information.
return tx[index_offset : index_offset + max_tx]
except grpc.aio._call.AioRpcError as error:
_check_if_locked(error)
_check_transient_ln_error(error)
raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR, detail=error.details()
)
@ -404,7 +424,7 @@ This will show more debug information.
response = await self._lnd_stub.ListInvoices(req)
return [Invoice.from_lnd_grpc(i) for i in response.invoices]
except grpc.aio._call.AioRpcError as error:
_check_if_locked(error)
_check_transient_ln_error(error)
raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR, detail=error.details()
)
@ -418,7 +438,7 @@ This will show more debug information.
response = await self._lnd_stub.GetTransactions(req)
return [OnChainTransaction.from_lnd_grpc(t) for t in response.transactions]
except grpc.aio._call.AioRpcError as error:
_check_if_locked(error)
_check_transient_ln_error(error)
raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR, detail=error.details()
)
@ -449,7 +469,7 @@ This will show more debug information.
response = await self._lnd_stub.ListPayments(req)
return [Payment.from_lnd_grpc(p) for p in response.payments]
except grpc.aio._call.AioRpcError as error:
_check_if_locked(error)
_check_transient_ln_error(error)
raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR, detail=error.details()
)
@ -495,7 +515,7 @@ This will show more debug information.
return invoice
except grpc.aio._call.AioRpcError as error:
_check_if_locked(error)
_check_transient_ln_error(error)
raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR, detail=error.details()
)
@ -509,7 +529,7 @@ This will show more debug information.
res = await self._lnd_stub.DecodePayReq(req)
return PaymentRequest.from_lnd_grpc(res)
except grpc.aio._call.AioRpcError as error:
_check_if_locked(error)
_check_transient_ln_error(error)
if (
error.details() is not None
and error.details().find("checksum failed.") > -1
@ -540,7 +560,7 @@ This will show more debug information.
response = await self._lnd_stub.NewAddress(req)
return response.address
except grpc.aio._call.AioRpcError as error:
_check_if_locked(error)
_check_transient_ln_error(error)
raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR, detail=error.details()
)
@ -576,7 +596,7 @@ This will show more debug information.
await broadcast_sse_msg(SSE.LN_ONCHAIN_PAYMENT_STATUS, r.model_dump())
return r
except grpc.aio._call.AioRpcError as error:
_check_if_locked(error)
_check_transient_ln_error(error)
details = error.details()
if details and details.find("invalid bech32 string") > -1:
raise HTTPException(
@ -623,7 +643,7 @@ This will show more debug information.
await broadcast_sse_msg(SSE.LN_PAYMENT_STATUS, p.model_dump())
return p
except grpc.aio._call.AioRpcError as error:
_check_if_locked(error)
_check_transient_ln_error(error)
if (
error.details() is not None
and error.details().find("invalid bech32 string") > -1
@ -692,7 +712,7 @@ This will show more debug information.
response = await self._lnd_stub.GetInfo(req)
return LnInfo.from_lnd_grpc(self.get_implementation_name(), response)
except grpc.aio._call.AioRpcError as error:
_check_if_locked(error)
_check_transient_ln_error(error)
raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR, detail=error.details()
)
@ -771,7 +791,7 @@ This will show more debug information.
async for r in self._lnd_stub.SubscribeInvoices(request):
yield Invoice.from_lnd_grpc(r)
except grpc.aio._call.AioRpcError as error:
_check_if_locked(error)
_check_transient_ln_error(error)
raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR, detail=error.details()
)
@ -811,7 +831,7 @@ This will show more debug information.
del _fwd_cache[e.incoming_htlc_id]
except grpc.aio._call.AioRpcError as error:
_check_if_locked(error)
_check_transient_ln_error(error)
raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR, detail=error.details()
)

View file

@ -0,0 +1,50 @@
"""
Regression test for blitz_api#247 (original symptom).
While the LND RPC server is still starting up it answers gRPC calls with
"the RPC server is in the process of starting up, but not yet ready to accept
calls". The API mapped only the "wallet locked" case to a proper status and
returned a bare 500 for everything else, so list-all-tx (and friends) 500'd
during LND startup. The startup case should map to a transient status the
client can retry.
"""
import pytest
from fastapi import HTTPException
from starlette import status
from app.lightning.impl import lnd_grpc
class _FakeGrpcError:
def __init__(self, details):
self._details = details
def details(self):
return self._details
def test_wallet_locked_maps_to_423():
err = _FakeGrpcError("wallet locked, unlock it to enable full RPC access")
with pytest.raises(HTTPException) as exc:
lnd_grpc._check_transient_ln_error(err)
assert exc.value.status_code == status.HTTP_423_LOCKED
def test_rpc_starting_up_maps_to_425():
err = _FakeGrpcError(
"the RPC server is in the process of starting up, but not yet "
"ready to accept calls"
)
with pytest.raises(HTTPException) as exc:
lnd_grpc._check_transient_ln_error(err)
assert exc.value.status_code == status.HTTP_425_TOO_EARLY
def test_unrelated_error_does_not_raise():
# unknown errors are left for the caller to turn into a 500
lnd_grpc._check_transient_ln_error(_FakeGrpcError("some unrelated error"))
def test_none_details_does_not_raise():
lnd_grpc._check_transient_ln_error(_FakeGrpcError(None))