Keep REST endpoints responsive when bitcoind is slow - #245
Open
EddieHouston wants to merge 1 commit into
Open
Conversation
The REST server dispatched every request through an async fn on the Tokio runtime, but almost every handler is synchronous. The broadcast and package submission handlers make a blocking JSON-RPC call to the daemon, serialized on a single process-wide Mutex<Connection> with a 600 second read timeout. One concurrent request per CPU core against a slow or paused daemon therefore parked every worker thread, and all REST endpoints stopped being served, including fully in-memory ones such as GET /blocks/tip/height. Move the synchronous router onto the Tokio blocking pool, keeping only GET /block-template (the one genuinely async handler) on the runtime. Stop routing client-triggered daemon RPC through the shared singleton connection. sendrawtransaction and submitpackage now run on their own short-lived connection with a 30 second I/O timeout, capped at 8 concurrent calls, so one slow client request can no longer stall indexing or other clients, and failures are reported rather than retried forever. Report the resulting failures as gateway errors instead of the previous 400: 503 when the concurrency cap is reached or bitcoind is warming up, 504 when the daemon does not answer in time. Tunable via DAEMON_PROXY_MAX_CONCURRENCY, DAEMON_PROXY_RPC_TIMEOUT and DAEMON_PROXY_QUEUE_TIMEOUT. New daemon_rpc_proxied metric counts these calls by result.
EddieHouston
force-pushed
the
agent/xf-05-rest-daemon-proxy-dos
branch
from
August 17, 2026 10:54
be7fa5a to
48eb0b5
Compare
Randy808
approved these changes
Aug 17, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The REST API stops serving all requests when bitcoind is slow or unresponsive — including endpoints that never touch the daemon.
What happens today
Every REST request is dispatched through an
async fnon the Tokio runtime, but nearly all of the handlers are synchronous. They read RocksDB, andPOST /txandPOST /txs/packagemake a blocking JSON-RPC call to bitcoind. A blocking call on an async worker thread ties that thread up for its whole duration.The runtime has one worker per CPU core, so it only takes a handful of concurrent broadcast requests against a slow daemon to occupy all of them. Once that happens nothing else gets polled, and cheap in-memory endpoints like
GET /blocks/tip/heighthang too.It is made worse by all daemon RPC sharing a single connection behind one mutex, with a 10 minute read timeout. A single slow
sendrawtransactionblocks every other daemon user — indexing included — and holds a worker thread for up to those 10 minutes.You can see it locally: pause bitcoind (
docker pause, orkill -STOP), fire onePOST /txper CPU core, then ask for/blocks/tip/height. It hangs.Changes
Run the synchronous handlers on the blocking pool.
handle_requestnow hands the router off totokio::task::spawn_blocking.GET /block-templateis the one genuinely async handler (concurrent callers share a single in-flight fetch) and already offloads its own blocking work, so it stays on the runtime. This keeps the runtime free to answer everything else no matter how long a daemon call takes.Give client-triggered daemon calls their own bounded path.
sendrawtransactionandsubmitpackageare reachable anonymously over both REST and Electrum, so they no longer use the shared singleton connection. Each call now gets a short-lived connection with a 30 second timeout, and a semaphore caps them at 8 in flight. One slow request can no longer stall indexing or other clients, and the number of threads a wedged daemon can park is explicitly bounded. This reuses the same one-shot connection pathgetblocktemplatealready uses.Failures are now reported to the caller rather than retried forever, which is what the old path did.
Report daemon problems as gateway errors. These used to surface as
400 Bad Request, which blames the caller for a perfectly valid request and lets caches and load balancers memoize it. Now:503when the concurrency cap is reached, or bitcoind is still warming up504when the daemon does not answer in timeElectrum clients get a
DaemonErrorfor both.Tuning
The defaults should be fine as-is:
DAEMON_PROXY_MAX_CONCURRENCYDAEMON_PROXY_RPC_TIMEOUTDAEMON_PROXY_QUEUE_TIMEOUTA new
daemon_rpc_proxiedPrometheus counter tracks these calls by result (ok/busy/unavailable/error).Testing
Known gap
In
--lightmode, transaction and block reads still go through the shared connection with the long timeout. Those calls are shared with the indexer, so separating them is a larger change. They no longer block the async runtime, so the problem above is fixed either way, but a lightmode deployment could still fill the blocking pool if bitcoind is down. Worth a follow-up.