Repository: ipfs/boxo

Issue:
Delegated routing v1 client: response status metrics
I believe we are missing response status metrics for routing v1 client. Particularly I don't think it is possible to see if an endpoint is 500'ing. There's a proxy metric which is the "number of results" returned, but would have to study if we log `0` for http-failure responses.

Discussion:
@stktyagi: Hi @hsanjuan, I’d like to work on this issue.
I’ll look into how metrics are currently being logged in the delegated routing v1 client, and check if there’s already a way to capture status codes or if we need to add new ones. From there, I can suggest an approach to expose response statuses more clearly.@hsanjuan: Am I right?@stktyagi: with respect to what i have seen in the code base
yes
@hsanjuan: Triage:
  * #1024 will address this as it will include new metrics (see CHANGELOG.md in that PR)@lidel: To clarify:
- OpenCensus metrics tag responses with status codes 
  - Status codes are recorded as string tags (e.g., "200", "404", "500")
  - These are attached to latency and length measurements
    - Example: Filter by host and status: `routing_http_client_latency_count{host="example.com", code="500"}`
- This issue will be closed once https://github.com/ipfs/boxo/pull/1032 is merged, but if anyone reads this and finds we need more metrics/more resolution, please fill new one.


Solution patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d094be60..805bbe88 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -23,6 +23,10 @@ The following emojis are used to highlight certain changes:
 
 ### Changed
 
+- `routing/http`: ✨ Delegated Routing V1 HTTP endpoints now return 200 with empty results instead of 404 when no records are found, per [IPIP-513](https://github.com/ipfs/specs/pull/513) ([#1024](https://github.com/ipfs/boxo/issues/1024))
+  - Server endpoints (`/routing/v1/providers/{cid}`, `/routing/v1/peers/{peer-id}`, `/routing/v1/ipns/{name}`) return HTTP 200 with empty JSON arrays or appropriate content types for empty results
+  - Client maintains backward compatibility by treating both 200 with empty results and 404 as "no records found"
+  - IPNS endpoint distinguishes between valid records (Content-Type: `application/vnd.ipfs.ipns-record`) and no record found (any other content type)
 - `verifcid`: 🛠 Enhanced Allowlist interface with per-hash size limits ([#1018](https://github.com/ipfs/boxo/pull/1018))
   - Expanded `Allowlist` interface with `MinDigestSize(code uint64)` and `MaxDigestSize(code uint64)` methods for per-hash function size validation
   - Added public constants: `DefaultMinDigestSize` (20 bytes), `DefaultMaxDigestSize` (128 bytes for cryptographic hashes), and `DefaultMaxIdentityDigestSize` (128 bytes for identity CIDs)
@@ -37,6 +41,12 @@ The following emojis are used to highlight certain changes:
 
 ### Fixed
 
+- `routing/http/client`:
+  - Fixed off-by-one error in `routing_http_client_length` metric - the metric now correctly reports 0 for empty results instead of 1
+  - Added metrics for IPNS operations (`GetIPNS` and `PutIPNS`) - these now report latency, status code, and result count (0 or 1 for GetIPNS)
+  - Added simple counter metrics to avoid confusing histogram bucket math:
+    - `routing_http_client_requests_total` - total requests including errors
+    - `routing_http_client_positive_responses_total` - requests that returned at least 1 result
 - `ipld/unixfs/mod`:
   - `DagModifier` now correctly preserves raw node codec when modifying data under the chunker threshold, instead of incorrectly forcing everything to dag-pb
   - `DagModifier` prevents creation of identity CIDs exceeding `verifcid.DefaultMaxIdentityDigestSize` limit when modifying data, automatically switching to proper cryptographic hash while preserving small identity CIDs
diff --git a/routing/http/client/client.go b/routing/http/client/client.go
index ebe518f2..29179362 100644
--- a/routing/http/client/client.go
+++ b/routing/http/client/client.go
@@ -27,6 +27,7 @@ import (
 	logging "github.com/ipfs/go-log/v2"
 	"github.com/libp2p/go-libp2p/core/crypto"
 	"github.com/libp2p/go-libp2p/core/peer"
+	"github.com/libp2p/go-libp2p/core/routing"
 	"github.com/multiformats/go-multiaddr"
 )
 
@@ -234,8 +235,11 @@ type measuringIter[T any] struct {
 }
 
 func (c *measuringIter[T]) Next() bool {
-	c.m.length++
-	return c.Iter.Next()
+	hasNext := c.Iter.Next()
+	if hasNext {
+		c.m.length++
+	}
+	return hasNext
 }
 
 func (c *measuringIter[T]) Val() T {
@@ -279,7 +283,10 @@ func (c *Client) FindProviders(ctx context.Context, key cid.Cid) (providers iter
 	}
 
 	m.statusCode = resp.StatusCode
+	// Per IPIP-0513: Handle 404 as empty results for backward compatibility
+	// New servers return 200 with empty results, old servers return 404
 	if resp.StatusCode == http.StatusNotFound {
+		io.Copy(io.Discard, resp.Body) // Drain body for connection reuse
 		resp.Body.Close()
 		m.record(ctx)
 		return iter.FromSlice[iter.Result[types.Record]](nil), nil
@@ -462,7 +469,10 @@ func (c *Client) FindPeers(ctx context.Context, pid peer.ID) (peers iter.ResultI
 	}
 
 	m.statusCode = resp.StatusCode
+	// Per IPIP-0513: Handle 404 as empty results for backward compatibility
+	// New servers return 200 with empty results, old servers return 404
 	if resp.StatusCode == http.StatusNotFound {
+		io.Copy(io.Discard, resp.Body) // Drain body for connection reuse
 		resp.Body.Close()
 		m.record(ctx)
 		return iter.FromSlice[iter.Result[*types.PeerRecord]](nil), nil
@@ -518,7 +528,20 @@ func (c *Client) FindPeers(ctx context.Context, pid peer.ID) (peers iter.ResultI
 // GetIPNS tries to retrieve the [ipns.Record] for the given [ipns.Name]. The record is
 // validated against the given name. If validation fails, an error is returned, but no
 // record.
-func (c *Client) GetIPNS(ctx context.Context, name ipns.Name) (*ipns.Record, error) {
+func (c *Client) GetIPNS(ctx context.Context, name ipns.Name) (record *ipns.Record, err error) {
+	m := newMeasurement("GetIPNS")
+	start := time.Now()
+	defer func() {
+		m.latency = time.Since(start)
+		m.err = err
+		if record != nil {
+			m.length = 1
+		} else {
+			m.length = 0
+		}
+		m.record(ctx)
+	}()
+
 	url := c.baseURL + "/routing/v1/ipns/" + name.String()
 
 	httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
@@ -527,23 +550,42 @@ func (c *Client) GetIPNS(ctx context.Context, name ipns.Name) (*ipns.Record, err
 	}
 	httpReq.Header.Set("Accept", mediaTypeIPNSRecord)
 
+	m.host = httpReq.Host
+
 	resp, err := c.httpClient.Do(httpReq)
 	if err != nil {
 		return nil, fmt.Errorf("making HTTP req to get IPNS record: %w", err)
 	}
 	defer resp.Body.Close()
 
+	m.statusCode = resp.StatusCode
+
+	// Per IPIP-0513: Handle 404 as "no record found" for backward compatibility
+	// New servers return 200 with text/plain for no record, old servers return 404
+	if resp.StatusCode == http.StatusNotFound {
+		io.Copy(io.Discard, resp.Body) // Drain body for connection reuse
+		return nil, routing.ErrNotFound
+	}
+
 	if resp.StatusCode != http.StatusOK {
 		return nil, httpError(resp.StatusCode, resp.Body)
 	}
 
+	// Per IPIP-0513: Only Content-Type: application/vnd.ipfs.ipns-record indicates a valid record
+	// Any other content type (e.g., text/plain) means no record found
+	contentType := resp.Header.Get("Content-Type")
+	if !strings.Contains(contentType, mediaTypeIPNSRecord) {
+		io.Copy(io.Discard, resp.Body) // Drain body for connection reuse
+		return nil, routing.ErrNotFound
+	}
+
 	// Limit the reader to the maximum record size.
 	rawRecord, err := io.ReadAll(io.LimitReader(resp.Body, int64(ipns.MaxRecordSize)))
 	if err != nil {
 		return nil, fmt.Errorf("making HTTP req to get IPNS record: %w", err)
 	}
 
-	record, err := ipns.UnmarshalRecord(rawRecord)
+	record, err = ipns.UnmarshalRecord(rawRecord)
 	if err != nil {
 		return nil, fmt.Errorf("IPNS record from remote endpoint is not valid: %w", err)
 	}
@@ -557,7 +599,16 @@ func (c *Client) GetIPNS(ctx context.Context, name ipns.Name) (*ipns.Record, err
 }
 
 // PutIPNS attempts at putting the given [ipns.Record] for the given [ipns.Name].
-func (c *Client) PutIPNS(ctx context.Context, name ipns.Name, record *ipns.Record) error {
+func (c *Client) PutIPNS(ctx context.Context, name ipns.Name, record *ipns.Record) (err error) {
+	m := newMeasurement("PutIPNS")
+	start := time.Now()
+	defer func() {
+		m.latency = time.Since(start)
+		m.err = err
+		// Don't set m.length for PutIPNS - not meaningful
+		m.record(ctx)
+	}()
+
 	url := c.baseURL + "/routing/v1/ipns/" + name.String()
 
 	rawRecord, err := ipns.MarshalRecord(record)
@@ -571,12 +622,16 @@ func (c *Client) PutIPNS(ctx context.Context, name ipns.Name, record *ipns.Recor
 	}
 	httpReq.Header.Set("Content-Type", mediaTypeIPNSRecord)
 
+	m.host = httpReq.Host
+
 	resp, err := c.httpClient.Do(httpReq)
 	if err != nil {
 		return fmt.Errorf("making HTTP req to get IPNS record: %w", err)
 	}
 	defer resp.Body.Close()
 
+	m.statusCode = resp.StatusCode
+
 	if resp.StatusCode != http.StatusOK {
 		return httpError(resp.StatusCode, resp.Body)
 	}
diff --git a/routing/http/client/measures.go b/routing/http/client/measures.go
index 9cc1911b..1d2677b9 100644
--- a/routing/http/client/measures.go
+++ b/routing/http/client/measures.go
@@ -13,12 +13,45 @@ import (
 )
 
 var (
-	distMS     = view.Distribution(0, 1, 2, 5, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000)
+	// distMS defines the histogram buckets for latency in milliseconds.
+	// Similar to distLength, bucket selection uses value < boundary:
+	//   - 0ms → bucket with bound 1ms
+	//   - 1ms → bucket with bound 2ms
+	//   - 50ms → bucket with bound 100ms
+	//   - etc.
+	//
+	// In Prometheus:
+	//   - routing_http_client_latency_bucket{le="1"} = requests with latency 0ms
+	//   - routing_http_client_latency_bucket{le="100"} = requests with latency <100ms
+	//   - routing_http_client_latency_bucket{le="1000"} = requests with latency <1s
+	distMS = view.Distribution(0, 1, 2, 5, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000)
+	// distLength defines the histogram buckets for result counts.
+	// In OpenCensus, bucket selection uses value < boundary comparisons:
+	//   - Value 0 → bucket with bound 1
+	//   - Value 1 → bucket with bound 2
+	//   - Value 2 → bucket with bound 5
+	//   - etc.
+	//
+	// When exported to Prometheus, these become cumulative "le" (less than) buckets:
+	//   - routing_http_client_length_bucket{le="1"} = count of operations with 0 results
+	//   - routing_http_client_length_bucket{le="2"} = count of operations with 0 or 1 results
+	//   - routing_http_client_length_bucket{le="5"} = count of operations with 0-4 results
+	//
+	// To determine specific counts from Prometheus metrics:
+	//   - Operations with exactly 0 results: bucket{le="1"}
+	//   - Operations with 1+ results: bucket{le="+Inf"} - bucket{le="1"}
+	//   - Operations with exactly 1 result: bucket{le="2"} - bucket{le="1"}
+	//   - Operations with exactly 2-4 results: bucket{le="5"} - bucket{le="2"}
 	distLength = view.Distribution(0, 1, 2, 5, 10, 11, 12, 15, 20, 50, 100, 200, 500)
 
 	measureLatency = stats.Int64("routing_http_client_latency", "the latency of operations by the routing HTTP client", stats.UnitMilliseconds)
 	measureLength  = stats.Int64("routing_http_client_length", "the number of elements in a response collection", stats.UnitDimensionless)
 
+	// Simple counters for tracking requests and positive responses
+	// These avoid the confusing bucket math required for histograms
+	measureRequests          = stats.Int64("routing_http_client_requests", "total number of requests", stats.UnitDimensionless)
+	measurePositiveResponses = stats.Int64("routing_http_client_positive_responses", "requests that returned at least one result", stats.UnitDimensionless)
+
 	keyOperation  = tag.MustNewKey("operation")
 	keyHost       = tag.MustNewKey("host")
 	keyStatusCode = tag.MustNewKey("code")
@@ -35,10 +68,31 @@ var (
 		Aggregation: distLength,
 		TagKeys:     []tag.Key{keyOperation, keyHost},
 	}
+	// ViewRequests counts all requests (including errors)
+	// Simple Grafana queries:
+	//   - Total requests: routing_http_client_requests_total
+	//   - Request rate: rate(routing_http_client_requests_total[5m])
+	ViewRequests = &view.View{
+		Measure:     measureRequests,
+		Aggregation: view.Count(),
+		TagKeys:     []tag.Key{keyOperation, keyHost},
+	}
+	// ViewPositiveResponses counts requests that returned at least 1 result
+	// Simple Grafana queries:
+	//   - Requests with results: routing_http_client_positive_responses_total
+	//   - Success rate: positive_responses_total / requests_total
+	//   - Empty/error rate: (requests_total - positive_responses_total) / requests_total
+	ViewPositiveResponses = &view.View{
+		Measure:     measurePositiveResponses,
+		Aggregation: view.Count(),
+		TagKeys:     []tag.Key{keyOperation, keyHost},
+	}
 
 	OpenCensusViews = []*view.View{
 		ViewLatency,
 		ViewLength,
+		ViewRequests,
+		ViewPositiveResponses,
 	}
 )
 
@@ -62,6 +116,15 @@ func (m measurement) record(ctx context.Context) {
 	}
 	stats.RecordWithTags(ctx, muts, measureLatency.M(m.latency.Milliseconds()))
 	stats.RecordWithTags(ctx, muts, measureLength.M(int64(m.length)))
+
+	// Record simple counters
+	// Always record a request (even on error)
+	stats.RecordWithTags(ctx, muts, measureRequests.M(1))
+
+	// Record positive response if we got results and no error
+	if m.length > 0 && m.err == nil {
+		stats.RecordWithTags(ctx, muts, measurePositiveResponses.M(1))
+	}
 }
 
 func newMeasurement(operation string) *measurement {
diff --git a/routing/http/server/server.go b/routing/http/server/server.go
index 3252f798..7e96d58f 100644
--- a/routing/http/server/server.go
+++ b/routing/http/server/server.go
@@ -295,6 +295,11 @@ func (s *server) findProvidersJSON(w http.ResponseWriter, provIter iter.ResultIt
 		return
 	}
 
+	// Ensure providers is an empty slice instead of nil for proper JSON marshaling
+	if providers == nil {
+		providers = []types.Record{}
+	}
+
 	writeJSONResult(w, "FindProviders", jsontypes.ProvidersResponse{
 		Providers: providers,
 	})
@@ -451,6 +456,11 @@ func (s *server) findPeersJSON(w http.ResponseWriter, peersIter iter.ResultIter[
 		return
 	}
 
+	// Ensure peers is an empty slice instead of nil for proper JSON marshaling
+	if peers == nil {
+		peers = []*types.PeerRecord{}
+	}
+
 	writeJSONResult(w, "FindPeers", jsontypes.PeersResponse{
 		Peers: peers,
 	})
@@ -503,7 +513,9 @@ func (s *server) GetIPNS(w http.ResponseWriter, r *http.Request) {
 	record, err := s.svc.GetIPNS(ctx, name)
 	if err != nil {
 		if errors.Is(err, routing.ErrNotFound) {
-			writeErr(w, "GetIPNS", http.StatusNotFound, fmt.Errorf("delegate error: %w", err))
+			// Per IPIP-0513: Return 200 with text/plain to indicate no record found
+			setCacheControl(w, maxAgeWithoutResults, maxStale)
+			http.Error(w, fmt.Sprintf("delegate error: %s", err), http.StatusOK)
 			return
 		} else {
 			writeErr(w, "GetIPNS", http.StatusInternalServerError, fmt.Errorf("delegate error: %w", err))
@@ -629,11 +641,8 @@ func writeJSONResult(w http.ResponseWriter, method string, val interface{ Length
 		return
 	}
 
-	if val.Length() > 0 {
-		w.WriteHeader(http.StatusOK)
-	} else {
-		w.WriteHeader(http.StatusNotFound)
-	}
+	// Always return 200 OK per IPIP-0513
+	w.WriteHeader(http.StatusOK)
 
 	_, err = io.Copy(w, bytes.NewBuffer(b))
 	if err != nil {
@@ -708,8 +717,8 @@ func writeResultsIterNDJSON[T types.Record](w http.ResponseWriter, resultIter it
 	}
 
 	if !hasResults {
-		// There weren't results, cache for shorter and send 404
+		// There weren't results, cache for shorter but still send 200 per IPIP-0513
 		setCacheControl(w, maxAgeWithoutResults, maxStale)
-		w.WriteHeader(http.StatusNotFound)
+		w.WriteHeader(http.StatusOK)
 	}
 }



Test patch:
diff --git a/routing/http/client/client_test.go b/routing/http/client/client_test.go
index bf7676ae..6171fe85 100644
--- a/routing/http/client/client_test.go
+++ b/routing/http/client/client_test.go
@@ -20,6 +20,7 @@ import (
 	"github.com/ipfs/go-cid"
 	"github.com/libp2p/go-libp2p/core/crypto"
 	"github.com/libp2p/go-libp2p/core/peer"
+	"github.com/libp2p/go-libp2p/core/routing"
 	"github.com/multiformats/go-multiaddr"
 	"github.com/multiformats/go-multibase"
 	"github.com/multiformats/go-multihash"
@@ -317,6 +318,19 @@ func TestClient_FindProviders(t *testing.T) {
 			httpStatusCode: 404,
 			expResult:      nil,
 		},
+		{
+			name:                    "returns no providers if the HTTP server returns 200 with empty JSON array",
+			routerResult:            []iter.Result[types.Record]{},
+			expResult:               nil,
+			serverStreamingDisabled: true,
+			expJSONResponse:         true,
+		},
+		{
+			name:                 "returns no providers if the HTTP server returns 200 with empty NDJSON",
+			routerResult:         []iter.Result[types.Record]{},
+			expResult:            nil,
+			expStreamingResponse: true,
+		},
 	}
 	for _, c := range cases {
 		t.Run(c.name, func(t *testing.T) {
@@ -604,6 +618,19 @@ func TestClient_FindPeers(t *testing.T) {
 			httpStatusCode: 404,
 			expResult:      nil,
 		},
+		{
+			name:                    "returns no providers if the HTTP server returns 200 with empty JSON array",
+			routerResult:            []iter.Result[*types.PeerRecord]{},
+			expResult:               nil,
+			serverStreamingDisabled: true,
+			expJSONResponse:         true,
+		},
+		{
+			name:                 "returns no providers if the HTTP server returns 200 with empty NDJSON",
+			routerResult:         []iter.Result[*types.PeerRecord]{},
+			expResult:            nil,
+			expStreamingResponse: true,
+		},
 	}
 	for _, c := range cases {
 		t.Run(c.name, func(t *testing.T) {
@@ -676,6 +703,139 @@ func TestClient_FindPeers(t *testing.T) {
 	}
 }
 
+func TestClient_EmptyResponses(t *testing.T) {
+	// Test that client handles various empty response formats correctly
+	cid := makeCID()
+	pid := peer.ID("12D3KooWD3eckifWpRn9wQpMG9R9hX3sD158z7EqHWmweQAJU5SA")
+	_, name := makeName(t)
+
+	// Helper to create test server with given response
+	makeTestServer := func(handler http.HandlerFunc) *httptest.Server {
+		return httptest.NewServer(handler)
+	}
+
+	// Helper to verify empty results for providers
+	verifyEmptyProviders := func(t *testing.T, serverURL string) {
+		client, err := New(serverURL, WithHTTPClient((&http.Client{})))
+		require.NoError(t, err)
+
+		resultsIter, err := client.FindProviders(context.Background(), cid)
+		require.NoError(t, err)
+
+		results, err := iter.ReadAllResults(resultsIter)
+		require.NoError(t, err)
+		require.Empty(t, results)
+	}
+
+	// Helper to verify empty results for peers
+	verifyEmptyPeers := func(t *testing.T, serverURL string) {
+		client, err := New(serverURL, WithHTTPClient((&http.Client{})))
+		require.NoError(t, err)
+
+		resultsIter, err := client.FindPeers(context.Background(), pid)
+		require.NoError(t, err)
+
+		results, err := iter.ReadAllResults(resultsIter)
+		require.NoError(t, err)
+		require.Empty(t, results)
+	}
+
+	// Helper to verify IPNS not found
+	verifyIPNSNotFound := func(t *testing.T, serverURL string) {
+		client, err := New(serverURL, WithHTTPClient((&http.Client{})))
+		require.NoError(t, err)
+
+		record, err := client.GetIPNS(context.Background(), name)
+		require.ErrorIs(t, err, routing.ErrNotFound)
+		require.Nil(t, record)
+	}
+
+	testCases := []struct {
+		name     string
+		handler  http.HandlerFunc
+		testFunc func(*testing.T, string)
+	}{
+		// FindProviders tests
+		{
+			name: "404 Not Found (legacy server)",
+			handler: func(w http.ResponseWriter, r *http.Request) {
+				w.WriteHeader(http.StatusNotFound)
+				w.Write([]byte("404 page not found"))
+			},
+			testFunc: verifyEmptyProviders,
+		},
+		{
+			name: "200 with null providers in JSON",
+			handler: func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				// Old server behavior - returns null instead of empty array
+				w.Write([]byte(`{"Providers":null}`))
+			},
+			testFunc: verifyEmptyProviders,
+		},
+		{
+			name: "200 with empty array in JSON",
+			handler: func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				// New server behavior - returns empty array
+				w.Write([]byte(`{"Providers":[]}`))
+			},
+			testFunc: verifyEmptyProviders,
+		},
+		{
+			name: "200 with empty NDJSON stream",
+			handler: func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/x-ndjson")
+				// Empty body - no NDJSON lines
+			},
+			testFunc: verifyEmptyProviders,
+		},
+		// FindPeers tests
+		{
+			name: "FindPeers: 404 Not Found (legacy server)",
+			handler: func(w http.ResponseWriter, r *http.Request) {
+				w.WriteHeader(http.StatusNotFound)
+				w.Write([]byte("404 page not found"))
+			},
+			testFunc: verifyEmptyPeers,
+		},
+		{
+			name: "FindPeers: 200 with null peers in JSON",
+			handler: func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				// Old server behavior - returns null instead of empty array
+				w.Write([]byte(`{"Peers":null}`))
+			},
+			testFunc: verifyEmptyPeers,
+		},
+		// IPNS tests
+		{
+			name: "IPNS: 404 Not Found (legacy server)",
+			handler: func(w http.ResponseWriter, r *http.Request) {
+				w.WriteHeader(http.StatusNotFound)
+				w.Write([]byte("404 page not found"))
+			},
+			testFunc: verifyIPNSNotFound,
+		},
+		{
+			name: "IPNS: 200 with text/plain (no record found)",
+			handler: func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+				w.Write([]byte("delegate error: routing: not found"))
+			},
+			testFunc: verifyIPNSNotFound,
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			server := makeTestServer(tc.handler)
+			defer server.Close()
+			tc.testFunc(t, server.URL)
+		})
+	}
+}
+
 func TestNormalizeBaseURL(t *testing.T) {
 	cases := []struct {
 		name        string
diff --git a/routing/http/server/server_test.go b/routing/http/server/server_test.go
index d5b8a710..29e25992 100644
--- a/routing/http/server/server_test.go
+++ b/routing/http/server/server_test.go
@@ -51,7 +51,7 @@ func TestHeaders(t *testing.T) {
 
 	resp, err := http.Get(serverAddr + "/routing/v1/providers/" + c)
 	require.NoError(t, err)
-	require.Equal(t, 200, resp.StatusCode)
+	require.Equal(t, http.StatusOK, resp.StatusCode)
 	header := resp.Header.Get("Content-Type")
 	require.Equal(t, mediaTypeJSON, header)
 	require.Equal(t, "Accept", resp.Header.Get("Vary"))
@@ -169,11 +169,8 @@ func TestProviders(t *testing.T) {
 
 		resp, err := http.DefaultClient.Do(req)
 		require.NoError(t, err)
-		if empty {
-			require.Equal(t, 404, resp.StatusCode)
-		} else {
-			require.Equal(t, 200, resp.StatusCode)
-		}
+		// Per IPIP-0513: Always return 200, even for empty results
+		require.Equal(t, http.StatusOK, resp.StatusCode)
 
 		require.Equal(t, contentType, resp.Header.Get("Content-Type"))
 		require.Equal(t, "Accept", resp.Header.Get("Vary"))
@@ -214,17 +211,17 @@ func TestProviders(t *testing.T) {
 	})
 
 	t.Run("Empty JSON Response", func(t *testing.T) {
-		runTest(t, mediaTypeJSON, "", "", true, false, `{"Providers":null}`)
+		runTest(t, mediaTypeJSON, "", "", true, false, `{"Providers":[]}`)
 	})
 
 	t.Run("Wildcard Accept header defaults to JSON Response", func(t *testing.T) {
 		accept := "text/html,*/*"
-		runTest(t, accept, "", "", true, false, `{"Providers":null}`)
+		runTest(t, accept, "", "", true, false, `{"Providers":[]}`)
 	})
 
 	t.Run("Missing Accept header defaults to JSON Response", func(t *testing.T) {
 		accept := ""
-		runTest(t, accept, "", "", true, false, `{"Providers":null}`)
+		runTest(t, accept, "", "", true, false, `{"Providers":[]}`)
 	})
 
 	t.Run("NDJSON Response", func(t *testing.T) {
@@ -256,7 +253,13 @@ func TestProviders(t *testing.T) {
 
 		resp, err := http.DefaultClient.Do(req)
 		require.NoError(t, err)
-		require.Equal(t, 404, resp.StatusCode)
+		// Per IPIP-0513: Return 200 for empty results even when ErrNotFound
+		require.Equal(t, http.StatusOK, resp.StatusCode)
+
+		// Verify empty response body
+		body, err := io.ReadAll(resp.Body)
+		require.NoError(t, err)
+		require.Equal(t, `{"Providers":[]}`, string(body))
 	})
 }
 
@@ -286,7 +289,7 @@ func TestPeers(t *testing.T) {
 		require.Equal(t, 400, resp.StatusCode)
 	})
 
-	t.Run("GET /routing/v1/peers/{cid-libp2p-key-peer-id} returns 404 with correct body and headers (No Results, explicit JSON)", func(t *testing.T) {
+	t.Run("GET /routing/v1/peers/{cid-libp2p-key-peer-id} returns 200 with empty results (No Results, explicit JSON)", func(t *testing.T) {
 		t.Parallel()
 
 		_, pid := makeEd25519PeerID(t)
@@ -296,16 +299,22 @@ func TestPeers(t *testing.T) {
 		router.On("FindPeers", mock.Anything, pid, DefaultRecordsLimit).Return(results, nil)
 
 		resp := makeRequest(t, router, mediaTypeJSON, peer.ToCid(pid).String(), "", "")
-		require.Equal(t, 404, resp.StatusCode)
+		// Per IPIP-0513: Return 200 for empty results
+		require.Equal(t, http.StatusOK, resp.StatusCode)
 
 		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"))
 
 		requireCloseToNow(t, resp.Header.Get("Last-Modified"))
+
+		// Verify the response body contains empty Peers array
+		body, err := io.ReadAll(resp.Body)
+		require.NoError(t, err)
+		require.Equal(t, `{"Peers":[]}`, string(body))
 	})
 
-	t.Run("GET /routing/v1/peers/{cid-libp2p-key-peer-id} returns 404 with correct body and headers (No Results, implicit JSON, wildcard Accept header)", func(t *testing.T) {
+	t.Run("GET /routing/v1/peers/{cid-libp2p-key-peer-id} returns 200 with empty results (No Results, implicit JSON, wildcard Accept header)", func(t *testing.T) {
 		t.Parallel()
 
 		_, pid := makeEd25519PeerID(t)
@@ -317,12 +326,17 @@ func TestPeers(t *testing.T) {
 		// Simulate request with Accept header that includes wildcard match
 		resp := makeRequest(t, router, "text/html,*/*", peer.ToCid(pid).String(), "", "")
 
-		// Expect response to default to application/json
-		require.Equal(t, 404, resp.StatusCode)
+		// Per IPIP-0513: Return 200 for empty results
+		require.Equal(t, http.StatusOK, resp.StatusCode)
 		require.Equal(t, mediaTypeJSON, resp.Header.Get("Content-Type"))
+
+		// Verify empty results
+		body, err := io.ReadAll(resp.Body)
+		require.NoError(t, err)
+		require.Equal(t, `{"Peers":[]}`, string(body))
 	})
 
-	t.Run("GET /routing/v1/peers/{cid-libp2p-key-peer-id} returns 404 with correct body and headers (No Results, implicit JSON, no Accept header)", func(t *testing.T) {
+	t.Run("GET /routing/v1/peers/{cid-libp2p-key-peer-id} returns 200 with empty results (No Results, implicit JSON, no Accept header)", func(t *testing.T) {
 		t.Parallel()
 
 		_, pid := makeEd25519PeerID(t)
@@ -334,12 +348,17 @@ func TestPeers(t *testing.T) {
 		// Simulate request without Accept header
 		resp := makeRequest(t, router, "", peer.ToCid(pid).String(), "", "")
 
-		// Expect response to default to application/json
-		require.Equal(t, 404, resp.StatusCode)
+		// Per IPIP-0513: Return 200 for empty results
+		require.Equal(t, http.StatusOK, resp.StatusCode)
 		require.Equal(t, mediaTypeJSON, resp.Header.Get("Content-Type"))
+
+		// Verify empty results
+		body, err := io.ReadAll(resp.Body)
+		require.NoError(t, err)
+		require.Equal(t, `{"Peers":[]}`, string(body))
 	})
 
-	t.Run("GET /routing/v1/peers/{cid-libp2p-key-peer-id} returns 404 when router returns routing.ErrNotFound", func(t *testing.T) {
+	t.Run("GET /routing/v1/peers/{cid-libp2p-key-peer-id} returns 200 with empty results when router returns routing.ErrNotFound", func(t *testing.T) {
 		t.Parallel()
 
 		_, pid := makeEd25519PeerID(t)
@@ -350,9 +369,14 @@ func TestPeers(t *testing.T) {
 		// Simulate request without Accept header
 		resp := makeRequest(t, router, "", peer.ToCid(pid).String(), "", "")
 
-		// Expect response to default to application/json
-		require.Equal(t, 404, resp.StatusCode)
+		// Per IPIP-0513: Return 200 for empty results even when ErrNotFound
+		require.Equal(t, http.StatusOK, resp.StatusCode)
 		require.Equal(t, mediaTypeJSON, resp.Header.Get("Content-Type"))
+
+		// Verify empty results
+		body, err := io.ReadAll(resp.Body)
+		require.NoError(t, err)
+		require.Equal(t, `{"Peers":[]}`, string(body))
 	})
 
 	t.Run("GET /routing/v1/peers/{cid-libp2p-key-peer-id} returns 200 with correct body and headers (JSON)", func(t *testing.T) {
@@ -379,7 +403,7 @@ func TestPeers(t *testing.T) {
 
 		libp2pKeyCID := peer.ToCid(pid).String()
 		resp := makeRequest(t, router, mediaTypeJSON, libp2pKeyCID, "", "")
-		require.Equal(t, 200, resp.StatusCode)
+		require.Equal(t, http.StatusOK, resp.StatusCode)
 
 		require.Equal(t, mediaTypeJSON, resp.Header.Get("Content-Type"))
 		require.Equal(t, "Accept", resp.Header.Get("Vary"))
@@ -431,7 +455,7 @@ func TestPeers(t *testing.T) {
 
 		libp2pKeyCID := peer.ToCid(pid).String()
 		resp := makeRequest(t, router, mediaTypeJSON, libp2pKeyCID, "tcp", "")
-		require.Equal(t, 200, resp.StatusCode)
+		require.Equal(t, http.StatusOK, resp.StatusCode)
 
 		require.Equal(t, mediaTypeJSON, resp.Header.Get("Content-Type"))
 		require.Equal(t, "Accept", resp.Header.Get("Vary"))
@@ -483,7 +507,7 @@ func TestPeers(t *testing.T) {
 
 		libp2pKeyCID := peer.ToCid(pid).String()
 		resp := makeRequest(t, router, mediaTypeJSON, libp2pKeyCID, "", "transport-bitswap")
-		require.Equal(t, 200, resp.StatusCode)
+		require.Equal(t, http.StatusOK, resp.StatusCode)
 
 		require.Equal(t, mediaTypeJSON, resp.Header.Get("Content-Type"))
 		require.Equal(t, "Accept", resp.Header.Get("Vary"))
@@ -498,7 +522,7 @@ func TestPeers(t *testing.T) {
 		require.Equal(t, expectedBody, string(body))
 	})
 
-	t.Run("GET /routing/v1/peers/{cid-libp2p-key-peer-id} returns 404 with correct body and headers (No Results, NDJSON)", func(t *testing.T) {
+	t.Run("GET /routing/v1/peers/{cid-libp2p-key-peer-id} returns 200 with empty results (No Results, NDJSON)", func(t *testing.T) {
 		t.Parallel()
 
 		_, pid := makeEd25519PeerID(t)
@@ -508,13 +532,19 @@ func TestPeers(t *testing.T) {
 		router.On("FindPeers", mock.Anything, pid, DefaultStreamingRecordsLimit).Return(results, nil)
 
 		resp := makeRequest(t, router, mediaTypeNDJSON, peer.ToCid(pid).String(), "", "")
-		require.Equal(t, 404, resp.StatusCode)
+		// Per IPIP-0513: Return 200 for empty results
+		require.Equal(t, http.StatusOK, resp.StatusCode)
 
 		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"))
 
 		requireCloseToNow(t, resp.Header.Get("Last-Modified"))
+
+		// Verify empty NDJSON stream (no lines)
+		body, err := io.ReadAll(resp.Body)
+		require.NoError(t, err)
+		require.Equal(t, "", string(body))
 	})
 
 	t.Run("GET /routing/v1/peers/{cid-libp2p-key-peer-id} returns 200 with correct body and headers (NDJSON)", func(t *testing.T) {
@@ -541,7 +571,7 @@ func TestPeers(t *testing.T) {
 
 		libp2pKeyCID := peer.ToCid(pid).String()
 		resp := makeRequest(t, router, mediaTypeNDJSON, libp2pKeyCID, "", "")
-		require.Equal(t, 200, resp.StatusCode)
+		require.Equal(t, http.StatusOK, resp.StatusCode)
 
 		require.Equal(t, mediaTypeNDJSON, resp.Header.Get("Content-Type"))
 		require.Equal(t, "Accept", resp.Header.Get("Vary"))
@@ -603,7 +633,7 @@ func TestPeers(t *testing.T) {
 			router.On("FindPeers", mock.Anything, pid, DefaultStreamingRecordsLimit).Return(iter.FromSlice(results), nil)
 
 			resp := makeRequest(t, router, mediaTypeNDJSON, peerIDStr, "", "")
-			require.Equal(t, 200, resp.StatusCode)
+			require.Equal(t, http.StatusOK, resp.StatusCode)
 
 			require.Equal(t, mediaTypeNDJSON, resp.Header.Get("Content-Type"))
 			require.Equal(t, "Accept", resp.Header.Get("Vary"))
@@ -623,7 +653,7 @@ func TestPeers(t *testing.T) {
 			router.On("FindPeers", mock.Anything, pid, DefaultRecordsLimit).Return(iter.FromSlice(results), nil)
 
 			resp := makeRequest(t, router, mediaTypeJSON, peerIDStr, "", "")
-			require.Equal(t, 200, resp.StatusCode)
+			require.Equal(t, http.StatusOK, resp.StatusCode)
 
 			require.Equal(t, mediaTypeJSON, resp.Header.Get("Content-Type"))
 			require.Equal(t, "Accept", resp.Header.Get("Vary"))
@@ -701,7 +731,7 @@ func TestIPNS(t *testing.T) {
 			router.On("GetIPNS", mock.Anything, name1).Return(rec, nil)
 
 			resp := makeRequest(t, router, "/routing/v1/ipns/"+name1.String(), mediaTypeIPNSRecord)
-			require.Equal(t, 200, resp.StatusCode)
+			require.Equal(t, http.StatusOK, resp.StatusCode)
 			require.Equal(t, mediaTypeIPNSRecord, resp.Header.Get("Content-Type"))
 			require.Equal(t, "Accept", resp.Header.Get("Vary"))
 			require.NotEmpty(t, resp.Header.Get("Etag"))
@@ -744,7 +774,7 @@ func TestIPNS(t *testing.T) {
 			resp := makeRequest(t, router, "/routing/v1/ipns/"+name1.String(), noAccept)
 
 			// Expect application/vnd.ipfs.ipns-record in response
-			require.Equal(t, 200, resp.StatusCode)
+			require.Equal(t, http.StatusOK, resp.StatusCode)
 			require.Equal(t, mediaTypeIPNSRecord, resp.Header.Get("Content-Type"))
 
 			// Confirm body matches  expected bytes
@@ -767,7 +797,7 @@ func TestIPNS(t *testing.T) {
 			resp := makeRequest(t, router, "/routing/v1/ipns/"+name1.String(), wcAccept)
 
 			// Expect application/vnd.ipfs.ipns-record in response
-			require.Equal(t, 200, resp.StatusCode)
+			require.Equal(t, http.StatusOK, resp.StatusCode)
 			require.Equal(t, mediaTypeIPNSRecord, resp.Header.Get("Content-Type"))
 
 			// Confirm body matches  expected bytes
@@ -792,7 +822,7 @@ func TestIPNS(t *testing.T) {
 			require.Equal(t, 400, resp.StatusCode)
 		})
 
-		t.Run("GET /routing/v1/ipns/{cid-peer-id} returns 404 (no record found)", func(t *testing.T) {
+		t.Run("GET /routing/v1/ipns/{cid-peer-id} returns 200 with text/plain (no record found)", func(t *testing.T) {
 			t.Parallel()
 
 			router := &mockContentRouter{}
@@ -802,8 +832,14 @@ func TestIPNS(t *testing.T) {
 			noAccept := ""
 			resp := makeRequest(t, router, "/routing/v1/ipns/"+name1.String(), noAccept)
 
-			// Expect application/vnd.ipfs.ipns-record in response
-			require.Equal(t, 404, resp.StatusCode)
+			// Per IPIP-0513: Return 200 with text/plain for no record found
+			require.Equal(t, http.StatusOK, resp.StatusCode)
+			require.Equal(t, "text/plain; charset=utf-8", resp.Header.Get("Content-Type"))
+
+			// Verify error message in body
+			body, err := io.ReadAll(resp.Body)
+			require.NoError(t, err)
+			require.Contains(t, string(body), "routing: not found")
 		})
 
 		t.Run("PUT /routing/v1/ipns/{cid-peer-id} returns 200", func(t *testing.T) {
@@ -823,7 +859,7 @@ func TestIPNS(t *testing.T) {
 
 			resp, err := http.DefaultClient.Do(req)
 			require.NoError(t, err)
-			require.Equal(t, 200, resp.StatusCode)
+			require.Equal(t, http.StatusOK, resp.StatusCode)
 		})
 
 		t.Run("PUT /routing/v1/ipns/{cid-peer-id} returns 400 for wrong record", func(t *testing.T) {