Skip to content
Open
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
16 changes: 3 additions & 13 deletions balancer/rls/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,14 @@ import (
"bytes"
"encoding/json"
"fmt"
"net/url"
"time"

"google.golang.org/grpc/balancer"
"google.golang.org/grpc/balancer/rls/internal/keys"
"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"
Expand Down Expand Up @@ -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())
Expand Down
13 changes: 0 additions & 13 deletions balancer/rls/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -252,19 +252,6 @@ func (s) TestParseConfigErrors(t *testing.T) {
}`),
wantErr: "rls: empty lookup_service in route lookup config",
},
{
desc: "unregistered scheme in lookup service URI",
input: []byte(`{
"routeLookupConfig": {
"grpcKeybuilders": [{
"names": [{"service": "service", "method": "method"}],
"headers": [{"key": "k1", "names": ["v1"]}]
}],
"lookupService": "badScheme:///target"
}
}`),
wantErr: "rls: unregistered scheme in lookup_service",
},
{
desc: "invalid lookup service timeout",
input: []byte(`{
Expand Down
43 changes: 6 additions & 37 deletions clientconn.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ import (
"errors"
"fmt"
"math"
"net/url"
"slices"
"strings"
"sync"
Expand Down Expand Up @@ -1798,54 +1797,24 @@ 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
}
}

// 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.
// Compute the fallback scheme up front so ParseTarget can handle
// the retry internally in a single call. 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()
}
Comment on lines 1804 to 1807
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we move the computation of the default scheme higher up and pass the default scheme to the first call to iresolver.ParseTarget? That should allow us to get rid of the second ParseTarget below.


canonicalTarget := defScheme + ":///" + cc.target

parsedTarget, err = parseTarget(canonicalTarget)
parsedTarget, err := iresolver.ParseTarget(cc.target, defScheme, 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 {
Expand Down
60 changes: 60 additions & 0 deletions internal/resolver/target.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/*
*
* 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. When the scheme is unregistered, empty,
// or parsing fails, 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
}
// Parse error, empty scheme, or unregistered scheme: retry with the
// default scheme if one is provided.
if defaultScheme != "" && builder(defaultScheme) != nil {
canonicalTarget := defaultScheme + ":///" + target
if u2, err2 := url.Parse(canonicalTarget); err2 == 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)
}
195 changes: 195 additions & 0 deletions internal/resolver/target_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
/*
*
* 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_scheme_falls_back_to_default",
target: "unknown:///foo",
defaultScheme: "passthrough",
wantScheme: "passthrough",
},
{
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_scheme_falls_back_to_custom_default",
target: "dns:///example.com:443",
defaultScheme: "passthrough",
wantScheme: "passthrough",
},
{
// 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)
}
})
}
}
Loading