rest: add Cache-Control headers to REST responses

Add Cache-Control headers to REST API responses so standard HTTP caches
can cache safe responses by default without per-deployment proxy rules.

Cache policy summary:
- Immutable: /block binary and hex responses, /blockpart, /blockfilter,
  and /spenttxouts in all formats, and blockhash-specific
  /deploymentinfo/<blockhash>.json responses return
  "public, immutable, max-age=86400".
- No-store: /block and /block/notxdetails JSON, /tx, /headers,
  /blockfilterheaders, /blockhashbyheight, /chaininfo, /mempool,
  /getutxos, tip-relative /deploymentinfo.json, and RESTERR error
  responses return "no-store".

Mutable responses are not stored because REST does not provide cache
validators such as ETag or Last-Modified.

Co-authored-by: stickies-v <stickies-v@protonmail.com>
This commit is contained in:
w0xlt
2026-03-10 13:00:18 -07:00
parent d3cfd02bd7
commit acf45c44c0
2 changed files with 158 additions and 6 deletions

View File

@@ -45,6 +45,12 @@ using util::SplitString;
static const size_t MAX_GETUTXOS_OUTPOINTS = 15; //allow a max of 15 outpoints to be queried at once
static constexpr unsigned int MAX_REST_HEADERS_RESULTS = 2000;
// Cache-Control values for REST responses.
/** Response bytes never change. One-day TTL limits staleness across software upgrades. */
static constexpr const char* REST_CACHE_IMMUTABLE = "public, immutable, max-age=86400";
/** Mutable, node-local, or error response; must not be cached. */
static constexpr const char* REST_CACHE_NO_STORE = "no-store";
static const struct {
RESTResponseFormat rf;
const char* name;
@@ -71,6 +77,7 @@ struct CCoin {
static bool RESTERR(HTTPRequest* req, enum HTTPStatusCode status, std::string message)
{
req->WriteHeader("Cache-Control", REST_CACHE_NO_STORE);
req->WriteHeader("Content-Type", "text/plain");
req->WriteReply(status, message + "\r\n");
return false;
@@ -242,6 +249,8 @@ static bool rest_headers(const std::any& context,
ssHeader << pindex->GetBlockHeader();
}
// Do not cache because chain extensions and reorgs can affect the response.
req->WriteHeader("Cache-Control", REST_CACHE_NO_STORE);
req->WriteHeader("Content-Type", "application/octet-stream");
req->WriteReply(HTTP_OK, ssHeader);
return true;
@@ -254,6 +263,7 @@ static bool rest_headers(const std::any& context,
}
std::string strHex = HexStr(ssHeader) + "\n";
req->WriteHeader("Cache-Control", REST_CACHE_NO_STORE);
req->WriteHeader("Content-Type", "text/plain");
req->WriteReply(HTTP_OK, strHex);
return true;
@@ -264,6 +274,7 @@ static bool rest_headers(const std::any& context,
jsonHeaders.push_back(blockheaderToJSON(*tip, *pindex, chainman.GetConsensus().powLimit));
}
std::string strJSON = jsonHeaders.write() + "\n";
req->WriteHeader("Cache-Control", REST_CACHE_NO_STORE);
req->WriteHeader("Content-Type", "application/json");
req->WriteReply(HTTP_OK, strJSON);
return true;
@@ -352,6 +363,7 @@ static bool rest_spent_txouts(const std::any& context, HTTPRequest* req, const s
case RESTResponseFormat::BINARY: {
DataStream ssSpentResponse{};
SerializeBlockUndo(ssSpentResponse, block_undo);
req->WriteHeader("Cache-Control", REST_CACHE_IMMUTABLE);
req->WriteHeader("Content-Type", "application/octet-stream");
req->WriteReply(HTTP_OK, ssSpentResponse);
return true;
@@ -361,6 +373,7 @@ static bool rest_spent_txouts(const std::any& context, HTTPRequest* req, const s
DataStream ssSpentResponse{};
SerializeBlockUndo(ssSpentResponse, block_undo);
const std::string strHex{HexStr(ssSpentResponse) + "\n"};
req->WriteHeader("Cache-Control", REST_CACHE_IMMUTABLE);
req->WriteHeader("Content-Type", "text/plain");
req->WriteReply(HTTP_OK, strHex);
return true;
@@ -370,6 +383,7 @@ static bool rest_spent_txouts(const std::any& context, HTTPRequest* req, const s
UniValue result(UniValue::VARR);
BlockUndoToJSON(block_undo, result);
std::string strJSON = result.write() + "\n";
req->WriteHeader("Cache-Control", REST_CACHE_IMMUTABLE);
req->WriteHeader("Content-Type", "application/json");
req->WriteReply(HTTP_OK, strJSON);
return true;
@@ -438,6 +452,7 @@ static bool rest_block(const std::any& context,
switch (rf) {
case RESTResponseFormat::BINARY: {
req->WriteHeader("Cache-Control", REST_CACHE_IMMUTABLE);
req->WriteHeader("Content-Type", "application/octet-stream");
req->WriteReply(HTTP_OK, *block_data);
return true;
@@ -445,6 +460,7 @@ static bool rest_block(const std::any& context,
case RESTResponseFormat::HEX: {
const std::string strHex{HexStr(*block_data) + "\n"};
req->WriteHeader("Cache-Control", REST_CACHE_IMMUTABLE);
req->WriteHeader("Content-Type", "text/plain");
req->WriteReply(HTTP_OK, strHex);
return true;
@@ -456,6 +472,7 @@ static bool rest_block(const std::any& context,
SpanReader{*block_data} >> TX_WITH_WITNESS(block);
UniValue objBlock = blockToJSON(chainman.m_blockman, block, *tip, *pblockindex, *tx_verbosity, chainman.GetConsensus().powLimit);
std::string strJSON = objBlock.write() + "\n";
req->WriteHeader("Cache-Control", REST_CACHE_NO_STORE);
req->WriteHeader("Content-Type", "application/json");
req->WriteReply(HTTP_OK, strJSON);
return true;
@@ -588,6 +605,8 @@ static bool rest_filter_header(const std::any& context, HTTPRequest* req, const
ssHeader << header;
}
// Do not cache because chain extensions and reorgs can affect the response.
req->WriteHeader("Cache-Control", REST_CACHE_NO_STORE);
req->WriteHeader("Content-Type", "application/octet-stream");
req->WriteReply(HTTP_OK, ssHeader);
return true;
@@ -599,6 +618,7 @@ static bool rest_filter_header(const std::any& context, HTTPRequest* req, const
}
std::string strHex = HexStr(ssHeader) + "\n";
req->WriteHeader("Cache-Control", REST_CACHE_NO_STORE);
req->WriteHeader("Content-Type", "text/plain");
req->WriteReply(HTTP_OK, strHex);
return true;
@@ -610,6 +630,7 @@ static bool rest_filter_header(const std::any& context, HTTPRequest* req, const
}
std::string strJSON = jsonHeaders.write() + "\n";
req->WriteHeader("Cache-Control", REST_CACHE_NO_STORE);
req->WriteHeader("Content-Type", "application/json");
req->WriteReply(HTTP_OK, strJSON);
return true;
@@ -684,6 +705,7 @@ static bool rest_block_filter(const std::any& context, HTTPRequest* req, const s
DataStream ssResp{};
ssResp << filter;
req->WriteHeader("Cache-Control", REST_CACHE_IMMUTABLE);
req->WriteHeader("Content-Type", "application/octet-stream");
req->WriteReply(HTTP_OK, ssResp);
return true;
@@ -693,6 +715,7 @@ static bool rest_block_filter(const std::any& context, HTTPRequest* req, const s
ssResp << filter;
std::string strHex = HexStr(ssResp) + "\n";
req->WriteHeader("Cache-Control", REST_CACHE_IMMUTABLE);
req->WriteHeader("Content-Type", "text/plain");
req->WriteReply(HTTP_OK, strHex);
return true;
@@ -701,6 +724,7 @@ static bool rest_block_filter(const std::any& context, HTTPRequest* req, const s
UniValue ret(UniValue::VOBJ);
ret.pushKV("filter", HexStr(filter.GetEncodedFilter()));
std::string strJSON = ret.write() + "\n";
req->WriteHeader("Cache-Control", REST_CACHE_IMMUTABLE);
req->WriteHeader("Content-Type", "application/json");
req->WriteReply(HTTP_OK, strJSON);
return true;
@@ -728,6 +752,7 @@ static bool rest_chaininfo(const std::any& context, HTTPRequest* req, const std:
jsonRequest.params = UniValue(UniValue::VARR);
UniValue chainInfoObject = getblockchaininfo().HandleRequest(jsonRequest);
std::string strJSON = chainInfoObject.write() + "\n";
req->WriteHeader("Cache-Control", REST_CACHE_NO_STORE);
req->WriteHeader("Content-Type", "application/json");
req->WriteReply(HTTP_OK, strJSON);
return true;
@@ -747,6 +772,7 @@ static bool rest_deploymentinfo(const std::any& context, HTTPRequest* req, const
std::string hash_str;
const RESTResponseFormat rf = ParseDataFormat(hash_str, str_uri_part);
const bool current_tip{hash_str.empty()};
switch (rf) {
case RESTResponseFormat::JSON: {
@@ -754,7 +780,7 @@ static bool rest_deploymentinfo(const std::any& context, HTTPRequest* req, const
jsonRequest.context = context;
jsonRequest.params = UniValue(UniValue::VARR);
if (!hash_str.empty()) {
if (!current_tip) {
auto hash{uint256::FromHex(hash_str)};
if (!hash) {
return RESTERR(req, HTTP_BAD_REQUEST, "Invalid hash: " + hash_str);
@@ -769,6 +795,7 @@ static bool rest_deploymentinfo(const std::any& context, HTTPRequest* req, const
jsonRequest.params.push_back(hash_str);
}
req->WriteHeader("Cache-Control", current_tip ? REST_CACHE_NO_STORE : REST_CACHE_IMMUTABLE);
req->WriteHeader("Content-Type", "application/json");
req->WriteReply(HTTP_OK, getdeploymentinfo().HandleRequest(jsonRequest).write() + "\n");
return true;
@@ -826,6 +853,7 @@ static bool rest_mempool(const std::any& context, HTTPRequest* req, const std::s
str_json = MempoolInfoToJSON(*mempool).write() + "\n";
}
req->WriteHeader("Cache-Control", REST_CACHE_NO_STORE);
req->WriteHeader("Content-Type", "application/json");
req->WriteReply(HTTP_OK, str_json);
return true;
@@ -859,12 +887,12 @@ static bool rest_tx(const std::any& context, HTTPRequest* req, const std::string
if (!tx) {
return RESTERR(req, HTTP_NOT_FOUND, hashStr + " not found");
}
switch (rf) {
case RESTResponseFormat::BINARY: {
DataStream ssTx;
ssTx << TX_WITH_WITNESS(tx);
req->WriteHeader("Cache-Control", REST_CACHE_NO_STORE);
req->WriteHeader("Content-Type", "application/octet-stream");
req->WriteReply(HTTP_OK, ssTx);
return true;
@@ -875,6 +903,7 @@ static bool rest_tx(const std::any& context, HTTPRequest* req, const std::string
ssTx << TX_WITH_WITNESS(tx);
std::string strHex = HexStr(ssTx) + "\n";
req->WriteHeader("Cache-Control", REST_CACHE_NO_STORE);
req->WriteHeader("Content-Type", "text/plain");
req->WriteReply(HTTP_OK, strHex);
return true;
@@ -884,6 +913,7 @@ static bool rest_tx(const std::any& context, HTTPRequest* req, const std::string
UniValue objTx(UniValue::VOBJ);
TxToUniv(*tx, /*block_hash=*/hashBlock, /*entry=*/ objTx);
std::string strJSON = objTx.write() + "\n";
req->WriteHeader("Cache-Control", REST_CACHE_NO_STORE);
req->WriteHeader("Content-Type", "application/json");
req->WriteReply(HTTP_OK, strJSON);
return true;
@@ -1039,6 +1069,7 @@ static bool rest_getutxos(const std::any& context, HTTPRequest* req, const std::
DataStream ssGetUTXOResponse{};
ssGetUTXOResponse << active_height << active_hash << bitmap << outs;
req->WriteHeader("Cache-Control", REST_CACHE_NO_STORE);
req->WriteHeader("Content-Type", "application/octet-stream");
req->WriteReply(HTTP_OK, ssGetUTXOResponse);
return true;
@@ -1049,6 +1080,7 @@ static bool rest_getutxos(const std::any& context, HTTPRequest* req, const std::
ssGetUTXOResponse << active_height << active_hash << bitmap << outs;
std::string strHex = HexStr(ssGetUTXOResponse) + "\n";
req->WriteHeader("Cache-Control", REST_CACHE_NO_STORE);
req->WriteHeader("Content-Type", "text/plain");
req->WriteReply(HTTP_OK, strHex);
return true;
@@ -1079,6 +1111,7 @@ static bool rest_getutxos(const std::any& context, HTTPRequest* req, const std::
// return json string
std::string strJSON = objGetUTXOResponse.write() + "\n";
req->WriteHeader("Cache-Control", REST_CACHE_NO_STORE);
req->WriteHeader("Content-Type", "application/json");
req->WriteReply(HTTP_OK, strJSON);
return true;
@@ -1117,16 +1150,20 @@ static bool rest_blockhash_by_height(const std::any& context, HTTPRequest* req,
case RESTResponseFormat::BINARY: {
DataStream ss_blockhash{};
ss_blockhash << pblockindex->GetBlockHash();
// Do not cache because reorgs can change the response.
req->WriteHeader("Cache-Control", REST_CACHE_NO_STORE);
req->WriteHeader("Content-Type", "application/octet-stream");
req->WriteReply(HTTP_OK, ss_blockhash);
return true;
}
case RESTResponseFormat::HEX: {
req->WriteHeader("Cache-Control", REST_CACHE_NO_STORE);
req->WriteHeader("Content-Type", "text/plain");
req->WriteReply(HTTP_OK, pblockindex->GetBlockHash().GetHex() + "\n");
return true;
}
case RESTResponseFormat::JSON: {
req->WriteHeader("Cache-Control", REST_CACHE_NO_STORE);
req->WriteHeader("Content-Type", "application/json");
UniValue resp = UniValue(UniValue::VOBJ);
resp.pushKV("blockhash", pblockindex->GetBlockHash().GetHex());

View File

@@ -23,6 +23,8 @@ from test_framework.util import (
assert_equal,
assert_greater_than,
assert_greater_than_or_equal,
assert_not_equal,
sync_txindex,
)
from test_framework.wallet import (
MiniWallet,
@@ -52,7 +54,7 @@ def filter_output_indices_by_value(vouts, value):
class RESTTest (BitcoinTestFramework):
def set_test_params(self):
self.num_nodes = 2
self.extra_args = [["-rest", "-blockfilterindex=1"], []]
self.extra_args = [["-rest", "-blockfilterindex=1", "-txindex"], []]
# whitelist peers to speed up tx relay / mempool sync
self.noban_tx_relay = True
@@ -60,14 +62,14 @@ class RESTTest (BitcoinTestFramework):
self,
uri: str,
http_method: str = 'GET',
req_type: ReqType = ReqType.JSON,
req_type: typing.Optional[ReqType] = ReqType.JSON,
body: str = '',
status: int = 200,
ret_type: RetType = RetType.JSON,
query_params: typing.Union[dict[str, typing.Any], str, None] = None,
) -> typing.Union[http.client.HTTPResponse, bytes, str, None]:
rest_uri = '/rest' + uri
if req_type in ReqType:
if isinstance(req_type, ReqType):
rest_uri += f'.{req_type.name.lower()}'
if query_params:
if isinstance(query_params, str):
@@ -304,7 +306,7 @@ class RESTTest (BitcoinTestFramework):
expected_filter = {
'basic block filter index': {'synced': True, 'best_block_height': 208},
}
self.wait_until(lambda: self.nodes[0].getindexinfo() == expected_filter)
self.wait_until(lambda: self.nodes[0].getindexinfo("basic block filter index") == expected_filter)
json_obj = self.test_rest_request(f"/headers/{bb_hash}", query_params={"count": 5})
assert_equal(len(json_obj), 5) # now we should have 5 header objects
json_obj = self.test_rest_request(f"/blockfilterheaders/basic/{bb_hash}", query_params={"count": 5})
@@ -523,5 +525,118 @@ class RESTTest (BitcoinTestFramework):
resp = self.test_rest_request(f"/deploymentinfo/{INVALID_PARAM}", ret_type=RetType.OBJ, status=400)
assert_equal(resp.read().decode('utf-8').rstrip(), f"Invalid hash: {INVALID_PARAM}")
self.log.info("Test Cache-Control headers on REST responses")
blockhash = self.nodes[0].getbestblockhash()
height = self.nodes[0].getblockcount()
immutable = "public, immutable, max-age=86400"
no_store = "no-store"
def assert_cache_control(
uri: str,
expected: str,
*,
req_type: typing.Optional[ReqType] = ReqType.JSON,
status: int = 200,
query_params: typing.Union[dict[str, typing.Any], str, None] = None,
) -> bytes:
"""Assert `uri` has expected Cache-Control and return the response body."""
response = self.test_rest_request(
uri,
req_type=req_type,
status=status,
ret_type=RetType.OBJ,
query_params=query_params,
)
assert isinstance(response, http.client.HTTPResponse)
assert_equal(response.getheader("Cache-Control"), expected)
return response.read()
# Immutable endpoints
immutable_block = assert_cache_control(f"/block/{blockhash}", immutable, req_type=ReqType.BIN)
immutable_spenttxouts = assert_cache_control(f"/spenttxouts/{blockhash}", immutable)
assert_cache_control(f"/spenttxouts/{blockhash}", immutable, req_type=ReqType.BIN)
assert_cache_control(f"/spenttxouts/{blockhash}", immutable, req_type=ReqType.HEX)
immutable_blockfilter = assert_cache_control(
f"/blockfilter/basic/{blockhash}", immutable, req_type=ReqType.JSON
)
assert_cache_control(f"/blockfilter/basic/{blockhash}", immutable, req_type=ReqType.BIN)
assert_cache_control(f"/blockfilter/basic/{blockhash}", immutable, req_type=ReqType.HEX)
# Mutable endpoints are not stored until validator support is available.
mutable_block = assert_cache_control(f"/block/{blockhash}", no_store)
assert_cache_control(f"/block/notxdetails/{blockhash}", no_store)
mutable_headers = assert_cache_control(
f"/headers/{blockhash}", no_store, req_type=ReqType.JSON, query_params={"count": 2}
)
assert_cache_control(f"/headers/{blockhash}", no_store, req_type=ReqType.BIN, query_params={"count": 1})
assert_cache_control(f"/headers/{blockhash}", no_store, req_type=ReqType.HEX, query_params={"count": 1})
assert_cache_control(f"/blockfilterheaders/basic/{blockhash}", no_store, req_type=ReqType.JSON, query_params={"count": 1})
assert_cache_control(f"/blockfilterheaders/basic/{blockhash}", no_store, req_type=ReqType.BIN, query_params={"count": 1})
assert_cache_control(f"/blockfilterheaders/basic/{blockhash}", no_store, req_type=ReqType.HEX, query_params={"count": 1})
assert_cache_control(f"/blockhashbyheight/{height}", no_store)
assert_cache_control(f"/blockhashbyheight/{height}", no_store, req_type=ReqType.BIN)
assert_cache_control(f"/blockhashbyheight/{height}", no_store, req_type=ReqType.HEX)
# Dynamic endpoints
mutable_chaininfo = assert_cache_control("/chaininfo", no_store)
assert_cache_control("/mempool/info", no_store)
mutable_deploymentinfo = assert_cache_control("/deploymentinfo", no_store)
immutable_deploymentinfo = assert_cache_control(f"/deploymentinfo/{blockhash}", immutable)
cache_tx = self.wallet.send_self_transfer(from_node=self.nodes[0])
mempool_txid = cache_tx["txid"]
cache_utxo = cache_tx["new_utxo"]
cache_utxo_path = f"/getutxos/{mempool_txid}-{cache_utxo['vout']}"
mempool_tx_response = assert_cache_control(f"/tx/{mempool_txid}", no_store, req_type=ReqType.JSON)
mempool_tx = json.loads(mempool_tx_response, parse_float=Decimal)
assert "blockhash" not in mempool_tx
assert_cache_control(f"/tx/{mempool_txid}", no_store, req_type=ReqType.BIN)
assert_cache_control(f"/tx/{mempool_txid}", no_store, req_type=ReqType.HEX)
self.generate(self.nodes[0], 1)
sync_txindex(self, self.nodes[0])
self.log.info("Test Cache-Control response behavior across a newly mined block")
assert_equal(immutable_block, assert_cache_control(f"/block/{blockhash}", immutable, req_type=ReqType.BIN))
assert_equal(immutable_spenttxouts, assert_cache_control(f"/spenttxouts/{blockhash}", immutable))
assert_equal(
immutable_blockfilter,
assert_cache_control(f"/blockfilter/basic/{blockhash}", immutable, req_type=ReqType.JSON),
)
assert_equal(immutable_deploymentinfo, assert_cache_control(f"/deploymentinfo/{blockhash}", immutable))
# no-store responses need not change after every block. Only compare responses
# that are guaranteed to incorporate this active chain extension.
assert_not_equal(mutable_block, assert_cache_control(f"/block/{blockhash}", no_store))
assert_not_equal(
mutable_headers,
assert_cache_control(f"/headers/{blockhash}", no_store, req_type=ReqType.JSON, query_params={"count": 2}),
)
assert_not_equal(mutable_chaininfo, assert_cache_control("/chaininfo", no_store))
assert_not_equal(mutable_deploymentinfo, assert_cache_control("/deploymentinfo", no_store))
confirmed_tx_response = assert_cache_control(f"/tx/{mempool_txid}", no_store, req_type=ReqType.JSON)
assert_not_equal(mempool_tx_response, confirmed_tx_response)
confirmed_tx = json.loads(confirmed_tx_response, parse_float=Decimal)
assert_equal(confirmed_tx["txid"], mempool_txid)
assert "blockhash" in confirmed_tx
assert_cache_control(f"/tx/{mempool_txid}", no_store, req_type=ReqType.BIN)
assert_cache_control(f"/tx/{mempool_txid}", no_store, req_type=ReqType.HEX)
assert_cache_control(cache_utxo_path, no_store, req_type=ReqType.JSON)
assert_cache_control(cache_utxo_path, no_store, req_type=ReqType.BIN)
assert_cache_control(cache_utxo_path, no_store, req_type=ReqType.HEX)
self.log.info("Test Cache-Control headers on REST error responses")
assert_cache_control(f"/block/{blockhash}.invalid", no_store, status=400, req_type=None)
assert_cache_control(f"/tx/{INVALID_PARAM}", no_store, status=400)
assert_cache_control(f"/deploymentinfo/{INVALID_PARAM}", no_store, status=400)
assert_cache_control("/blockhashbyheight/999999999", no_store, status=404)
assert_cache_control(f"/block/{UNKNOWN_PARAM}", no_store, status=404)
assert_cache_control(f"/tx/{'f' * 64}", no_store, status=404)
assert_cache_control("/mempool/not-a-valid-path", no_store, status=400)
assert_cache_control(f"/deploymentinfo/{non_existing_blockhash}", no_store, status=400)
if __name__ == '__main__':
RESTTest(__file__).main()