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

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

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

    Closes #33809

    Cache policy summary:

    • Immutable: confirmed /block and /tx binary and hex responses, plus /blockfilter and /spenttxouts in all formats, return Cache-Control: public, max-age=31536000, immutable.

    • Short-lived: /block and /block/notxdetails JSON, confirmed /tx JSON, /headers, /blockfilterheaders, and /blockhashbyheight return Cache-Control: public, max-age=10. /headers and /blockfilterheaders remain short-lived even though they are hash-addressed, because near-tip and count-based responses can change across reorgs and as new blocks are added.

    • No-store: mempool /tx responses, /chaininfo, /mempool, /getutxos, /deploymentinfo, and all error responses return Cache-Control: no-store.

    This gives operators correct default caching behavior for REST responses out of the box, while avoiding long-lived caching for responses that can change with chain state.

  2. DrahtBot added the label RPC/REST/ZMQ on Mar 11, 2026
  3. 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, optout21

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

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

  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 ?

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

     0# nginx
     1location ~ ^/rest/block/[^/]+\.(bin|hex)$ {
     2  proxy_pass http://127.0.0.1:8332;
     3  proxy_hide_header Cache-Control;
     4  add_header Cache-Control "public, max-age=31536000, immutable" always;
     5}
     6
     7# Caddy
     8[@block](/bitcoin-bitcoin/contributor/block/)_raw path_regexp ^/rest/block/[^/]+\.(bin|hex)$
     9handle [@block](/bitcoin-bitcoin/contributor/block/)_raw {
    10  reverse_proxy 127.0.0.1:8332 {
    11      header_down Cache-Control "public, max-age=31536000, immutable"
    12  }
    13}
    

    (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
    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. rest: add Cache-Control headers to REST responses
    Add Cache-Control headers to REST API responses so standard HTTP caches
    (Nginx, CDNs, browsers) can cache safe responses by default without
    per-deployment proxy rules.
    
    Cache policy summary:
    - Immutable: confirmed /block and /tx binary and hex responses, plus
      /blockfilter and /spenttxouts in all formats, return
      "public, max-age=86400, immutable". Blockhash-specific
      /deploymentinfo/<blockhash>.json responses are also immutable.
    - Short-lived: /block and /block/notxdetails JSON, confirmed /tx JSON,
      /headers, /blockfilterheaders, and /blockhashbyheight return
      "public, max-age=10". /headers and /blockfilterheaders stay
      short-lived because near-tip and count-based results can change across
      reorgs and new blocks.
    - No-store: mempool /tx responses, /chaininfo, /mempool, /getutxos,
      tip-relative /deploymentinfo.json, and all error responses return
      "no-store".
    27a1f509f8
  28. doc: document REST cache-control defaults 42a5ae62d7
  29. w0xlt force-pushed on Mar 25, 2026
  30. 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.


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-04-12 03:13 UTC

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