diff --git a/balancer/rls/config.go b/balancer/rls/config.go index 9693c8ba9590..8f9a899eeae4 100644 --- a/balancer/rls/config.go +++ b/balancer/rls/config.go @@ -22,7 +22,6 @@ import ( "bytes" "encoding/json" "fmt" - "net/url" "time" "google.golang.org/grpc/balancer" @@ -30,6 +29,7 @@ import ( "google.golang.org/grpc/internal" "google.golang.org/grpc/internal/pretty" rlspb "google.golang.org/grpc/internal/proto/grpc_lookup_v1" + iresolver "google.golang.org/grpc/internal/resolver" "google.golang.org/grpc/resolver" "google.golang.org/grpc/serviceconfig" "google.golang.org/protobuf/encoding/protojson" @@ -195,19 +195,9 @@ func parseRLSProto(rlsProto *rlspb.RouteLookupConfig) (*lbConfig, error) { if lookupService == "" { return nil, fmt.Errorf("rls: empty lookup_service in route lookup config %+v", rlsProto) } - parsedTarget, err := url.Parse(lookupService) + _, err = iresolver.ParseTarget(lookupService, resolver.GetDefaultScheme(), resolver.Get) if err != nil { - // url.Parse() fails if scheme is missing. Retry with default scheme. - parsedTarget, err = url.Parse(resolver.GetDefaultScheme() + ":///" + lookupService) - if err != nil { - return nil, fmt.Errorf("rls: invalid target URI in lookup_service %s", lookupService) - } - } - if parsedTarget.Scheme == "" { - parsedTarget.Scheme = resolver.GetDefaultScheme() - } - if resolver.Get(parsedTarget.Scheme) == nil { - return nil, fmt.Errorf("rls: unregistered scheme in lookup_service %s", lookupService) + return nil, fmt.Errorf("rls: invalid target URI in lookup_service %s: %v", lookupService, err) } lookupServiceTimeout, err := convertDuration(rlsProto.GetLookupServiceTimeout()) diff --git a/balancer/rls/config_test.go b/balancer/rls/config_test.go index c1aff0c9cb8d..650d5e350a99 100644 --- a/balancer/rls/config_test.go +++ b/balancer/rls/config_test.go @@ -263,7 +263,7 @@ func (s) TestParseConfigErrors(t *testing.T) { "lookupService": "badScheme:///target" } }`), - wantErr: "rls: unregistered scheme in lookup_service", + wantErr: "rls: invalid target URI in lookup_service", }, { desc: "invalid lookup service timeout", diff --git a/clientconn.go b/clientconn.go index 5dec2dacc0ba..dcc698971cfc 100644 --- a/clientconn.go +++ b/clientconn.go @@ -23,7 +23,6 @@ import ( "errors" "fmt" "math" - "net/url" "slices" "strings" "sync" @@ -1798,54 +1797,32 @@ func (cc *ClientConn) connectionError() error { func (cc *ClientConn) initParsedTargetAndResolverBuilder() error { logger.Infof("original dial target is: %q", cc.target) - var rb resolver.Builder - parsedTarget, err := parseTarget(cc.target) - if err == nil { - rb = cc.getResolver(parsedTarget.URL.Scheme) - if rb != nil { - cc.parsedTarget = parsedTarget - cc.resolverBuilder = rb - return nil - } + // Try the target as given first. cc.getResolver checks both globally + // registered resolvers and any resolver registered via dial options. + if parsedTarget, err := iresolver.ParseTarget(cc.target, "", cc.getResolver); err == nil { + cc.parsedTarget = parsedTarget + cc.resolverBuilder = cc.getResolver(parsedTarget.URL.Scheme) + return nil } - // We are here because the user's dial target did not contain a scheme or - // specified an unregistered scheme. We should fallback to the default - // scheme, except when a custom dialer is specified in which case, we should - // always use passthrough scheme. For either case, we need to respect any overridden - // global defaults set by the user. + // The target did not contain a scheme or specified an unregistered + // scheme. Fall back to the default scheme. When a custom dialer is + // specified we use passthrough; otherwise respect any global default + // the user may have overridden. defScheme := cc.dopts.defaultScheme if internal.UserSetDefaultScheme { defScheme = resolver.GetDefaultScheme() } - canonicalTarget := defScheme + ":///" + cc.target - - parsedTarget, err = parseTarget(canonicalTarget) + parsedTarget, err := iresolver.ParseTarget(defScheme+":///"+cc.target, "", cc.getResolver) if err != nil { return err } - rb = cc.getResolver(parsedTarget.URL.Scheme) - if rb == nil { - return fmt.Errorf("could not get resolver for default scheme: %q", parsedTarget.URL.Scheme) - } cc.parsedTarget = parsedTarget - cc.resolverBuilder = rb + cc.resolverBuilder = cc.getResolver(parsedTarget.URL.Scheme) return nil } -// parseTarget uses RFC 3986 semantics to parse the given target into a -// resolver.Target struct containing url. Query params are stripped from the -// endpoint. -func parseTarget(target string) (resolver.Target, error) { - u, err := url.Parse(target) - if err != nil { - return resolver.Target{}, err - } - - return resolver.Target{URL: *u}, nil -} - // encodeAuthority escapes the authority string based on valid chars defined in // https://datatracker.ietf.org/doc/html/rfc3986#section-3.2. func encodeAuthority(authority string) string { diff --git a/internal/resolver/target.go b/internal/resolver/target.go new file mode 100644 index 000000000000..ce4dfc0a633a --- /dev/null +++ b/internal/resolver/target.go @@ -0,0 +1,68 @@ +/* + * + * Copyright 2026 gRPC authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package resolver + +import ( + "fmt" + "net/url" + + "google.golang.org/grpc/resolver" +) + +// ParseTarget parses a gRPC target string into a resolver.Target, verifying +// that a resolver is registered for the parsed scheme using builder. +// +// If the target parses successfully and builder recognises the scheme, the +// parsed target is returned directly. +// +// For hierarchical URIs (scheme://authority/path) with an unregistered scheme, +// ParseTarget returns an error immediately because the caller explicitly chose +// that scheme. For opaque URIs (e.g. host:port), empty-scheme URIs, and parse +// failures, ParseTarget retries by prepending defaultScheme + ":///" if +// defaultScheme is non-empty. +// +// builder is a function that returns the resolver.Builder for a given scheme, +// or nil if no resolver is registered. Pass resolver.Get to use the global +// resolver registry, or a custom lookup function (e.g. cc.getResolver) to +// also consider resolvers registered via dial options. +func ParseTarget(target, defaultScheme string, builder func(string) resolver.Builder) (resolver.Target, error) { + u, err := url.Parse(target) + if err == nil && u.Scheme != "" && builder(u.Scheme) != nil { + return resolver.Target{URL: *u}, nil + } + // Hierarchical URI with an unregistered scheme: the caller explicitly + // chose this scheme, so do not silently fall back. + if err == nil && u.Scheme != "" && u.Opaque == "" { + return resolver.Target{}, fmt.Errorf("no resolver registered for scheme %q in target %q", u.Scheme, target) + } + // Parse error, empty scheme, or opaque URI (e.g. host:port): retry by + // prepending defaultScheme if one is provided. + if defaultScheme != "" { + if u2, err2 := url.Parse(defaultScheme + ":///" + target); err2 == nil && builder(u2.Scheme) != nil { + return resolver.Target{URL: *u2}, nil + } + } + if err != nil { + return resolver.Target{}, fmt.Errorf("invalid target URI %q: %v", target, err) + } + if u.Scheme == "" { + return resolver.Target{}, fmt.Errorf("target URI %q has no scheme", target) + } + return resolver.Target{}, fmt.Errorf("no resolver registered for scheme %q in target %q", u.Scheme, target) +} diff --git a/internal/resolver/target_test.go b/internal/resolver/target_test.go new file mode 100644 index 000000000000..cbb7e92b0fd4 --- /dev/null +++ b/internal/resolver/target_test.go @@ -0,0 +1,197 @@ +/* + * + * Copyright 2026 gRPC authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package resolver_test + +import ( + "strings" + "testing" + + iresolver "google.golang.org/grpc/internal/resolver" + _ "google.golang.org/grpc/internal/resolver/passthrough" // Register passthrough resolver. + "google.golang.org/grpc/resolver" + _ "google.golang.org/grpc/resolver/dns" // Register dns resolver. +) + +func TestParseTarget(t *testing.T) { + tests := []struct { + name string + target string + defaultScheme string + wantScheme string + wantErr bool + errContain string + }{ + { + name: "valid_dns_scheme", + target: "dns:///example.com:443", + wantScheme: "dns", + }, + { + name: "valid_passthrough_scheme", + target: "passthrough:///localhost:8080", + wantScheme: "passthrough", + }, + { + name: "valid_dns_scheme_with_default", + target: "dns:///example.com:443", + defaultScheme: "dns", + wantScheme: "dns", + }, + { + name: "missing_scheme_falls_back_to_default", + target: "/path/to/socket", + defaultScheme: "passthrough", + wantScheme: "passthrough", + }, + { + name: "missing_scheme_without_default", + target: "/path/to/socket", + wantErr: true, + errContain: "has no scheme", + }, + { + name: "host_port_retries_with_default_scheme", + target: "localhost:8080", + defaultScheme: "passthrough", + wantScheme: "passthrough", + }, + { + name: "host_port_without_default", + target: "localhost:8080", + wantErr: true, + errContain: "no resolver registered for scheme", + }, + { + name: "unregistered_scheme", + target: "unknown:///example.com:443", + wantErr: true, + errContain: "no resolver registered for scheme", + }, + { + name: "unregistered_hierarchical_scheme_no_fallback", + target: "unknown:///foo", + defaultScheme: "passthrough", + wantErr: true, + errContain: "no resolver registered for scheme", + }, + { + name: "invalid_URI", + target: "dns:///example\x00.com", + wantErr: true, + errContain: "invalid target URI", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := iresolver.ParseTarget(tt.target, tt.defaultScheme, resolver.Get) + if (err != nil) != tt.wantErr { + t.Errorf("ParseTarget(%q, %q) error = %v, wantErr %v", tt.target, tt.defaultScheme, err, tt.wantErr) + return + } + if tt.wantErr { + if tt.errContain != "" && !strings.Contains(err.Error(), tt.errContain) { + t.Errorf("ParseTarget(%q, %q) error = %q, want it to contain %q", tt.target, tt.defaultScheme, err, tt.errContain) + } + return + } + if got.URL.Scheme != tt.wantScheme { + t.Errorf("ParseTarget(%q, %q).URL.Scheme = %q, want %q", tt.target, tt.defaultScheme, got.URL.Scheme, tt.wantScheme) + } + }) + } +} + +func TestParseTargetWithCustomBuilder(t *testing.T) { + // A registry that only recognises "passthrough". This mirrors the + // cc.getResolver pattern in ClientConn, which may include resolvers + // registered via dial options that are invisible to resolver.Get. + passthroughOnly := func(scheme string) resolver.Builder { + if scheme == "passthrough" { + return resolver.Get("passthrough") + } + return nil + } + + tests := []struct { + name string + target string + defaultScheme string + wantScheme string + wantErr bool + errContain string + }{ + { + name: "known_scheme_resolves", + target: "passthrough:///service:8080", + wantScheme: "passthrough", + }, + { + name: "dns_not_in_custom_registry", + target: "dns:///example.com:443", + wantErr: true, + errContain: "no resolver registered for scheme", + }, + { + name: "unregistered_hierarchical_scheme_no_fallback", + target: "dns:///example.com:443", + defaultScheme: "passthrough", + wantErr: true, + errContain: "no resolver registered for scheme", + }, + { + // Opaque URI (host:port form) falls back to the default scheme. + name: "host_port_falls_back_to_custom_default", + target: "service:8080", + defaultScheme: "passthrough", + wantScheme: "passthrough", + }, + { + name: "missing_scheme_without_default", + target: "/path", + wantErr: true, + errContain: "has no scheme", + }, + { + name: "missing_scheme_uses_default", + target: "/path", + defaultScheme: "passthrough", + wantScheme: "passthrough", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := iresolver.ParseTarget(tt.target, tt.defaultScheme, passthroughOnly) + if (err != nil) != tt.wantErr { + t.Errorf("ParseTarget(%q, %q) error = %v, wantErr %v", tt.target, tt.defaultScheme, err, tt.wantErr) + return + } + if tt.wantErr { + if tt.errContain != "" && !strings.Contains(err.Error(), tt.errContain) { + t.Errorf("ParseTarget(%q, %q) error = %q, want it to contain %q", tt.target, tt.defaultScheme, err, tt.errContain) + } + return + } + if got.URL.Scheme != tt.wantScheme { + t.Errorf("ParseTarget(%q, %q).URL.Scheme = %q, want %q", tt.target, tt.defaultScheme, got.URL.Scheme, tt.wantScheme) + } + }) + } +}