fix(routing): use LegacyProvider for HTTP-only custom routing (#11112)
* fix(routing): use LegacyProvider for HTTP-only custom routing when `Routing.Type=custom` with only HTTP routers and no DHT, fall back to LegacyProvider instead of SweepingProvider. SweepingProvider requires a DHT client which is unavailable in HTTP-only configurations, causing it to return NoopProvider and breaking provider record announcements to HTTP routers. fixes #11089 * test(routing): verify provide stat works with HTTP-only routing * docs(config): clarify SweepEnabled fallback for HTTP-only routing --------- Co-authored-by: Andrew Gillis <11790789+gammazero@users.noreply.github.com>
Marcin Rataj committed
Jan 9, 2026 at 18:23 UTC
23ba660ef07a282e5fc3f4b0ad44dae107c74f3f
3 files changed
+133
-1
core/node/provider.go
+51
-1
@@ -692,6 +692,48 @@ See docs: https://github.com/ipfs/kubo/blob/master/docs/config.md#providedhtmaxw
692
693
// ONLINE/OFFLINE
694
695
+// hasDHTRouting checks if the routing configuration includes a DHT component.
696
+// Returns false for HTTP-only custom routing configurations (e.g., Routing.Type="custom"
697
+// with only HTTP routers). This is used to determine whether SweepingProviderOpt
698
+// can be used, since it requires a DHT client.
699
+func hasDHTRouting(cfg *config.Config) bool {
700
+ routingType := cfg.Routing.Type.WithDefault(config.DefaultRoutingType)
701
+ switch routingType {
702
+ case "auto", "autoclient", "dht", "dhtclient", "dhtserver":
703
+ return true
704
+ case "custom":
705
+ // Check if any router in custom config is DHT-based
706
+ for _, router := range cfg.Routing.Routers {
707
+ if routerIncludesDHT(router, cfg) {
708
+ return true
709
+ }
710
+ }
711
+ return false
712
+ default: // "none", "delegated"
713
+ return false
714
+ }
715
+}
716
+
717
+// routerIncludesDHT recursively checks if a router configuration includes DHT.
718
+// Handles parallel and sequential composite routers by checking their children.
719
+func routerIncludesDHT(rp config.RouterParser, cfg *config.Config) bool {
720
+ switch rp.Type {
721
+ case config.RouterTypeDHT:
722
+ return true
723
+ case config.RouterTypeParallel, config.RouterTypeSequential:
724
+ if children, ok := rp.Parameters.(*config.ComposableRouterParams); ok {
725
+ for _, child := range children.Routers {
726
+ if childRouter, exists := cfg.Routing.Routers[child.RouterName]; exists {
727
+ if routerIncludesDHT(childRouter, cfg) {
728
+ return true
729
+ }
730
+ }
731
+ }
732
+ }
733
+ }
734
+ return false
735
+}
736
+
737
// OnlineProviders groups units managing provide routing records online
738
func OnlineProviders(provide bool, cfg *config.Config) fx.Option {
739
if !provide {
@@ -708,7 +750,15 @@ func OnlineProviders(provide bool, cfg *config.Config) fx.Option {
750
opts := []fx.Option{
751
fx.Provide(setReproviderKeyProvider(providerStrategy)),
752
}
711
- if cfg.Provide.DHT.SweepEnabled.WithDefault(config.DefaultProvideDHTSweepEnabled) {
753
+
754
+ sweepEnabled := cfg.Provide.DHT.SweepEnabled.WithDefault(config.DefaultProvideDHTSweepEnabled)
755
+ dhtAvailable := hasDHTRouting(cfg)
756
+
757
+ // Use SweepingProvider only when both sweep is enabled AND DHT is available.
758
+ // For HTTP-only routing (e.g., Routing.Type="custom" with only HTTP routers),
759
+ // fall back to LegacyProvider which works with ProvideManyRouter.
760
+ // See https://github.com/ipfs/kubo/issues/11089
761
+ if sweepEnabled && dhtAvailable {
762
opts = append(opts, SweepingProviderOpt(cfg))
763
} else {
764
reprovideInterval := cfg.Provide.DHT.Interval.WithDefault(config.DefaultProvideDHTInterval)
docs/config.md
+3
@@ -2195,6 +2195,9 @@ You can compare the effectiveness of sweep mode vs legacy mode by monitoring the
2195
> [!NOTE]
2196
> This is the default provider system as of Kubo v0.39. To use the legacy provider instead, set `Provide.DHT.SweepEnabled=false`.
2197
2198
+> [!NOTE]
2199
+> When DHT routing is unavailable (e.g., `Routing.Type=custom` with only HTTP routers), the provider automatically falls back to the legacy provider regardless of this setting.
2200
+
2201
Default: `true`
2202
2203
Type: `flag`
test/cli/provider_test.go
+79
@@ -7,6 +7,7 @@ import (
7
"net/http"
8
"net/http/httptest"
9
"strings"
10
+ "sync/atomic"
11
"testing"
12
"time"
13
@@ -764,3 +765,81 @@ func TestProvider(t *testing.T) {
765
})
766
}
767
}
768
+
769
+// TestHTTPOnlyProviderWithSweepEnabled tests that provider records are correctly
770
+// sent to HTTP routers when Routing.Type="custom" with only HTTP routers configured,
771
+// even when Provide.DHT.SweepEnabled=true (the default since v0.39).
772
+//
773
+// This is a regression test for https://github.com/ipfs/kubo/issues/11089
774
+func TestHTTPOnlyProviderWithSweepEnabled(t *testing.T) {
775
+ t.Parallel()
776
+
777
+ // Track provide requests received by the mock HTTP router
778
+ var provideRequests atomic.Int32
779
+ mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
780
+ if (r.Method == http.MethodPut || r.Method == http.MethodPost) &&
781
+ strings.HasPrefix(r.URL.Path, "/routing/v1/providers") {
782
+ provideRequests.Add(1)
783
+ w.WriteHeader(http.StatusOK)
784
+ } else if strings.HasPrefix(r.URL.Path, "/routing/v1/providers") && r.Method == http.MethodGet {
785
+ // Return empty providers for findprovs
786
+ w.Header().Set("Content-Type", "application/x-ndjson")
787
+ w.WriteHeader(http.StatusOK)
788
+ } else {
789
+ w.WriteHeader(http.StatusNotFound)
790
+ }
791
+ }))
792
+ defer mockServer.Close()
793
+
794
+ h := harness.NewT(t)
795
+ node := h.NewNode().Init()
796
+
797
+ // Explicitly set SweepEnabled=true (the default since v0.39, but be explicit for test clarity)
798
+ node.SetIPFSConfig("Provide.DHT.SweepEnabled", true)
799
+ node.SetIPFSConfig("Provide.Enabled", true)
800
+
801
+ // Configure HTTP-only custom routing (no DHT) with explicit Routing.Type=custom
802
+ routingConf := map[string]any{
803
+ "Type": "custom", // Explicitly set Routing.Type=custom
804
+ "Methods": map[string]any{
805
+ "provide": map[string]any{"RouterName": "HTTPRouter"},
806
+ "get-ipns": map[string]any{"RouterName": "HTTPRouter"},
807
+ "put-ipns": map[string]any{"RouterName": "HTTPRouter"},
808
+ "find-peers": map[string]any{"RouterName": "HTTPRouter"},
809
+ "find-providers": map[string]any{"RouterName": "HTTPRouter"},
810
+ },
811
+ "Routers": map[string]any{
812
+ "HTTPRouter": map[string]any{
813
+ "Type": "http",
814
+ "Parameters": map[string]any{
815
+ "Endpoint": mockServer.URL,
816
+ },
817
+ },
818
+ },
819
+ }
820
+ node.SetIPFSConfig("Routing", routingConf)
821
+ node.StartDaemon()
822
+ defer node.StopDaemon()
823
+
824
+ // Add content and manually provide it
825
+ cid := node.IPFSAddStr(time.Now().String())
826
+
827
+ // Manual provide should succeed even without libp2p peers
828
+ res := node.RunIPFS("routing", "provide", cid)
829
+ // Check that the command succeeded (exit code 0) and no provide-related errors
830
+ assert.Equal(t, 0, res.ExitCode(), "routing provide should succeed with HTTP-only routing and SweepEnabled=true")
831
+ assert.NotContains(t, res.Stderr.String(), "cannot provide", "should not have provide errors")
832
+
833
+ // Verify HTTP router received at least one provide request
834
+ assert.Greater(t, provideRequests.Load(), int32(0),
835
+ "HTTP router should have received provide requests")
836
+
837
+ // Verify 'provide stat' works with HTTP-only routing (regression test for stats)
838
+ statRes := node.RunIPFS("provide", "stat")
839
+ assert.Equal(t, 0, statRes.ExitCode(), "provide stat should succeed with HTTP-only routing")
840
+ assert.NotContains(t, statRes.Stderr.String(), "stats not available",
841
+ "should not report stats unavailable")
842
+ // LegacyProvider outputs "TotalReprovides:" in its stats
843
+ assert.Contains(t, statRes.Stdout.String(), "TotalReprovides:",
844
+ "should show legacy provider stats")
845
+}