rest: add Cache-Control headers to REST responses #34794

pull w0xlt wants to merge 1 commits into bitcoin:master from w0xlt:rest-cache-control-headers changing 2 files +74 −0
  1. w0xlt commented at 0:09 am on March 11, 2026: contributor

    Add Cache-Control headers to all REST API responses, enabling standard HTTP caches (Nginx, CDNs, browsers) to cache responses appropriately without requiring per-deployment proxy configuration.

    Closes #33809

    Cache policies by endpoint type:

    • Hash-addressed (immutable): block, tx, headers, blockfilter, blockfilterheaders, spenttxouts return Cache-Control: public, max-age=31536000, immutable - content identified by hash never changes.

    • Height-addressed (short-lived): blockhashbyheight returns Cache-Control: public, max-age=30 - height-to-hash mapping can change during reorgs.

    • Dynamic state: chaininfo, mempool, getutxos, deploymentinfo return Cache-Control: no-store - these reflect current node state.

    • Error responses: return no-store via RESTERR to prevent caching of transient errors (e.g. 404 for not-yet-downloaded blocks).

    This allows operators to place a reverse proxy in front of bitcoind’s REST interface and get correct caching behavior out of the box, without needing endpoint-specific proxy rules

  2. rest: add Cache-Control headers to REST responses
    Add Cache-Control headers to all REST API responses so that HTTP
    caches (Nginx, CDNs, browsers) can cache responses appropriately
    without requiring per-deployment configuration.
    
    Cache policies by endpoint type:
    - Hash-addressed (immutable): block, tx, headers, blockfilter,
      blockfilterheaders, spenttxouts get "public, max-age=31536000,
      immutable" since content identified by hash never changes.
    - Height-addressed (short-lived): blockhashbyheight gets
      "public, max-age=30" since height-to-hash mapping can change
      during reorgs.
    - Dynamic state: chaininfo, mempool, getutxos, deploymentinfo
      get "no-store" since they reflect current node state.
    - Error responses: get "no-store" via RESTERR to prevent caching
      of transient errors (e.g. 404 for not-yet-downloaded blocks).
    dd65725207
  3. DrahtBot added the label RPC/REST/ZMQ on Mar 11, 2026
  4. DrahtBot commented at 0:10 am on March 11, 2026: contributor

    The following sections might be updated with supplementary metadata relevant to reviewers and maintainers.

    Reviews

    See the guideline for information on the review process.

    Type Reviewers
    Concept ACK sedited, willcl-ark, andrewtoth

    If your review is incorrectly listed, please copy-paste <!–meta-tag:bot-skip–> into the comment that the bot should ignore.

  5. sedited commented at 7:22 am on March 11, 2026: contributor
    Concept ACK
  6. in test/functional/interface_rest.py:566 in dd65725207
    561+
    562+        # Error responses should not be cached
    563+        response = self.test_rest_request(f"/block/{UNKNOWN_PARAM}", status=404, ret_type=RetType.OBJ)
    564+        assert_equal(response.getheader("Cache-Control"), "no-store")
    565+        response.read()
    566+
    


    ViniciusCestarii commented at 1:06 pm on March 11, 2026:

    You missed tests for the following routes:

    Endpoint Expected Cache-Control
    /tx public, max-age=31536000, immutable
    /blockfilter public, max-age=31536000, immutable
    /blockfilterheaders public, max-age=31536000, immutable
    /deploymentinfo no-store
    /getutxos no-store
     0        self.log.info("Test Cache-Control headers on REST responses")
     1
     2        blockhash = self.nodes[0].getbestblockhash()
     3        height = self.nodes[0].getblockcount()
     4        txid = self.wallet.send_to(from_node=self.nodes[0], scriptPubKey=getnewdestination()[1], amount=int(0.1 * COIN))["txid"]
     5
     6        # Immutable (hash-addressed) endpoints
     7        response = self.test_rest_request(f"/block/{blockhash}", ret_type=RetType.OBJ)
     8        assert_equal(response.getheader("Cache-Control"), "public, max-age=31536000, immutable")
     9        response.read()
    10
    11        response = self.test_rest_request(f"/block/{blockhash}", req_type=ReqType.BIN, ret_type=RetType.OBJ)
    12        assert_equal(response.getheader("Cache-Control"), "public, max-age=31536000, immutable")
    13        response.read()
    14
    15        response = self.test_rest_request(f"/headers/{blockhash}", ret_type=RetType.OBJ, query_params={"count": 1})
    16        assert_equal(response.getheader("Cache-Control"), "public, max-age=31536000, immutable")
    17        response.read()
    18
    19        response = self.test_rest_request(f"/spenttxouts/{blockhash}", ret_type=RetType.OBJ)
    20        assert_equal(response.getheader("Cache-Control"), "public, max-age=31536000, immutable")
    21        response.read()
    22
    23        response = self.test_rest_request(f"/tx/{txid}", ret_type=RetType.OBJ)
    24        assert_equal(response.getheader("Cache-Control"), "public, max-age=31536000, immutable")
    25        response.read()
    26
    27        response = self.test_rest_request(f"/blockfilter/basic/{blockhash}", ret_type=RetType.OBJ)
    28        assert_equal(response.getheader("Cache-Control"), "public, max-age=31536000, immutable")
    29        response.read()
    30
    31        response = self.test_rest_request(f"/blockfilterheaders/basic/{blockhash}", ret_type=RetType.OBJ, query_params={"count": 1})
    32        assert_equal(response.getheader("Cache-Control"), "public, max-age=31536000, immutable")
    33        response.read()
    34
    35        # Short-lived (height-addressed) endpoints
    36        response = self.test_rest_request(f"/blockhashbyheight/{height}", ret_type=RetType.OBJ)
    37        assert_equal(response.getheader("Cache-Control"), "public, max-age=30")
    38        response.read()
    39
    40        # Dynamic (no-store) endpoints
    41        response = self.test_rest_request("/chaininfo", ret_type=RetType.OBJ)
    42        assert_equal(response.getheader("Cache-Control"), "no-store")
    43        response.read()
    44
    45        response = self.test_rest_request("/mempool/info", ret_type=RetType.OBJ)
    46        assert_equal(response.getheader("Cache-Control"), "no-store")
    47        response.read()
    48
    49        response = self.test_rest_request("/deploymentinfo", ret_type=RetType.OBJ)
    50        assert_equal(response.getheader("Cache-Control"), "no-store")
    51        response.read()
    52
    53        response = self.test_rest_request(f"/getutxos/{txid}-0", ret_type=RetType.OBJ)
    54        assert_equal(response.getheader("Cache-Control"), "no-store")
    55        response.read()
    56
    57        # Error responses should not be cached
    58        response = self.test_rest_request(f"/block/{UNKNOWN_PARAM}", status=404, ret_type=RetType.OBJ)
    59        assert_equal(response.getheader("Cache-Control"), "no-store")
    60        response.read()
    
  7. in src/rest.cpp:911 in dd65725207
    907@@ -882,6 +908,7 @@ static bool rest_tx(const std::any& context, HTTPRequest* req, const std::string
    908         UniValue objTx(UniValue::VOBJ);
    909         TxToUniv(*tx, /*block_hash=*/hashBlock, /*entry=*/ objTx);
    910         std::string strJSON = objTx.write() + "\n";
    911+        req->WriteHeader("Cache-Control", REST_CACHE_IMMUTABLE);
    


    willcl-ark commented at 8:53 pm on March 11, 2026:

    If we get the tx from the mempool, (so no hashBlock) then this JSON response will be cached for a year missing a hashBlock field that would otherwise be present/expected in the reponse.

    I guess we coul check hashBlock.IsNull() before deciding which cache policy to use on this response?


    willcl-ark commented at 8:54 pm on March 11, 2026:
    This makes me think perhaps, outside of this case, the header could be outside the switch statment? As we repeat the same header 3x in each one currently…

    andrewtoth commented at 3:14 am on March 12, 2026:
    I think the JSON format should be short, hex and binary should be immutable. The JSON response also has confirmations field, which will be cached for a year.
  8. in src/rest.cpp:238 in dd65725207


    willcl-ark commented at 8:59 pm on March 11, 2026:

    As we are walking forwards here, could a re-org not cause stale headers to be cached for a year near the (current) tip?

    Maybe not very likely to be hit, as someone would have to request the same query parameter: /rest/headers/<hash>?count=<count> I think? But perhaps worth considering…


    andrewtoth commented at 3:16 am on March 12, 2026:

    Won’t the count be cached, so if we lookup /rest/headers/<hash>?count=2 at the tip we would get no more results? Then new blocks come in, but the response is cached so a polling client won’t see new headers.

    Same goes for block filter headers.

  9. willcl-ark commented at 9:00 pm on March 11, 2026: member
    Concept ACK
  10. andrewtoth commented at 3:20 am on March 12, 2026: contributor

    Concept ACK

    I think this is being too liberal with IMMUTABLE. For the JSON variants of headers, block, and tx they all contain a confirmations field. This updates every block. I think we should have SHORT for the JSON variants of these and IMMUTABLE only for binary and hex.


github-metadata-mirror

This is a metadata mirror of the GitHub repository bitcoin/bitcoin. This site is not affiliated with GitHub. Content is generated from a GitHub metadata backup.
generated: 2026-03-15 09:13 UTC

This site is hosted by @0xB10C
More mirrored repositories can be found on mirror.b10c.me