Skip to content

Commit 0e41866

Browse files
authored
feat(routing/http): return 200 for empty results per IPIP-513 (#1032)
* feat: implement IPIP-513 for routing v1 endpoints return HTTP 200 with empty results instead of 404 when no records found, while maintaining backward compatibility in clients to handle both response codes Fixes: #1024 Spec: ipfs/specs#513 * docs: add IPIP-513 changes to changelog * fix: correct off-by-one error in routing_http_client_length metric the metric now correctly reports: - 0 for empty results (instead of 1) - n for n results (instead of n+1) * feat: add metrics for IPNS operations - GetIPNS now records latency, status code, and length (0 or 1) - PutIPNS now records latency and status code - uses consistent pattern with httpReq.Host for host extraction * docs: explain histogram bucket interpretation for metrics - document how OpenCensus bucket selection works (value < boundary) - explain mapping to Prometheus le buckets for both latency and length - provide examples of how to extract meaningful data from metrics * feat: add simple counter metrics for requests and positive responses - routing_http_client_requests_total: counts all requests including errors - routing_http_client_positive_responses_total: counts requests with 1+ results - avoids confusing histogram bucket math for common queries - documented with simple Grafana query examples * fix: drain response body for connection reuse in 404 cases ensure http connection can be reused by draining response body before closing
1 parent 43c1149 commit 0e41866

File tree

6 files changed

+383
-50
lines changed

6 files changed

+383
-50
lines changed

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ The following emojis are used to highlight certain changes:
2323

2424
### Changed
2525

26+
- `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))
27+
- 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
28+
- Client maintains backward compatibility by treating both 200 with empty results and 404 as "no records found"
29+
- IPNS endpoint distinguishes between valid records (Content-Type: `application/vnd.ipfs.ipns-record`) and no record found (any other content type)
2630
- `verifcid`: 🛠 Enhanced Allowlist interface with per-hash size limits ([#1018](https://github.com/ipfs/boxo/pull/1018))
2731
- Expanded `Allowlist` interface with `MinDigestSize(code uint64)` and `MaxDigestSize(code uint64)` methods for per-hash function size validation
2832
- 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:
3741

3842
### Fixed
3943

44+
- `routing/http/client`:
45+
- Fixed off-by-one error in `routing_http_client_length` metric - the metric now correctly reports 0 for empty results instead of 1
46+
- Added metrics for IPNS operations (`GetIPNS` and `PutIPNS`) - these now report latency, status code, and result count (0 or 1 for GetIPNS)
47+
- Added simple counter metrics to avoid confusing histogram bucket math:
48+
- `routing_http_client_requests_total` - total requests including errors
49+
- `routing_http_client_positive_responses_total` - requests that returned at least 1 result
4050
- `ipld/unixfs/mod`:
4151
- `DagModifier` now correctly preserves raw node codec when modifying data under the chunker threshold, instead of incorrectly forcing everything to dag-pb
4252
- `DagModifier` prevents creation of identity CIDs exceeding `verifcid.DefaultMaxIdentityDigestSize` limit when modifying data, automatically switching to proper cryptographic hash while preserving small identity CIDs

routing/http/client/client.go

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import (
2727
logging "github.com/ipfs/go-log/v2"
2828
"github.com/libp2p/go-libp2p/core/crypto"
2929
"github.com/libp2p/go-libp2p/core/peer"
30+
"github.com/libp2p/go-libp2p/core/routing"
3031
"github.com/multiformats/go-multiaddr"
3132
)
3233

@@ -234,8 +235,11 @@ type measuringIter[T any] struct {
234235
}
235236

236237
func (c *measuringIter[T]) Next() bool {
237-
c.m.length++
238-
return c.Iter.Next()
238+
hasNext := c.Iter.Next()
239+
if hasNext {
240+
c.m.length++
241+
}
242+
return hasNext
239243
}
240244

241245
func (c *measuringIter[T]) Val() T {
@@ -279,7 +283,10 @@ func (c *Client) FindProviders(ctx context.Context, key cid.Cid) (providers iter
279283
}
280284

281285
m.statusCode = resp.StatusCode
286+
// Per IPIP-0513: Handle 404 as empty results for backward compatibility
287+
// New servers return 200 with empty results, old servers return 404
282288
if resp.StatusCode == http.StatusNotFound {
289+
io.Copy(io.Discard, resp.Body) // Drain body for connection reuse
283290
resp.Body.Close()
284291
m.record(ctx)
285292
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
462469
}
463470

464471
m.statusCode = resp.StatusCode
472+
// Per IPIP-0513: Handle 404 as empty results for backward compatibility
473+
// New servers return 200 with empty results, old servers return 404
465474
if resp.StatusCode == http.StatusNotFound {
475+
io.Copy(io.Discard, resp.Body) // Drain body for connection reuse
466476
resp.Body.Close()
467477
m.record(ctx)
468478
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
518528
// GetIPNS tries to retrieve the [ipns.Record] for the given [ipns.Name]. The record is
519529
// validated against the given name. If validation fails, an error is returned, but no
520530
// record.
521-
func (c *Client) GetIPNS(ctx context.Context, name ipns.Name) (*ipns.Record, error) {
531+
func (c *Client) GetIPNS(ctx context.Context, name ipns.Name) (record *ipns.Record, err error) {
532+
m := newMeasurement("GetIPNS")
533+
start := time.Now()
534+
defer func() {
535+
m.latency = time.Since(start)
536+
m.err = err
537+
if record != nil {
538+
m.length = 1
539+
} else {
540+
m.length = 0
541+
}
542+
m.record(ctx)
543+
}()
544+
522545
url := c.baseURL + "/routing/v1/ipns/" + name.String()
523546

524547
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
527550
}
528551
httpReq.Header.Set("Accept", mediaTypeIPNSRecord)
529552

553+
m.host = httpReq.Host
554+
530555
resp, err := c.httpClient.Do(httpReq)
531556
if err != nil {
532557
return nil, fmt.Errorf("making HTTP req to get IPNS record: %w", err)
533558
}
534559
defer resp.Body.Close()
535560

561+
m.statusCode = resp.StatusCode
562+
563+
// Per IPIP-0513: Handle 404 as "no record found" for backward compatibility
564+
// New servers return 200 with text/plain for no record, old servers return 404
565+
if resp.StatusCode == http.StatusNotFound {
566+
io.Copy(io.Discard, resp.Body) // Drain body for connection reuse
567+
return nil, routing.ErrNotFound
568+
}
569+
536570
if resp.StatusCode != http.StatusOK {
537571
return nil, httpError(resp.StatusCode, resp.Body)
538572
}
539573

574+
// Per IPIP-0513: Only Content-Type: application/vnd.ipfs.ipns-record indicates a valid record
575+
// Any other content type (e.g., text/plain) means no record found
576+
contentType := resp.Header.Get("Content-Type")
577+
if !strings.Contains(contentType, mediaTypeIPNSRecord) {
578+
io.Copy(io.Discard, resp.Body) // Drain body for connection reuse
579+
return nil, routing.ErrNotFound
580+
}
581+
540582
// Limit the reader to the maximum record size.
541583
rawRecord, err := io.ReadAll(io.LimitReader(resp.Body, int64(ipns.MaxRecordSize)))
542584
if err != nil {
543585
return nil, fmt.Errorf("making HTTP req to get IPNS record: %w", err)
544586
}
545587

546-
record, err := ipns.UnmarshalRecord(rawRecord)
588+
record, err = ipns.UnmarshalRecord(rawRecord)
547589
if err != nil {
548590
return nil, fmt.Errorf("IPNS record from remote endpoint is not valid: %w", err)
549591
}
@@ -557,7 +599,16 @@ func (c *Client) GetIPNS(ctx context.Context, name ipns.Name) (*ipns.Record, err
557599
}
558600

559601
// PutIPNS attempts at putting the given [ipns.Record] for the given [ipns.Name].
560-
func (c *Client) PutIPNS(ctx context.Context, name ipns.Name, record *ipns.Record) error {
602+
func (c *Client) PutIPNS(ctx context.Context, name ipns.Name, record *ipns.Record) (err error) {
603+
m := newMeasurement("PutIPNS")
604+
start := time.Now()
605+
defer func() {
606+
m.latency = time.Since(start)
607+
m.err = err
608+
// Don't set m.length for PutIPNS - not meaningful
609+
m.record(ctx)
610+
}()
611+
561612
url := c.baseURL + "/routing/v1/ipns/" + name.String()
562613

563614
rawRecord, err := ipns.MarshalRecord(record)
@@ -571,12 +622,16 @@ func (c *Client) PutIPNS(ctx context.Context, name ipns.Name, record *ipns.Recor
571622
}
572623
httpReq.Header.Set("Content-Type", mediaTypeIPNSRecord)
573624

625+
m.host = httpReq.Host
626+
574627
resp, err := c.httpClient.Do(httpReq)
575628
if err != nil {
576629
return fmt.Errorf("making HTTP req to get IPNS record: %w", err)
577630
}
578631
defer resp.Body.Close()
579632

633+
m.statusCode = resp.StatusCode
634+
580635
if resp.StatusCode != http.StatusOK {
581636
return httpError(resp.StatusCode, resp.Body)
582637
}

routing/http/client/client_test.go

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"github.com/ipfs/go-cid"
2121
"github.com/libp2p/go-libp2p/core/crypto"
2222
"github.com/libp2p/go-libp2p/core/peer"
23+
"github.com/libp2p/go-libp2p/core/routing"
2324
"github.com/multiformats/go-multiaddr"
2425
"github.com/multiformats/go-multibase"
2526
"github.com/multiformats/go-multihash"
@@ -317,6 +318,19 @@ func TestClient_FindProviders(t *testing.T) {
317318
httpStatusCode: 404,
318319
expResult: nil,
319320
},
321+
{
322+
name: "returns no providers if the HTTP server returns 200 with empty JSON array",
323+
routerResult: []iter.Result[types.Record]{},
324+
expResult: nil,
325+
serverStreamingDisabled: true,
326+
expJSONResponse: true,
327+
},
328+
{
329+
name: "returns no providers if the HTTP server returns 200 with empty NDJSON",
330+
routerResult: []iter.Result[types.Record]{},
331+
expResult: nil,
332+
expStreamingResponse: true,
333+
},
320334
}
321335
for _, c := range cases {
322336
t.Run(c.name, func(t *testing.T) {
@@ -604,6 +618,19 @@ func TestClient_FindPeers(t *testing.T) {
604618
httpStatusCode: 404,
605619
expResult: nil,
606620
},
621+
{
622+
name: "returns no providers if the HTTP server returns 200 with empty JSON array",
623+
routerResult: []iter.Result[*types.PeerRecord]{},
624+
expResult: nil,
625+
serverStreamingDisabled: true,
626+
expJSONResponse: true,
627+
},
628+
{
629+
name: "returns no providers if the HTTP server returns 200 with empty NDJSON",
630+
routerResult: []iter.Result[*types.PeerRecord]{},
631+
expResult: nil,
632+
expStreamingResponse: true,
633+
},
607634
}
608635
for _, c := range cases {
609636
t.Run(c.name, func(t *testing.T) {
@@ -676,6 +703,139 @@ func TestClient_FindPeers(t *testing.T) {
676703
}
677704
}
678705

706+
func TestClient_EmptyResponses(t *testing.T) {
707+
// Test that client handles various empty response formats correctly
708+
cid := makeCID()
709+
pid := peer.ID("12D3KooWD3eckifWpRn9wQpMG9R9hX3sD158z7EqHWmweQAJU5SA")
710+
_, name := makeName(t)
711+
712+
// Helper to create test server with given response
713+
makeTestServer := func(handler http.HandlerFunc) *httptest.Server {
714+
return httptest.NewServer(handler)
715+
}
716+
717+
// Helper to verify empty results for providers
718+
verifyEmptyProviders := func(t *testing.T, serverURL string) {
719+
client, err := New(serverURL, WithHTTPClient((&http.Client{})))
720+
require.NoError(t, err)
721+
722+
resultsIter, err := client.FindProviders(context.Background(), cid)
723+
require.NoError(t, err)
724+
725+
results, err := iter.ReadAllResults(resultsIter)
726+
require.NoError(t, err)
727+
require.Empty(t, results)
728+
}
729+
730+
// Helper to verify empty results for peers
731+
verifyEmptyPeers := func(t *testing.T, serverURL string) {
732+
client, err := New(serverURL, WithHTTPClient((&http.Client{})))
733+
require.NoError(t, err)
734+
735+
resultsIter, err := client.FindPeers(context.Background(), pid)
736+
require.NoError(t, err)
737+
738+
results, err := iter.ReadAllResults(resultsIter)
739+
require.NoError(t, err)
740+
require.Empty(t, results)
741+
}
742+
743+
// Helper to verify IPNS not found
744+
verifyIPNSNotFound := func(t *testing.T, serverURL string) {
745+
client, err := New(serverURL, WithHTTPClient((&http.Client{})))
746+
require.NoError(t, err)
747+
748+
record, err := client.GetIPNS(context.Background(), name)
749+
require.ErrorIs(t, err, routing.ErrNotFound)
750+
require.Nil(t, record)
751+
}
752+
753+
testCases := []struct {
754+
name string
755+
handler http.HandlerFunc
756+
testFunc func(*testing.T, string)
757+
}{
758+
// FindProviders tests
759+
{
760+
name: "404 Not Found (legacy server)",
761+
handler: func(w http.ResponseWriter, r *http.Request) {
762+
w.WriteHeader(http.StatusNotFound)
763+
w.Write([]byte("404 page not found"))
764+
},
765+
testFunc: verifyEmptyProviders,
766+
},
767+
{
768+
name: "200 with null providers in JSON",
769+
handler: func(w http.ResponseWriter, r *http.Request) {
770+
w.Header().Set("Content-Type", "application/json")
771+
// Old server behavior - returns null instead of empty array
772+
w.Write([]byte(`{"Providers":null}`))
773+
},
774+
testFunc: verifyEmptyProviders,
775+
},
776+
{
777+
name: "200 with empty array in JSON",
778+
handler: func(w http.ResponseWriter, r *http.Request) {
779+
w.Header().Set("Content-Type", "application/json")
780+
// New server behavior - returns empty array
781+
w.Write([]byte(`{"Providers":[]}`))
782+
},
783+
testFunc: verifyEmptyProviders,
784+
},
785+
{
786+
name: "200 with empty NDJSON stream",
787+
handler: func(w http.ResponseWriter, r *http.Request) {
788+
w.Header().Set("Content-Type", "application/x-ndjson")
789+
// Empty body - no NDJSON lines
790+
},
791+
testFunc: verifyEmptyProviders,
792+
},
793+
// FindPeers tests
794+
{
795+
name: "FindPeers: 404 Not Found (legacy server)",
796+
handler: func(w http.ResponseWriter, r *http.Request) {
797+
w.WriteHeader(http.StatusNotFound)
798+
w.Write([]byte("404 page not found"))
799+
},
800+
testFunc: verifyEmptyPeers,
801+
},
802+
{
803+
name: "FindPeers: 200 with null peers in JSON",
804+
handler: func(w http.ResponseWriter, r *http.Request) {
805+
w.Header().Set("Content-Type", "application/json")
806+
// Old server behavior - returns null instead of empty array
807+
w.Write([]byte(`{"Peers":null}`))
808+
},
809+
testFunc: verifyEmptyPeers,
810+
},
811+
// IPNS tests
812+
{
813+
name: "IPNS: 404 Not Found (legacy server)",
814+
handler: func(w http.ResponseWriter, r *http.Request) {
815+
w.WriteHeader(http.StatusNotFound)
816+
w.Write([]byte("404 page not found"))
817+
},
818+
testFunc: verifyIPNSNotFound,
819+
},
820+
{
821+
name: "IPNS: 200 with text/plain (no record found)",
822+
handler: func(w http.ResponseWriter, r *http.Request) {
823+
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
824+
w.Write([]byte("delegate error: routing: not found"))
825+
},
826+
testFunc: verifyIPNSNotFound,
827+
},
828+
}
829+
830+
for _, tc := range testCases {
831+
t.Run(tc.name, func(t *testing.T) {
832+
server := makeTestServer(tc.handler)
833+
defer server.Close()
834+
tc.testFunc(t, server.URL)
835+
})
836+
}
837+
}
838+
679839
func TestNormalizeBaseURL(t *testing.T) {
680840
cases := []struct {
681841
name string

0 commit comments

Comments
 (0)