Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,18 @@ The following emojis are used to highlight certain changes:

### Added

- `gateway`: added `WithMaxTraversalDepth`, bounding how deep `BlocksBackend` descends into a DAG while serving CAR responses. Traversal keeps per-level state, so its cost grows with depth. On by default at `DefaultMaxTraversalDepth` (1024), well above anything UnixFS produces: a file reaches terabytes by depth 4, and HAMT adds about 4 levels per million directory entries. Pass a positive value to set your own limit, or `WithMaxTraversalDepth(0)` to remove it entirely. [#1197](https://github.com/ipfs/boxo/pull/1197)
- `gateway`: added `WithMaxTraversalDepth`. It limits how deep `BlocksBackend` descends into a DAG while it serves a CAR response. Traversal keeps per-level state, so its cost grows with depth. The limit is on by default at `DefaultMaxTraversalDepth` (1024), well above anything UnixFS produces. A file reaches terabytes by depth 4, and a HAMT adds about 4 levels per million directory entries. Pass a positive value to set your own limit, or `WithMaxTraversalDepth(0)` to remove the limit. [#1197](https://github.com/ipfs/boxo/pull/1197)

### Changed

- `gateway`: a CAR response that fails partway through now ends with `[Gateway Error: CAR stream truncated, response is incomplete]`, the same approach `withRetrievalTimeout` already uses when it cuts a response short. `X-Stream-Error` is only set once the body is streaming, so it rarely reaches the client, and a truncated CAR was otherwise indistinguishable from a complete one. The marker makes the trailing bytes invalid CAR, so a reader stops with an error instead of accepting a short DAG. Mostly this is a quality of life improvement for operators: gateways usually sit behind reverse proxies and third-party CDNs, and when a response arrives short it is hard to tell which hop dropped it. Now the response says so itself. [#1197](https://github.com/ipfs/boxo/pull/1197)
- `gateway`: a CAR response that fails partway through now ends with `[Gateway Error: CAR stream truncated, response is incomplete]`. `withRetrievalTimeout` already uses the same marker when it cuts a response short. The gateway sets `X-Stream-Error` only once the body is streaming, so that header rarely reaches the client. A truncated CAR was otherwise indistinguishable from a complete one. The marker makes the trailing bytes invalid CAR, so a reader stops with an error instead of accepting a short DAG. This mostly helps operators. Gateways usually sit behind reverse proxies and third-party CDNs, so a short response leaves you guessing which hop cut it. Now the response says so itself. [#1197](https://github.com/ipfs/boxo/pull/1197)
- `routing/http/server`: `/routing/v1` responses no longer let a cache serve a two-day-old answer while the origin is healthy. Peer addresses in routing results come from short-lived sources such as relay reservations. A stale window measured in days handed clients addresses that had stopped working long ago. `stale-while-revalidate` is now 10 minutes for responses with results, and 1 minute for empty ones. That covers a background refresh. `stale-if-error` applies only when the origin is failing, so responses with results keep the 48h Amino DHT expiration window. For empty responses it is 1 hour. `max-age` is unchanged. [#1195](https://github.com/ipfs/boxo/pull/1195)

### Removed

### Fixed

- `bitswap/network`: `ExtractHTTPAddress` now brackets IPv6 literals when building the provider URL, so peers announcing `/ip6/<addr>/tcp/443/tls/http` are usable as HTTP providers. Without brackets the address either failed to parse (Go 1.26 and later) or was split at the last colon into a bogus host and port.
- `bitswap/network`: `ExtractHTTPAddress` now brackets an IPv6 literal when it builds the provider URL. A peer that announces `/ip6/<addr>/tcp/443/tls/http` is now usable as an HTTP provider. Without brackets, `url.Parse` rejected the address under Go 1.26 and later, and the peer was skipped. On earlier versions it parsed, but the authority split at the last colon, so the client dialed a host and port that do not exist. [#1196](https://github.com/ipfs/boxo/pull/1196)

### Security

Expand Down
46 changes: 34 additions & 12 deletions routing/http/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -530,7 +530,7 @@ func (s *server) GetIPNS(w http.ResponseWriter, r *http.Request) {
if err != nil {
if errors.Is(err, routing.ErrNotFound) {
// Per IPIP-0513: Return 200 with text/plain to indicate no record found
setCacheControl(w, maxAgeWithoutResults, maxStale)
setCacheControlWithoutResults(w)
http.Error(w, fmt.Sprintf("delegate error: %s", err), http.StatusOK)
return
} else {
Expand Down Expand Up @@ -686,11 +686,24 @@ func (s *server) getClosestPeersNDJSON(w http.ResponseWriter, peersIter iter.Res
var (
// Rule-of-thumb Cache-Control policy is to work well with caching proxies and load balancers.
// If there are any results, cache on the client for longer, and hint any in-between caches to
// serve cached result and upddate cache in background as long we have
// result that is within Amino DHT expiration window
// serve cached result and update cache in background.
maxAgeWithResults = int((5 * time.Minute).Seconds()) // cache >0 results for longer
maxAgeWithoutResults = int((15 * time.Second).Seconds()) // cache no results briefly
maxStale = int((48 * time.Hour).Seconds()) // allow stale results as long within Amino DHT Expiration window

// stale-while-revalidate is served while the origin is healthy, so it only
// needs to cover a background refresh. Routing results churn much faster
// than the Amino DHT provider record expiration: the peer addresses in them
// come from short-lived sources such as relay reservations, so a window
// measured in days hands clients addresses that stopped working long ago.
staleWhileRevalidateWithResults = int((10 * time.Minute).Seconds())
staleWhileRevalidateWithoutResults = int((1 * time.Minute).Seconds())

// stale-if-error only applies when the origin is failing, where a stale
// answer beats no answer. Results stay usable as long as they are within the
// Amino DHT expiration window. An empty answer is worth little, so it is not
// held nearly as long.
staleIfErrorWithResults = int((48 * time.Hour).Seconds())
staleIfErrorWithoutResults = int((1 * time.Hour).Seconds())
)

func parsePeerID(pidStr string) (peer.ID, error) {
Expand Down Expand Up @@ -748,8 +761,16 @@ func parseKey(keyStr string) (cid.Cid, error) {
return cid.Cid{}, fmt.Errorf("unable to parse as CID or PeerID: %w", errors.Join(cidErr, pidErr))
}

func setCacheControl(w http.ResponseWriter, maxAge int, stale int) {
w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d, stale-while-revalidate=%d, stale-if-error=%d", maxAge, stale, stale))
func setCacheControl(w http.ResponseWriter, maxAge, staleWhileRevalidate, staleIfError int) {
w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d, stale-while-revalidate=%d, stale-if-error=%d", maxAge, staleWhileRevalidate, staleIfError))
}

func setCacheControlWithResults(w http.ResponseWriter) {
setCacheControl(w, maxAgeWithResults, staleWhileRevalidateWithResults, staleIfErrorWithResults)
}

func setCacheControlWithoutResults(w http.ResponseWriter) {
setCacheControl(w, maxAgeWithoutResults, staleWhileRevalidateWithoutResults, staleIfErrorWithoutResults)
}

// setIPNSCacheControl sets Cache-Control for an IPNS record response. An IPNS
Expand All @@ -767,17 +788,18 @@ func setIPNSCacheControl(w http.ResponseWriter, ttl int, remainingValidity int)
return
}
maxAge := min(max(0, ttl), remainingValidity)
setCacheControl(w, maxAge, remainingValidity-maxAge)
stale := remainingValidity - maxAge
setCacheControl(w, maxAge, stale, stale)
}

func writeJSONResult(w http.ResponseWriter, method string, val interface{ Length() int }) {
w.Header().Add("Content-Type", mediaTypeJSON)
w.Header().Add("Vary", "Accept")

if val.Length() > 0 {
setCacheControl(w, maxAgeWithResults, maxStale)
setCacheControlWithResults(w)
} else {
setCacheControl(w, maxAgeWithoutResults, maxStale)
setCacheControlWithoutResults(w)
}
w.Header().Set("Last-Modified", time.Now().UTC().Format(http.TimeFormat))

Expand All @@ -800,7 +822,7 @@ func writeJSONResult(w http.ResponseWriter, method string, val interface{ Length

func writeErr(w http.ResponseWriter, method string, statusCode int, cause error) {
if errors.Is(cause, routing.ErrNotFound) {
setCacheControl(w, maxAgeWithoutResults, maxStale)
setCacheControlWithoutResults(w)
}

w.WriteHeader(statusCode)
Expand Down Expand Up @@ -844,7 +866,7 @@ func writeResultsIterNDJSON[T types.Record](w http.ResponseWriter, resultIter it
if !hasResults {
hasResults = true
// There's results, cache useful result for longer
setCacheControl(w, maxAgeWithResults, maxStale)
setCacheControlWithResults(w)
}

_, err = w.Write(b)
Expand All @@ -866,7 +888,7 @@ func writeResultsIterNDJSON[T types.Record](w http.ResponseWriter, resultIter it

if !hasResults {
// There weren't results, cache for shorter but still send 200 per IPIP-0513
setCacheControl(w, maxAgeWithoutResults, maxStale)
setCacheControlWithoutResults(w)
w.WriteHeader(http.StatusOK)
}
}
30 changes: 15 additions & 15 deletions routing/http/server/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,9 +179,9 @@ func TestProviders(t *testing.T) {
require.Equal(t, "Accept", resp.Header.Get("Vary"))

if empty {
require.Equal(t, "public, max-age=15, stale-while-revalidate=172800, stale-if-error=172800", resp.Header.Get("Cache-Control"))
require.Equal(t, "public, max-age=15, stale-while-revalidate=60, stale-if-error=3600", resp.Header.Get("Cache-Control"))
} else {
require.Equal(t, "public, max-age=300, stale-while-revalidate=172800, stale-if-error=172800", resp.Header.Get("Cache-Control"))
require.Equal(t, "public, max-age=300, stale-while-revalidate=600, stale-if-error=172800", resp.Header.Get("Cache-Control"))
}
requireCloseToNow(t, resp.Header.Get("Last-Modified"))

Expand Down Expand Up @@ -543,7 +543,7 @@ func TestPeers(t *testing.T) {

require.Equal(t, mediaTypeJSON, resp.Header.Get("Content-Type"))
require.Equal(t, "Accept", resp.Header.Get("Vary"))
require.Equal(t, "public, max-age=15, stale-while-revalidate=172800, stale-if-error=172800", resp.Header.Get("Cache-Control"))
require.Equal(t, "public, max-age=15, stale-while-revalidate=60, stale-if-error=3600", resp.Header.Get("Cache-Control"))

requireCloseToNow(t, resp.Header.Get("Last-Modified"))

Expand Down Expand Up @@ -646,7 +646,7 @@ func TestPeers(t *testing.T) {

require.Equal(t, mediaTypeJSON, resp.Header.Get("Content-Type"))
require.Equal(t, "Accept", resp.Header.Get("Vary"))
require.Equal(t, "public, max-age=300, stale-while-revalidate=172800, stale-if-error=172800", resp.Header.Get("Cache-Control"))
require.Equal(t, "public, max-age=300, stale-while-revalidate=600, stale-if-error=172800", resp.Header.Get("Cache-Control"))

requireCloseToNow(t, resp.Header.Get("Last-Modified"))

Expand Down Expand Up @@ -698,7 +698,7 @@ func TestPeers(t *testing.T) {

require.Equal(t, mediaTypeJSON, resp.Header.Get("Content-Type"))
require.Equal(t, "Accept", resp.Header.Get("Vary"))
require.Equal(t, "public, max-age=300, stale-while-revalidate=172800, stale-if-error=172800", resp.Header.Get("Cache-Control"))
require.Equal(t, "public, max-age=300, stale-while-revalidate=600, stale-if-error=172800", resp.Header.Get("Cache-Control"))

requireCloseToNow(t, resp.Header.Get("Last-Modified"))

Expand Down Expand Up @@ -750,7 +750,7 @@ func TestPeers(t *testing.T) {

require.Equal(t, mediaTypeJSON, resp.Header.Get("Content-Type"))
require.Equal(t, "Accept", resp.Header.Get("Vary"))
require.Equal(t, "public, max-age=300, stale-while-revalidate=172800, stale-if-error=172800", resp.Header.Get("Cache-Control"))
require.Equal(t, "public, max-age=300, stale-while-revalidate=600, stale-if-error=172800", resp.Header.Get("Cache-Control"))

requireCloseToNow(t, resp.Header.Get("Last-Modified"))

Expand All @@ -776,7 +776,7 @@ func TestPeers(t *testing.T) {

require.Equal(t, mediaTypeNDJSON, resp.Header.Get("Content-Type"))
require.Equal(t, "Accept", resp.Header.Get("Vary"))
require.Equal(t, "public, max-age=15, stale-while-revalidate=172800, stale-if-error=172800", resp.Header.Get("Cache-Control"))
require.Equal(t, "public, max-age=15, stale-while-revalidate=60, stale-if-error=3600", resp.Header.Get("Cache-Control"))

requireCloseToNow(t, resp.Header.Get("Last-Modified"))

Expand Down Expand Up @@ -814,7 +814,7 @@ func TestPeers(t *testing.T) {

require.Equal(t, mediaTypeNDJSON, resp.Header.Get("Content-Type"))
require.Equal(t, "Accept", resp.Header.Get("Vary"))
require.Equal(t, "public, max-age=300, stale-while-revalidate=172800, stale-if-error=172800", resp.Header.Get("Cache-Control"))
require.Equal(t, "public, max-age=300, stale-while-revalidate=600, stale-if-error=172800", resp.Header.Get("Cache-Control"))

body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
Expand Down Expand Up @@ -876,7 +876,7 @@ func TestPeers(t *testing.T) {

require.Equal(t, mediaTypeNDJSON, resp.Header.Get("Content-Type"))
require.Equal(t, "Accept", resp.Header.Get("Vary"))
require.Equal(t, "public, max-age=300, stale-while-revalidate=172800, stale-if-error=172800", resp.Header.Get("Cache-Control"))
require.Equal(t, "public, max-age=300, stale-while-revalidate=600, stale-if-error=172800", resp.Header.Get("Cache-Control"))

body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
Expand All @@ -896,7 +896,7 @@ func TestPeers(t *testing.T) {

require.Equal(t, mediaTypeJSON, resp.Header.Get("Content-Type"))
require.Equal(t, "Accept", resp.Header.Get("Vary"))
require.Equal(t, "public, max-age=300, stale-while-revalidate=172800, stale-if-error=172800", resp.Header.Get("Cache-Control"))
require.Equal(t, "public, max-age=300, stale-while-revalidate=600, stale-if-error=172800", resp.Header.Get("Cache-Control"))

body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
Expand Down Expand Up @@ -1020,7 +1020,7 @@ func TestGetClosestPeers(t *testing.T) {

require.Equal(t, mediaTypeJSON, resp.Header.Get("Content-Type"))
require.Equal(t, "Accept", resp.Header.Get("Vary"))
require.Equal(t, "public, max-age=15, stale-while-revalidate=172800, stale-if-error=172800", resp.Header.Get("Cache-Control"))
require.Equal(t, "public, max-age=15, stale-while-revalidate=60, stale-if-error=3600", resp.Header.Get("Cache-Control"))

requireCloseToNow(t, resp.Header.Get("Last-Modified"))
})
Expand Down Expand Up @@ -1106,7 +1106,7 @@ func TestGetClosestPeers(t *testing.T) {

require.Equal(t, mediaTypeJSON, resp.Header.Get("Content-Type"))
require.Equal(t, "Accept", resp.Header.Get("Vary"))
require.Equal(t, "public, max-age=300, stale-while-revalidate=172800, stale-if-error=172800", resp.Header.Get("Cache-Control"))
require.Equal(t, "public, max-age=300, stale-while-revalidate=600, stale-if-error=172800", resp.Header.Get("Cache-Control"))

requireCloseToNow(t, resp.Header.Get("Last-Modified"))

Expand All @@ -1133,7 +1133,7 @@ func TestGetClosestPeers(t *testing.T) {

require.Equal(t, mediaTypeNDJSON, resp.Header.Get("Content-Type"))
require.Equal(t, "Accept", resp.Header.Get("Vary"))
require.Equal(t, "public, max-age=15, stale-while-revalidate=172800, stale-if-error=172800", resp.Header.Get("Cache-Control"))
require.Equal(t, "public, max-age=15, stale-while-revalidate=60, stale-if-error=3600", resp.Header.Get("Cache-Control"))

requireCloseToNow(t, resp.Header.Get("Last-Modified"))
})
Expand Down Expand Up @@ -1166,7 +1166,7 @@ func TestGetClosestPeers(t *testing.T) {

require.Equal(t, mediaTypeNDJSON, resp.Header.Get("Content-Type"))
require.Equal(t, "Accept", resp.Header.Get("Vary"))
require.Equal(t, "public, max-age=300, stale-while-revalidate=172800, stale-if-error=172800", resp.Header.Get("Cache-Control"))
require.Equal(t, "public, max-age=300, stale-while-revalidate=600, stale-if-error=172800", resp.Header.Get("Cache-Control"))

body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
Expand Down Expand Up @@ -1235,7 +1235,7 @@ func TestGetClosestPeers(t *testing.T) {

require.Equal(t, mediaTypeJSON, resp.Header.Get("Content-Type"))
require.Equal(t, "Accept", resp.Header.Get("Vary"))
require.Equal(t, "public, max-age=300, stale-while-revalidate=172800, stale-if-error=172800", resp.Header.Get("Cache-Control"))
require.Equal(t, "public, max-age=300, stale-while-revalidate=600, stale-if-error=172800", resp.Header.Get("Cache-Control"))

body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
Expand Down
Loading