fix(bitcoind): guard block-sub handlers against errored RPC replies

Same #277 root cause in the block-subscription path: Bitcoin Core 28+
uses strict JSON-RPC 2.0, so an errored getblock/getbestblockhash reply
has no 'result' key. handle_block_sub and handle_block_sub_redis accessed
r['result'] unguarded, so a transient RPC error would raise
KeyError: 'result' and kill the block stream / block-update task.

Skip and log the block when the reply has no result instead of crashing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
fusion44 2026-07-08 14:33:59 +02:00
parent 54fc16e627
commit 6444769dc4
No known key found for this signature in database
2 changed files with 94 additions and 0 deletions

View file

@ -162,6 +162,11 @@ async def handle_block_sub(request: Request, verbosity: int = 1) -> str:
hash = binascii.hexlify(body).decode("utf-8")
r = await bitcoin_rpc_async("getblock", [hash, verbosity])
# strict JSON-RPC 2.0: an errored reply has no "result" key
if "result" not in r or r.get("error") is not None:
logger.error(f"getblock failed, skipping block: {r.get('error')}")
continue
yield json.dumps(r["result"])
@ -180,6 +185,11 @@ async def handle_block_sub_redis(verbosity: int = 1) -> str:
hash = binascii.hexlify(body).decode("utf-8")
elif bitcoin_config.zmq_block_rpc == BlockRpcFunc.RAWBLOCK:
r1 = await bitcoin_rpc_async("getbestblockhash", [])
if "result" not in r1 or r1.get("error") is not None:
logger.error(
f"getbestblockhash failed, skipping block: {r1.get('error')}"
)
continue
hash = r1["result"]
else:
raise NotImplementedError(
@ -187,6 +197,11 @@ async def handle_block_sub_redis(verbosity: int = 1) -> str:
)
r = await bitcoin_rpc_async("getblock", [hash, verbosity])
# strict JSON-RPC 2.0: an errored reply has no "result" key
if "result" not in r or r.get("error") is not None:
logger.error(f"getblock failed, skipping block: {r.get('error')}")
continue
await broadcast_sse_msg(SSE.BTC_NEW_BLOC, r["result"])

View file

@ -0,0 +1,79 @@
"""
Regression test for the same #277 root cause in the block-subscription path.
Bitcoin Core 28+ returns strict JSON-RPC 2.0, so an errored getblock reply
has no "result" key. handle_block_sub did `yield json.dumps(r["result"])`
unguarded, which would crash the block stream with KeyError: 'result'.
"""
import json
from app.bitcoind import service
class _FakeSocket:
def setsockopt(self, *args):
pass
def setsockopt_string(self, *args):
pass
def connect(self, *args):
pass
async def recv_multipart(self):
# 32-byte block hash body -> a valid 64 char hex hash
return (b"hashblock", b"\x11" * 32, b"\x00")
class _FakeCtx:
def socket(self, *args):
return _FakeSocket()
def destroy(self):
pass
class _FakeRequest:
"""is_disconnected() returns False for the first `process` checks, then
True to break the loop."""
def __init__(self, process: int):
self._calls = 0
self._process = process
async def is_disconnected(self):
self._calls += 1
return self._calls > self._process
def _patch_zmq(monkeypatch):
monkeypatch.setattr(service.zmq.asyncio, "Context", lambda: _FakeCtx())
async def test_handle_block_sub_skips_errored_getblock(monkeypatch):
_patch_zmq(monkeypatch)
async def fake_rpc(method, params=[]):
# strict JSON-RPC 2.0 error: no "result" key
return {"error": "some transient RPC error", "status": 500}
monkeypatch.setattr(service, "bitcoin_rpc_async", fake_rpc)
items = [item async for item in service.handle_block_sub(_FakeRequest(1))]
assert items == [] # error skipped, nothing yielded, no KeyError
async def test_handle_block_sub_yields_on_success(monkeypatch):
_patch_zmq(monkeypatch)
async def fake_rpc(method, params=[]):
return {"result": {"height": 5}, "error": None}
monkeypatch.setattr(service, "bitcoin_rpc_async", fake_rpc)
items = [item async for item in service.handle_block_sub(_FakeRequest(1))]
assert len(items) == 1
assert json.loads(items[0]) == {"height": 5}