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

pull w0xlt wants to merge 3 commits into bitcoin:master from w0xlt:rest-cache-control-headers changing 4 files +179 −3
  1. w0xlt commented at 12:09 AM on March 11, 2026: contributor

    This PR adds explicit Cache-Control headers to REST responses.

    The policy is:

    • Immutable data gets: Cache-Control: public, immutable, max-age=86400

    • Mutable but cache-revalidatable data gets: Cache-Control: no-cache, must-revalidate

    • Dynamic responses and errors get: Cache-Control: no-store

    Important details:

    • /block bin/hex, /blockfilter, /spenttxouts, and /deploymentinfo/<blockhash>.json are treated as immutable.
    • /block JSON, /headers, /blockfilterheaders, /blockhashbyheight, and confirmed /tx are treated as mutable.
    • Mempool /tx, /chaininfo, /mempool, /getutxos, /deploymentinfo.json, and REST errors are no-store.
    • Confirmed /tx bin/hex are mutable because the REST URL is keyed by txid, but the returned bytes include witness data.
    • Unmatched /rest 404s now also return no-store, including paths like /rest/tx, /rest/does-not-exist, and /rest?x=1.

    Tests were added in interface_rest.py to cover successful responses, REST errors, and unmatched REST 404s.

    Docs were added to REST-interface.md, including safer proxy examples that avoid caching REST error responses.

    Closes #33809

  2. DrahtBot added the label RPC/REST/ZMQ on Mar 11, 2026
  3. DrahtBot commented at 12:10 AM on March 11, 2026: contributor

    <!--e57a25ab6845829454e8d69fc972939a-->

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

    <!--006a51241073e994b41acfe9ec718e94-->

    Code Coverage & Benchmarks

    For details see: https://corecheck.dev/bitcoin/bitcoin/pulls/34794.

    <!--021abf342d371248e50ceaed478a90ca-->

    Reviews

    See the guideline for information on the review process.

    Type Reviewers
    ACK stickies-v
    Concept ACK sedited, willcl-ark, andrewtoth, optout21

    If your review is incorrectly listed, please copy-paste <code>&lt;!--meta-tag:bot-skip--&gt;</code> into the comment that the bot should ignore.

    <!--5faf32d7da4f0f540f40219e4f7537a3-->

  4. sedited commented at 7:22 AM on March 11, 2026: contributor

    Concept ACK

  5. in test/functional/interface_rest.py:566 in dd65725207 outdated
     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
            self.log.info("Test Cache-Control headers on REST responses")
    
            blockhash = self.nodes[0].getbestblockhash()
            height = self.nodes[0].getblockcount()
            txid = self.wallet.send_to(from_node=self.nodes[0], scriptPubKey=getnewdestination()[1], amount=int(0.1 * COIN))["txid"]
    
            # Immutable (hash-addressed) endpoints
            response = self.test_rest_request(f"/block/{blockhash}", ret_type=RetType.OBJ)
            assert_equal(response.getheader("Cache-Control"), "public, max-age=31536000, immutable")
            response.read()
    
            response = self.test_rest_request(f"/block/{blockhash}", req_type=ReqType.BIN, ret_type=RetType.OBJ)
            assert_equal(response.getheader("Cache-Control"), "public, max-age=31536000, immutable")
            response.read()
    
            response = self.test_rest_request(f"/headers/{blockhash}", ret_type=RetType.OBJ, query_params={"count": 1})
            assert_equal(response.getheader("Cache-Control"), "public, max-age=31536000, immutable")
            response.read()
    
            response = self.test_rest_request(f"/spenttxouts/{blockhash}", ret_type=RetType.OBJ)
            assert_equal(response.getheader("Cache-Control"), "public, max-age=31536000, immutable")
            response.read()
    
            response = self.test_rest_request(f"/tx/{txid}", ret_type=RetType.OBJ)
            assert_equal(response.getheader("Cache-Control"), "public, max-age=31536000, immutable")
            response.read()
    
            response = self.test_rest_request(f"/blockfilter/basic/{blockhash}", ret_type=RetType.OBJ)
            assert_equal(response.getheader("Cache-Control"), "public, max-age=31536000, immutable")
            response.read()
    
            response = self.test_rest_request(f"/blockfilterheaders/basic/{blockhash}", ret_type=RetType.OBJ, query_params={"count": 1})
            assert_equal(response.getheader("Cache-Control"), "public, max-age=31536000, immutable")
            response.read()
    
            # Short-lived (height-addressed) endpoints
            response = self.test_rest_request(f"/blockhashbyheight/{height}", ret_type=RetType.OBJ)
            assert_equal(response.getheader("Cache-Control"), "public, max-age=30")
            response.read()
    
            # Dynamic (no-store) endpoints
            response = self.test_rest_request("/chaininfo", ret_type=RetType.OBJ)
            assert_equal(response.getheader("Cache-Control"), "no-store")
            response.read()
    
            response = self.test_rest_request("/mempool/info", ret_type=RetType.OBJ)
            assert_equal(response.getheader("Cache-Control"), "no-store")
            response.read()
    
            response = self.test_rest_request("/deploymentinfo", ret_type=RetType.OBJ)
            assert_equal(response.getheader("Cache-Control"), "no-store")
            response.read()
    
            response = self.test_rest_request(f"/getutxos/{txid}-0", ret_type=RetType.OBJ)
            assert_equal(response.getheader("Cache-Control"), "no-store")
            response.read()
    
            # Error responses should not be cached
            response = self.test_rest_request(f"/block/{UNKNOWN_PARAM}", status=404, ret_type=RetType.OBJ)
            assert_equal(response.getheader("Cache-Control"), "no-store")
            response.read()
    

    w0xlt commented at 1:52 AM on May 22, 2026:

    Done. Thanks.

  6. 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.


    w0xlt commented at 1:52 AM on May 22, 2026:

    Done. Thanks.

  7. 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.


    w0xlt commented at 1:52 AM on May 22, 2026:

    Done. Thanks.


    stickies-v commented at 12:43 PM on May 22, 2026:

    I think this test would be more readable with a single assert_cache_control that returns the response body. For tests where we test all 3 request types, making JSON explicit also feels a bit more consistent.

    That, and a couple of other nits in:

    <details> <summary>git diff on d2a3b2a10f</summary>

    diff --git a/test/functional/interface_rest.py b/test/functional/interface_rest.py
    index 840ba0cc66..524b9185ef 100755
    --- a/test/functional/interface_rest.py
    +++ b/test/functional/interface_rest.py
    @@ -534,37 +534,35 @@ class RESTTest (BitcoinTestFramework):
             mutable = "no-cache, must-revalidate"
             no_store = "no-store"
     
    -        def assert_cache_control(uri, expected, *, req_type=ReqType.JSON, status=200, query_params=None):
    -            response = self.test_rest_request(uri, req_type=req_type, status=status, ret_type=RetType.OBJ, query_params=query_params)
    +        def assert_cache_control(
    +            uri: str,
    +            expected: str,
    +            *,
    +            req_type: ReqType = ReqType.JSON,
    +            status: int = 200,
    +            query_params: typing.Union[dict[str, typing.Any], str, None] = None,
    +            append_suffix: bool = True,
    +        ) -> bytes:
    +            """Request `uri`, assert its Cache-Control matches `expected`, 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, append_suffix=append_suffix)
                 assert_equal(response.getheader("Cache-Control"), expected)
    -            response.read()
    -
    -        def assert_rest_error_no_store(uri, status, *, req_type=ReqType.JSON, query_params=None, append_suffix=True):
    -            response = self.test_rest_request(
    -                uri,
    -                req_type=req_type,
    -                status=status,
    -                ret_type=RetType.OBJ,
    -                query_params=query_params,
    -                append_suffix=append_suffix,
    -            )
    -            assert_equal(response.getheader("Cache-Control"), no_store)
    -            response.read()
    +            return response.read()
     
             # Immutable endpoints
             assert_cache_control(f"/block/{blockhash}", immutable, req_type=ReqType.BIN)
             assert_cache_control(f"/spenttxouts/{blockhash}", immutable)
    -        assert_cache_control(f"/blockfilter/basic/{blockhash}", immutable)
    +        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)
    +        assert_cache_control(f"/deploymentinfo/{blockhash}", immutable)
     
             # Mutable endpoints
             assert_cache_control(f"/block/{blockhash}", mutable)
             assert_cache_control(f"/block/notxdetails/{blockhash}", mutable)
    -        assert_cache_control(f"/headers/{blockhash}", mutable, query_params={"count": 1})
    +        assert_cache_control(f"/headers/{blockhash}", mutable, req_type=ReqType.JSON, query_params={"count": 1})
             assert_cache_control(f"/headers/{blockhash}", mutable, req_type=ReqType.BIN, query_params={"count": 1})
             assert_cache_control(f"/headers/{blockhash}", mutable, req_type=ReqType.HEX, query_params={"count": 1})
    -        assert_cache_control(f"/blockfilterheaders/basic/{blockhash}", mutable, query_params={"count": 1})
    +        assert_cache_control(f"/blockfilterheaders/basic/{blockhash}", mutable, req_type=ReqType.JSON, query_params={"count": 1})
             assert_cache_control(f"/blockfilterheaders/basic/{blockhash}", mutable, req_type=ReqType.BIN, query_params={"count": 1})
             assert_cache_control(f"/blockfilterheaders/basic/{blockhash}", mutable, req_type=ReqType.HEX, query_params={"count": 1})
             assert_cache_control(f"/blockhashbyheight/{height}", mutable)
    @@ -573,16 +571,13 @@ class RESTTest (BitcoinTestFramework):
             assert_cache_control("/chaininfo", no_store)
             assert_cache_control("/mempool/info", no_store)
             assert_cache_control("/deploymentinfo", no_store)
    -        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']}"
    +        cache_utxo_path = f"/getutxos/{mempool_txid}-{cache_tx['new_utxo']['vout']}"
     
    -        response = self.test_rest_request(f"/tx/{mempool_txid}", ret_type=RetType.OBJ)
    -        assert_equal(response.getheader("Cache-Control"), no_store)
    -        mempool_tx = json.loads(response.read().decode('utf-8'), parse_float=Decimal)
    +        # Mempool /tx: no-store across all formats; JSON has no blockhash.
    +        mempool_tx = json.loads(assert_cache_control(f"/tx/{mempool_txid}", no_store), 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)
    @@ -590,29 +585,28 @@ class RESTTest (BitcoinTestFramework):
             self.generate(self.nodes[0], 1)
             sync_txindex(self, self.nodes[0])
     
    -        response = self.test_rest_request(f"/tx/{mempool_txid}", ret_type=RetType.OBJ)
    -        assert_equal(response.getheader("Cache-Control"), mutable)
    -        confirmed_tx = json.loads(response.read().decode('utf-8'), parse_float=Decimal)
    +        # Confirmed /tx: mutable across all formats; JSON includes blockhash.
    +        confirmed_tx = json.loads(assert_cache_control(f"/tx/{mempool_txid}", mutable), parse_float=Decimal)
             assert_equal(confirmed_tx["txid"], mempool_txid)
             assert "blockhash" in confirmed_tx
             assert_cache_control(f"/tx/{mempool_txid}", mutable, req_type=ReqType.BIN)
             assert_cache_control(f"/tx/{mempool_txid}", mutable, req_type=ReqType.HEX)
    -        assert_cache_control(cache_utxo_path, no_store)
    +        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_rest_error_no_store(f"/block/{blockhash}.invalid", 400, append_suffix=False)
    -        assert_rest_error_no_store(f"/tx/{INVALID_PARAM}", 400)
    -        assert_rest_error_no_store(f"/deploymentinfo/{INVALID_PARAM}", 400)
    -        assert_rest_error_no_store("/blockhashbyheight/999999999", 404)
    -        assert_rest_error_no_store(f"/block/{UNKNOWN_PARAM}", 404)
    -        assert_rest_error_no_store(f"/tx/{'f' * 64}", 404)
    -        assert_rest_error_no_store("", 404, query_params={"x": 1}, append_suffix=False)
    -        assert_rest_error_no_store("/tx", 404, append_suffix=False)
    -        assert_rest_error_no_store("/does-not-exist", 404, append_suffix=False)
    -        assert_rest_error_no_store("/mempool/not-a-valid-path", 400)
    -        assert_rest_error_no_store(f"/deploymentinfo/{non_existing_blockhash}", 400)
    +        assert_cache_control(f"/block/{blockhash}.invalid", no_store, status=400, append_suffix=False)
    +        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("", no_store, status=404, query_params={"x": 1}, append_suffix=False)
    +        assert_cache_control("/tx", no_store, status=404, append_suffix=False)
    +        assert_cache_control("/does-not-exist", no_store, status=404, append_suffix=False)
    +        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()
    
    

    </details>

  8. willcl-ark commented at 9:00 PM on March 11, 2026: member

    Concept ACK

  9. 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.

  10. in src/rest.cpp:46 in dd65725207 outdated
      42 | @@ -43,6 +43,11 @@ using util::SplitString;
      43 |  static const size_t MAX_GETUTXOS_OUTPOINTS = 15; //allow a max of 15 outpoints to be queried at once
      44 |  static constexpr unsigned int MAX_REST_HEADERS_RESULTS = 2000;
      45 |  
      46 | +/** Cache-Control header values for REST responses. */
    


    chriszeng1010 commented at 8:26 PM on March 15, 2026:

    Cache the JSON forever is wrong isn't it? The confirmations field will be wrong within 10 minutes.

    Is it possible to use no-store instead of immutable ?


    w0xlt commented at 1:52 AM on May 22, 2026:

    Done. Thanks.

  11. in src/rest.cpp:49 in dd65725207
      42 | @@ -43,6 +43,11 @@ using util::SplitString;
      43 |  static const size_t MAX_GETUTXOS_OUTPOINTS = 15; //allow a max of 15 outpoints to be queried at once
      44 |  static constexpr unsigned int MAX_REST_HEADERS_RESULTS = 2000;
      45 |  
      46 | +/** Cache-Control header values for REST responses. */
      47 | +static constexpr const char* REST_CACHE_IMMUTABLE = "public, max-age=31536000, immutable";
      48 | +static constexpr const char* REST_CACHE_NO_STORE = "no-store";
      49 | +static constexpr const char* REST_CACHE_SHORT = "public, max-age=30";
    


    optout21 commented at 9:45 AM on March 16, 2026:

    It would be useful to state the assumed objective of the caching. I suppose it's performance in case of heavy RPC traffic, by taking off load of a node (and e.g., not resilience in face of a crashing node). Assuming the objective is RPC performance, I think even for the immutable case the validity period should be much shorter, like 1 day, or even shorter, such as 1 hour. This would be a much more conservative setup, against issues caused by accidentally caching changing values for short. I suggest shorter for SHORT as well, 10 seconds. That's plenty of caching for frequent queries (like getnetworkinfo) in case of high-frequency usage. Note that in specific large deployements it's always possible to finetune this via nginx config (which should be mentioned, btw), therefore it's better to err on the lower side than the too-much caching side.


    w0xlt commented at 1:52 AM on May 22, 2026:

    Done. Thanks.

  12. luke-jr commented at 9:58 AM on March 16, 2026: member

    Height-addressed (short-lived)

    It might make sense to promote to immutable after it's sufficiently buried?

  13. w0xlt force-pushed on Mar 17, 2026
  14. w0xlt commented at 12:05 AM on March 18, 2026: contributor

    Thanks all for the review comments. I went through the feedback and updated the implementation to address the main correctness concerns, and also expanded the functional coverage to match the final behavior.

    The main change is that this is no longer a single cache policy per endpoint family. The implementation now uses a more conservative endpoint/format/state matrix:

    • Truly immutable responses stay Cache-Control: public, max-age=31536000, immutable. This now applies to confirmed /block and /tx binary/hex responses, and to /blockfilter and /spenttxouts in all formats.

    • Responses that can change with chain progress are now short-lived with Cache-Control: public, max-age=10. This applies to /block and /block/notxdetails JSON, confirmed /tx JSON, /headers, /blockfilterheaders, and /blockhashbyheight.

    • Dynamic/stateful responses remain Cache-Control: no-store. This applies to mempool /tx, /chaininfo, /mempool, /getutxos, /deploymentinfo, and all error responses.

    This addresses the review concerns around mutable JSON fields and near-tip staleness raised by @willcl-ark, @andrewtoth, and @chriszeng1010:

    • For /block JSON, the response contains tip-dependent fields, so JSON is no longer treated as immutable even though the block itself is hash-addressed, as pointed out by @andrewtoth and @chriszeng1010.

    • For /tx, mempool responses are now no-store by checking whether hashBlock.IsNull(), since the response can later gain block metadata after confirmation, as pointed out by @willcl-ark.

    • For /headers and /blockfilterheaders, all formats were downgraded from immutable to short-lived. Even though they are hash-addressed, the count parameter and near-tip behavior mean these responses can become stale across reorgs or as new blocks are added, as pointed out by @willcl-ark and @andrewtoth.

    • I also reduced the short TTL from 30 seconds to 10 seconds to make the default behavior more conservative, following the feedback from @optout21.

    On the testing side, I expanded the functional coverage to match the final policy matrix, following the test coverage suggestions from @ViniciusCestarii. The test now includes cache-control assertions for /tx, /blockfilter, /blockfilterheaders, /deploymentinfo, and /getutxos, and it explicitly exercises both /tx cases:

    • a mempool tx (no-store)
    • the same tx after confirmation (JSON short-lived, binary/hex immutable)

    I did not add burial-depth, as suggested by @luke-jr or reorg-detection logic in this PR. For now, I chose the simpler conservative policy of keeping the ambiguous near-tip responses short-lived rather than trying to promote them to immutable later. The goal here is to provide safe default caching behavior out of the box, while still allowing operators to override TTLs in their proxy/CDN configuration if they want something more aggressive.

  15. optout21 commented at 9:57 AM on March 18, 2026: contributor
    • Truly immutable responses stay Cache-Control: public, max-age=31536000, immutable.

    I reiterate my opinion that proposing caching for 1 year is very risky with little benefits. I propose using 1 day as the max age, or at most 1 week. In a very high-traffic setup with a caching frontend, if the bitcoin core cannot handle requests for immutable data filtered to 1 request per day, then there must be such high usage that probably some custom architecture/solution is needed. In other words, from the performance point of view I don't see much benefit if using 1 year over using 1 day. On the other hand, I can envision scenarios where data cached for 1 year can cause trouble, even though the content is such that will never change in the blockchain. For example, imagine a new bitcoin core version that adds a new response field to an RPC. If the node is upgraded and restarted, but the rest of the serving/caching layers not, it can happen that the new response fields will not show up for a year (Note: if caching is critical, flushing the cache is a sensitive operation, because it could put very high strain on the node). Using a 1 day limit is much safer from this point of view.

  16. willcl-ark commented at 10:59 AM on March 18, 2026: member

    For example, imagine a new bitcoin core version that adds a new response field to an RPC. If the node is upgraded and restarted, but the rest of the serving/caching layers not, it can happen that the new response fields will not show up for a year (Note: if caching is critical, flushing the cache is a sensitive operation, because it could put very high strain on the node).

    I don't see how this would change the binary/hex returned for a /block or /tx REST response, so not sure it applies.

    That said, I also don't really see much benefit in caching for max-age=31536000. I agree that probably using something much more conservative here like a day or a week is more appropriate, perhaps max-age=86400. Doing a single REST lookup per day/week per tx/block seems like it would be fine. And being more conservative prevents defaults-using servers from ever getting stuck for too long without needing manual intervention, in some as-yet unforseen circumstance.

    We could also update https://github.com/bitcoin/bitcoin/blob/master/doc/REST-interface.md to describe default policies and an example of how to override one, for example:

    # nginx
    location ~ ^/rest/block/[^/]+\.(bin|hex)$ {
      proxy_pass http://127.0.0.1:8332;
      proxy_hide_header Cache-Control;
      add_header Cache-Control "public, max-age=31536000, immutable" always;
    }
    
    # Caddy
    [@block](/bitcoin-bitcoin/contributor/block/)_raw path_regexp ^/rest/block/[^/]+\.(bin|hex)$
    handle [@block](/bitcoin-bitcoin/contributor/block/)_raw {
      reverse_proxy 127.0.0.1:8332 {
          header_down Cache-Control "public, max-age=31536000, immutable"
      }
    }
    

    (examples not tested for correctness)

  17. w0xlt force-pushed on Mar 18, 2026
  18. w0xlt commented at 6:04 PM on March 18, 2026: contributor

    @optout21 @willcl-ark Thanks, I updated the default immutable TTL to 86400 and also added documentation in doc/REST-interface.md describing the default cache policies plus example proxy overrides for operators who want more aggressive caching.

  19. willcl-ark commented at 1:19 PM on March 19, 2026: member

    Great thanks! Looks pretty good now to me, apart from the first commit message detailing the previous max-age=31536000 for REST_CACHE_IMMUTABLE

  20. w0xlt force-pushed on Mar 19, 2026
  21. w0xlt commented at 5:03 PM on March 19, 2026: contributor

    Commit message updated. Thanks.

  22. optout21 commented at 11:27 AM on March 20, 2026: contributor

    crACK 42a5ae62d7d3b9114324db73e68a4c63ce7ad8aa

    Re-reviewed, bumped conceptACK to ACK. Previous: concept ACK 7392159574c15b775b2374f0046027d5730a8408

  23. DrahtBot requested review from willcl-ark on Mar 20, 2026
  24. DrahtBot requested review from andrewtoth on Mar 20, 2026
  25. DrahtBot requested review from sedited on Mar 20, 2026
  26. andrewtoth commented at 9:13 PM on March 21, 2026: contributor

    /chaininfo and /deploymentinfo should be short from what I can see. They depend on blocks and not mempool. The /getutxos/mempool should be no store, but /getutxos without checking mempool should be short as well.

    For /tx, mempool responses are now no-store by checking whether hashBlock.IsNull(), since the response can later gain block metadata after confirmation, as pointed out by willcl-ark.

    This reasoning doesn't seem consistent. If we are only concerned about block metadata after confirmation, then short is more appropriate? I suppose for this endpoint a common use case for this would be to poll an unconfirmed tx until it is confirmed, and a user would not want this state cached.

    Thinking about this more, I think that reasoning can apply to all responses that change based on block height. I think we should consider removing the short variant entirely. There are truly immutable responses, and then for everything else a user wants to see the updated block information immediately. I don't think we can introduce 10 second delays on block height changes for users who are expecting immediate updates. For instance, an exchange polling whether a deposit has gotten 2 confirmations will now cause a user to wait an extra 10 seconds before they can trade.

  27. w0xlt force-pushed on Mar 25, 2026
  28. w0xlt commented at 1:10 AM on March 25, 2026: contributor

    Thanks for the feedback @andrewtoth.

    /deploymentinfo in REST is a wrapper around the same getdeploymentinfo RPC, but it exposes two different cases: /deploymentinfo.json is tip-relative and changes with chain state, while /deploymentinfo/<blockhash>.json is anchored to a specific block. Based on that, I split the caching: the tip-relative form stays no-store, and the blockhash form is now cacheable.

    I did not change /chaininfo or /getutxos. Even without mempool involvement, those are still current-state endpoints, so freshness might be the better default than a short shared-cache window.

    That said, removing the short tier can be a reasonable direction if the goal is to avoid serving stale mutable REST state by default, although a 10-second cache may still be useful for heavily polled endpoints.

  29. sedited requested review from stickies-v on May 8, 2026
  30. sedited requested review from hodlinator on May 8, 2026
  31. stickies-v commented at 2:46 PM on May 8, 2026: contributor

    Concept ACK . I like how this aligns with the philosophy that our REST server is not meant to be exposed publically or serve large amounts of traffic, but allows other software like nginx to do their job better.

    I plan to fully review this PR soon.

  32. in src/rest.cpp:143 in 42a5ae62d7
     139 | @@ -70,6 +140,7 @@ struct CCoin {
     140 |  
     141 |  static bool RESTERR(HTTPRequest* req, enum HTTPStatusCode status, std::string message)
     142 |  {
     143 | +    WriteCacheControlHeader(req, RESTCacheControl::NO_STORE);
    


    b-l-u-e commented at 10:41 AM on May 12, 2026:

    I temporarily commented out WriteCacheControlHeader in RESTERR in rest.cpp, rebuilt, and compared:

    • GET /rest/block/<hash>.bin200 - this unchanged with or without that line: success responses set Cache-Control on their own path, not only via RESTERR.

    • GET /rest/tx/<64×0>.json404 - this without the line: no Cache-Control header. With the line: Cache-Control: no-store.

    So a regression that only breaks RESTERR can still look fine if tests only assert 200 responses.

    might be worth extending tests in interface_rest.py with append_suffix and assert_rest_error_no_store for several RESTERR outcomes 400/404.

    <details> <summary>click to expand</summary>

    --- a/test/functional/interface_rest.py
    +++ b/test/functional/interface_rest.py
    @@ -67,9 +67,11 @@ class RESTTest (BitcoinTestFramework):
                 status: int = 200,
                 ret_type: RetType = RetType.JSON,
                 query_params: typing.Union[dict[str, typing.Any], str, None] = None,
    +            *,
    +            append_suffix: bool = True,
                 ) -> typing.Union[http.client.HTTPResponse, bytes, str, None]:
             rest_uri = '/rest' + uri
    -        if req_type in ReqType:
    +        if append_suffix and req_type in ReqType:
                 rest_uri += f'.{req_type.name.lower()}'
             if query_params:
                 if isinstance(query_params, str):
    :...skipping...
    diff --git a/test/functional/interface_rest.py b/test/functional/interface_rest.py
    index ed2aef0f48..62ffdaa92b 100755
    --- a/test/functional/interface_rest.py
    +++ b/test/functional/interface_rest.py
    @@ -67,9 +67,11 @@ class RESTTest (BitcoinTestFramework):
                 status: int = 200,
                 ret_type: RetType = RetType.JSON,
                 query_params: typing.Union[dict[str, typing.Any], str, None] = None,
    +            *,
    +            append_suffix: bool = True,
                 ) -> typing.Union[http.client.HTTPResponse, bytes, str, None]:
             rest_uri = '/rest' + uri
    -        if req_type in ReqType:
    +        if append_suffix and req_type in ReqType:
                 rest_uri += f'.{req_type.name.lower()}'
             if query_params:
                 if isinstance(query_params, str):
    @@ -537,6 +539,18 @@ class RESTTest (BitcoinTestFramework):
                 assert_equal(response.getheader("Cache-Control"), expected)
    :...skipping...
    diff --git a/test/functional/interface_rest.py b/test/functional/interface_rest.py
    index ed2aef0f48..62ffdaa92b 100755
    --- a/test/functional/interface_rest.py
    +++ b/test/functional/interface_rest.py
    @@ -67,9 +67,11 @@ class RESTTest (BitcoinTestFramework):
                 status: int = 200,
                 ret_type: RetType = RetType.JSON,
                 query_params: typing.Union[dict[str, typing.Any], str, None] = None,
    +            *,
    +            append_suffix: bool = True,
                 ) -> typing.Union[http.client.HTTPResponse, bytes, str, None]:
             rest_uri = '/rest' + uri
    -        if req_type in ReqType:
    +        if append_suffix and req_type in ReqType:
                 rest_uri += f'.{req_type.name.lower()}'
             if query_params:
                 if isinstance(query_params, str):
    @@ -537,6 +539,18 @@ class RESTTest (BitcoinTestFramework):
                 assert_equal(response.getheader("Cache-Control"), expected)
                 response.read()
     
    :...skipping...
    diff --git a/test/functional/interface_rest.py b/test/functional/interface_rest.py
    index ed2aef0f48..62ffdaa92b 100755
    --- a/test/functional/interface_rest.py
    +++ b/test/functional/interface_rest.py
    @@ -67,9 +67,11 @@ class RESTTest (BitcoinTestFramework):
                 status: int = 200,
                 ret_type: RetType = RetType.JSON,
                 query_params: typing.Union[dict[str, typing.Any], str, None] = None,
    +            *,
    +            append_suffix: bool = True,
                 ) -> typing.Union[http.client.HTTPResponse, bytes, str, None]:
             rest_uri = '/rest' + uri
    -        if req_type in ReqType:
    +        if append_suffix and req_type in ReqType:
                 rest_uri += f'.{req_type.name.lower()}'
             if query_params:
                 if isinstance(query_params, str):
    @@ -537,6 +539,18 @@ class RESTTest (BitcoinTestFramework):
                 assert_equal(response.getheader("Cache-Control"), expected)
                 response.read()
     
    +        def assert_rest_error_no_store(uri, status, *, req_type=ReqType.JSON, query_params=None, append_suffix=True):
    +            response = self.test_rest_request(
    :...skipping...
    diff --git a/test/functional/interface_rest.py b/test/functional/interface_rest.py
    index ed2aef0f48..62ffdaa92b 100755
    --- a/test/functional/interface_rest.py
    +++ b/test/functional/interface_rest.py
    @@ -67,9 +67,11 @@ class RESTTest (BitcoinTestFramework):
                 status: int = 200,
                 ret_type: RetType = RetType.JSON,
                 query_params: typing.Union[dict[str, typing.Any], str, None] = None,
    +            *,
    +            append_suffix: bool = True,
                 ) -> typing.Union[http.client.HTTPResponse, bytes, str, None]:
             rest_uri = '/rest' + uri
    -        if req_type in ReqType:
    +        if append_suffix and req_type in ReqType:
                 rest_uri += f'.{req_type.name.lower()}'
             if query_params:
                 if isinstance(query_params, str):
    @@ -537,6 +539,18 @@ class RESTTest (BitcoinTestFramework):
                 assert_equal(response.getheader("Cache-Control"), expected)
                 response.read()
     
    +        def assert_rest_error_no_store(uri, status, *, req_type=ReqType.JSON, query_params=None, append_suffix=True):
    +            response = self.test_rest_request(
    +                uri,
    +                req_type=req_type,
    :...skipping...
    diff --git a/test/functional/interface_rest.py b/test/functional/interface_rest.py
    index ed2aef0f48..62ffdaa92b 100755
    --- a/test/functional/interface_rest.py
    +++ b/test/functional/interface_rest.py
    @@ -67,9 +67,11 @@ class RESTTest (BitcoinTestFramework):
                 status: int = 200,
                 ret_type: RetType = RetType.JSON,
                 query_params: typing.Union[dict[str, typing.Any], str, None] = None,
    +            *,
    +            append_suffix: bool = True,
                 ) -> typing.Union[http.client.HTTPResponse, bytes, str, None]:
             rest_uri = '/rest' + uri
    -        if req_type in ReqType:
    +        if append_suffix and req_type in ReqType:
                 rest_uri += f'.{req_type.name.lower()}'
             if query_params:
                 if isinstance(query_params, str):
    @@ -537,6 +539,18 @@ class RESTTest (BitcoinTestFramework):
                 assert_equal(response.getheader("Cache-Control"), expected)
                 response.read()
     
    +        def assert_rest_error_no_store(uri, status, *, req_type=ReqType.JSON, query_params=None, append_suffix=True):
    +            response = self.test_rest_request(
    +                uri,
    +                req_type=req_type,
    +                status=status,
    +                ret_type=RetType.OBJ,
    +                query_params=query_params,
    :...skipping...
    diff --git a/test/functional/interface_rest.py b/test/functional/interface_rest.py
    index ed2aef0f48..62ffdaa92b 100755
    --- a/test/functional/interface_rest.py
    +++ b/test/functional/interface_rest.py
    @@ -67,9 +67,11 @@ class RESTTest (BitcoinTestFramework):
                 status: int = 200,
                 ret_type: RetType = RetType.JSON,
                 query_params: typing.Union[dict[str, typing.Any], str, None] = None,
    +            *,
    +            append_suffix: bool = True,
                 ) -> typing.Union[http.client.HTTPResponse, bytes, str, None]:
             rest_uri = '/rest' + uri
    -        if req_type in ReqType:
    +        if append_suffix and req_type in ReqType:
                 rest_uri += f'.{req_type.name.lower()}'
             if query_params:
                 if isinstance(query_params, str):
    @@ -537,6 +539,18 @@ class RESTTest (BitcoinTestFramework):
                 assert_equal(response.getheader("Cache-Control"), expected)
                 response.read()
     
    +        def assert_rest_error_no_store(uri, status, *, req_type=ReqType.JSON, query_params=None, append_suffix=True):
    +            response = self.test_rest_request(
    +                uri,
    +                req_type=req_type,
    +                status=status,
    +                ret_type=RetType.OBJ,
    +                query_params=query_params,
    +                append_suffix=append_suffix,
    +            )
    +            assert_equal(response.getheader("Cache-Control"), no_store)
    +            response.read()
    +
             # Immutable endpoints
             assert_cache_control(f"/block/{blockhash}", immutable, req_type=ReqType.BIN)
             assert_cache_control(f"/spenttxouts/{blockhash}", immutable)
    @@ -587,8 +601,16 @@ class RESTTest (BitcoinTestFramework):
             assert_cache_control(cache_utxo_path, no_store, req_type=ReqType.BIN)
             assert_cache_control(cache_utxo_path, no_store, req_type=ReqType.HEX)
     
    -        # Error responses should not be cached
    -        assert_cache_control(f"/block/{UNKNOWN_PARAM}", no_store, status=404)
    +        self.log.info("Cache-Control on REST error responses (no-store)")
    +        # Unknown format suffix leaves hashStr unparsed (like "<hex>.invalid") → 400 Invalid hash.
    +        assert_rest_error_no_store(f"/block/{blockhash}.invalid", 400, append_suffix=False)
    +        assert_rest_error_no_store(f"/tx/{INVALID_PARAM}", 400)
    +        assert_rest_error_no_store(f"/deploymentinfo/{INVALID_PARAM}", 400)
    +        assert_rest_error_no_store("/blockhashbyheight/999999999", 404)
    +        assert_rest_error_no_store(f"/block/{UNKNOWN_PARAM}", 404)
    +        assert_rest_error_no_store(f"/tx/{'f' * 64}", 404)
    +        assert_rest_error_no_store("/mempool/not-a-valid-path", 400)
    +        assert_rest_error_no_store(f"/deploymentinfo/{non_existing_blockhash}", 400)
    

    </details>


    w0xlt commented at 1:51 AM on May 22, 2026:

    Done. Thanks.

  33. in src/rest.cpp:81 in 42a5ae62d7
      76 | +{
      77 | +    req->WriteHeader("Cache-Control", GetCacheControlValue(cache_control));
      78 | +}
      79 | +
      80 | +/** Block JSON responses include tip-dependent fields such as confirmations. */
      81 | +static RESTCacheControl BlockCacheControl(RESTResponseFormat rf)
    


    stickies-v commented at 11:50 AM on May 12, 2026:

    I don't see the benefit of these ...CacheControl helpers, they're verbose and I don't see what they add? Inlining them is more readable and imo less brittle by keeping the values close to where the response is generated.


    w0xlt commented at 1:51 AM on May 22, 2026:

    Done. Thanks.

  34. in src/rest.cpp:55 in 42a5ae62d7
      50 | + */
      51 | +static constexpr const char* REST_CACHE_IMMUTABLE = "public, max-age=86400, immutable";
      52 | +static constexpr const char* REST_CACHE_NO_STORE = "no-store";
      53 | +static constexpr const char* REST_CACHE_SHORT = "public, max-age=10";
      54 | +
      55 | +enum class RESTCacheControl {
    


    stickies-v commented at 11:56 AM on May 12, 2026:

    I don't think an enum is well suited when there are so many directives, different endpoint should be able to optimally configure their cache control.

    What about using a struct like below?

    class RESTCacheControl {
    public:
        static RESTCacheControl Immutable(std::chrono::seconds max_age)
        {
            return {strprintf("public, max-age=%d, immutable", max_age.count())};
        }
        static RESTCacheControl Public(std::chrono::seconds max_age)
        {
            return {strprintf("public, max-age=%d", max_age.count())};
        }
        static RESTCacheControl NoStore()
        {
            return {"no-store"};
        }
    
        std::string_view value() const { return m_value; }
    
    private:
        explicit RESTCacheControl(std::string value) : m_value{std::move(value)} {}
        std::string m_value;
    };
    

    w0xlt commented at 1:51 AM on May 22, 2026:

    Done. Thanks.

  35. stickies-v commented at 12:49 PM on May 12, 2026: contributor

    That said, removing the short tier can be a reasonable direction if the goal is to avoid serving stale mutable REST state by default, although a 10-second cache may still be useful for heavily polled endpoints.

    I think that is the better approach, yes. It's a much simpler interface if we simple provide cache control guarantees where we actually can, and let the user decide/implement whenever we can't. It avoids bikeshedding and confusion.

    For mutable responses, we could optionally/selectively add an ETag header, so clients can send If-None-Match. I think we can selectively implement this on costlier endpoints where it makes sense, and doesn't need to be done in this PR.

    In my view, that means:

    • errors and rapidly changing: "no-store"
    • mutable: "no-cache, must-revalidate" (+ optional ETag)
    • immutable: "public, immutable, max-age=86400"
  36. 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: confirmed /block binary and hex responses, /blockfilter and
      /spenttxouts in all formats, and blockhash-specific
      /deploymentinfo/<blockhash>.json responses return
      "public, immutable, max-age=86400".
    - Mutable: /block and /block/notxdetails JSON, confirmed /tx in all
      formats, /headers, /blockfilterheaders, and /blockhashbyheight return
      "no-cache, must-revalidate".
    - No-store: mempool /tx responses, /chaininfo, /mempool, /getutxos,
      tip-relative /deploymentinfo.json, and RESTERR error responses return
      "no-store".
    e173fee06a
  37. http: add no-store to unmatched REST 404s cc77ca1983
  38. doc: document REST cache-control defaults d2a3b2a10f
  39. w0xlt force-pushed on May 22, 2026
  40. w0xlt commented at 1:51 AM on May 22, 2026: contributor

    Since the latest review rounds, I changed the PR as follows:

    • Removed the short-lived public, max-age=10 tier. Suggested by @stickies-v, building on @andrewtoth’s concern about serving stale mutable state. Mutable REST responses now use Cache-Control: no-cache, must-revalidate.

    • Simplified the cache-control implementation. Suggested by @stickies-v. Removed the RESTCacheControl enum and endpoint policy helper functions. The code now keeps three policy constants and writes the selected policy directly at each response site.

    • Made confirmed /tx responses mutable. /rest/tx/<txid> returns witness serialization, but the URL is keyed by txid, which does not commit to witness data. Confirmed /tx bin/hex/json now use no-cache, must-revalidate.

    • Expanded REST error-response test coverage. Suggested by @b-l-u-e. The functional test now checks several RESTERR() paths and verifies they return Cache Control: no-store.

    • Covered unmatched REST 404s. Requests that bypass RESTERR(), such as /rest/tx, /rest/does-not-exist, and /rest?x=1, now also return Cache-Control: no-store.

    • Updated docs and proxy examples. The policy table now matches the code, and the Nginx/Caddy examples avoid caching REST error responses.

  41. in src/rest.cpp:55 in d2a3b2a10f
      50 | + */
      51 | +static constexpr const char* REST_CACHE_IMMUTABLE = "public, immutable, max-age=86400";
      52 | +static constexpr const char* REST_CACHE_MUTABLE = "no-cache, must-revalidate";
      53 | +static constexpr const char* REST_CACHE_NO_STORE = "no-store";
      54 | +
      55 | +static void WriteCacheControlHeader(HTTPRequest* req, const char* cache_control)
    


    stickies-v commented at 9:45 AM on May 22, 2026:

    Would inline this too. All other headers get written through req->WriteHeader, so the inconsistency is weird and makes this less readable.

  42. in src/rest.cpp:51 in d2a3b2a10f
      43 | @@ -44,6 +44,19 @@ using util::SplitString;
      44 |  static const size_t MAX_GETUTXOS_OUTPOINTS = 15; //allow a max of 15 outpoints to be queried at once
      45 |  static constexpr unsigned int MAX_REST_HEADERS_RESULTS = 2000;
      46 |  
      47 | +/** Cache-Control header values for REST responses.
      48 | + * Keep immutable responses on a conservative freshness window so caches do not
      49 | + * pin older response shapes across software upgrades longer than necessary.
      50 | + */
      51 | +static constexpr const char* REST_CACHE_IMMUTABLE = "public, immutable, max-age=86400";
    


    stickies-v commented at 9:53 AM on May 22, 2026:

    nit: I expect many contributors to the REST API to not be intimately familiar with cache control, and its directives. The names are clear already, but I think adding hints on how they should be used might limit future review cycles.

    Possible alternative:

    <details> <summary>git diff on d2a3b2a10f</summary>

    diff --git a/src/rest.cpp b/src/rest.cpp
    index f1e2436966..898058b42f 100644
    --- a/src/rest.cpp
    +++ b/src/rest.cpp
    @@ -44,12 +44,13 @@ 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 header values for REST responses.
    - * Keep immutable responses on a conservative freshness window so caches do not
    - * pin older response shapes across software upgrades longer than necessary.
    - */
    +// Cache-Control values for REST responses.
    +
    +/** Response bytes never change. One-day TTL limits staleness across software upgrades in case schema changes. */
     static constexpr const char* REST_CACHE_IMMUTABLE = "public, immutable, max-age=86400";
    +/** Response may change; caches must revalidate before reuse. */
     static constexpr const char* REST_CACHE_MUTABLE = "no-cache, must-revalidate";
    +/** Rapidly changing node-local state or errors; must not be cached. */
     static constexpr const char* REST_CACHE_NO_STORE = "no-store";
     
     static void WriteCacheControlHeader(HTTPRequest* req, const char* cache_control)
    
    

    </details>

  43. in src/rest.cpp:258 in d2a3b2a10f
     254 | @@ -241,6 +255,7 @@ static bool rest_headers(const std::any& context,
     255 |              ssHeader << pindex->GetBlockHeader();
     256 |          }
     257 |  
     258 | +        WriteCacheControlHeader(req, REST_CACHE_MUTABLE);
    


    stickies-v commented at 11:08 AM on May 22, 2026:

    nit: I think it's not immediately obvious why this is mutable, given that a headers chain is immutable, so I'd document that (+2 instances below, and for blockfilterheaders and blockhashbyheight).

            WriteCacheControlHeader(req, REST_CACHE_MUTABLE);  # response depends on the requested hash being in the active chain
    
  44. in src/rest.cpp:779 in d2a3b2a10f
     775 | @@ -746,6 +776,7 @@ static bool rest_deploymentinfo(const std::any& context, HTTPRequest* req, const
     776 |  
     777 |      std::string hash_str;
     778 |      const RESTResponseFormat rf = ParseDataFormat(hash_str, str_uri_part);
     779 | +    const bool current_tip{hash_str.empty()};
    


    stickies-v commented at 11:17 AM on May 22, 2026:

    nit: better to also use this var in if (!hash_str.empty()) a few lines down

  45. in src/rest.cpp:901 in d2a3b2a10f
     896 |      switch (rf) {
     897 |      case RESTResponseFormat::BINARY: {
     898 |          DataStream ssTx;
     899 |          ssTx << TX_WITH_WITNESS(tx);
     900 |  
     901 | +        WriteCacheControlHeader(req, confirmed ? REST_CACHE_MUTABLE : REST_CACHE_NO_STORE);
    


    stickies-v commented at 11:40 AM on May 22, 2026:

    nit: similarly, I think the cache control here is non-trivial and would benefit from documentation. My understanding is: the endpoint is txid based, so unconfirmed transactions can easily be replaced (e.g. RBF) and should be considered ephemeral. For confirmed transactions: the block in which they're confirmed can be re-orged out, re-introducing the replacement concern. Also, the response commits to the blockhash in which it's confirmed.

    (+ 2 other paths below)

  46. in doc/REST-interface.md:18 in d2a3b2a10f
      11 | @@ -12,6 +12,44 @@ REST Interface consistency guarantees
      12 |  The [same guarantees as for the RPC Interface](/doc/JSON-RPC-interface.md#rpc-consistency-guarantees)
      13 |  apply.
      14 |  
      15 | +Default HTTP caching
      16 | +--------------------
      17 | +
      18 | +REST responses include `Cache-Control` headers by default:
    


    stickies-v commented at 12:58 PM on May 22, 2026:

    A few suggested nits, feel free to pick and choose:

    <details> <summary>git diff on d2a3b2a10f</summary>

    diff --git a/doc/REST-interface.md b/doc/REST-interface.md
    index 7376f8aa32..50cf7070bd 100644
    --- a/doc/REST-interface.md
    +++ b/doc/REST-interface.md
    @@ -17,16 +17,16 @@ Default HTTP caching
     
     REST responses include `Cache-Control` headers by default:
     
    -* `public, immutable, max-age=86400` for confirmed `/block` binary and hex
    -  responses, `/blockfilter` and `/spenttxouts` in all formats, and
    -  `/deploymentinfo/<BLOCKHASH>.json`.
    +* `public, immutable, max-age=86400` for `/block` binary and hex responses,
    +  `/blockpart`, `/blockfilter` and `/spenttxouts` in all formats, and
    +  `/deploymentinfo/<BLOCKHASH>.json`. The TTL is deliberately short so
    +  caches do not hold older response shapes across software upgrades.
     * `no-cache, must-revalidate` for `/block` and `/block/notxdetails` JSON,
    -  confirmed `/tx`, `/headers`, `/blockfilterheaders`, and `/blockhashbyheight`.
    +  confirmed `/tx`, `/headers`, `/blockfilterheaders`, and `/blockhashbyheight`:
    +  responses whose content depends on active chain state and can change with
    +  new blocks or reorgs.
     * `no-store` for mempool `/tx`, `/chaininfo`, `/mempool`, `/getutxos`,
    -  `/deploymentinfo.json`, and error responses.
    -
    -The immutable TTL is intentionally conservative so caches do not hold older
    -response shapes across software upgrades for more than a day.
    +  `/deploymentinfo.json`, and all error responses.
     
     If you front `bitcoind` with a reverse proxy or CDN, you can override these
     defaults there. Keep overrides scoped to responses you know are safe to cache
    
    

    </details>

  47. in doc/REST-interface.md:20 in d2a3b2a10f
      11 | @@ -12,6 +12,44 @@ REST Interface consistency guarantees
      12 |  The [same guarantees as for the RPC Interface](/doc/JSON-RPC-interface.md#rpc-consistency-guarantees)
      13 |  apply.
      14 |  
      15 | +Default HTTP caching
      16 | +--------------------
      17 | +
      18 | +REST responses include `Cache-Control` headers by default:
      19 | +
      20 | +* `public, immutable, max-age=86400` for confirmed `/block` binary and hex
    


    stickies-v commented at 12:59 PM on May 22, 2026:

    nit: we never cache on errors, so "confirmed' here seems unnecessary and confusing

  48. stickies-v approved
  49. stickies-v commented at 1:00 PM on May 22, 2026: contributor

    ACK d2a3b2a10f64e1116daa86cbaeade6d9d5287397 once release notes are added

  50. DrahtBot requested review from optout21 on May 22, 2026

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-05-23 16:51 UTC

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