Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

improvement: WithAllowCreateWithEmptyURIs() suppresses errors due to empty URIs in NewClient() #693

Merged
merged 8 commits into from
Sep 27, 2024
6 changes: 6 additions & 0 deletions changelog/@unreleased/pr-693.v2.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
type: improvement
improvement:
description: WithAllowCreateWithEmptyURIs() suppresses errors due to empty URIs
in NewClient()
links:
- https://github.com/palantir/conjure-go-runtime/pull/693
3 changes: 2 additions & 1 deletion conjure-go-client/httpclient/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ type Client interface {
}

type clientImpl struct {
serviceName refreshable.String
client RefreshableHTTPClient
middlewares []Middleware
errorDecoderMiddleware Middleware
Expand Down Expand Up @@ -84,7 +85,7 @@ func (c *clientImpl) Delete(ctx context.Context, params ...RequestParam) (*http.
func (c *clientImpl) Do(ctx context.Context, params ...RequestParam) (*http.Response, error) {
uris := c.uriScorer.CurrentURIScoringMiddleware().GetURIsInOrderOfIncreasingScore()
if len(uris) == 0 {
return nil, werror.ErrorWithContextParams(ctx, "no base URIs are configured")
return nil, werror.WrapWithContextParams(ctx, ErrEmptyURIs, "", werror.SafeParam("serviceName", c.serviceName.CurrentString()))
}

attempts := 2 * len(uris)
Expand Down
28 changes: 22 additions & 6 deletions conjure-go-client/httpclient/client_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package httpclient
import (
"context"
"crypto/tls"
"fmt"
"net/http"
"time"

Expand Down Expand Up @@ -45,12 +46,23 @@ const (
defaultMaxBackoff = 2 * time.Second
)

var (
// ErrEmptyURIs is returned when the client expects to have base URIs configured to make requests, but the URIs are empty.
// This check occurs in two places: when the client is constructed and when a request is executed.
// To avoid the construction validation, use WithAllowCreateWithEmptyURIs().
ErrEmptyURIs = fmt.Errorf("httpclient URLs must not be empty")
)

type clientBuilder struct {
HTTP *httpClientBuilder

URIs refreshable.StringSlice
URIScorerBuilder func([]string) internal.URIScoringMiddleware

// If false, NewClient() will return an error when URIs.Current() is empty.
// This allows for a refreshable URI slice to be populated after construction but before use.
AllowEmptyURIs bool

ErrorDecoder ErrorDecoder

BytesBufferPool bytesbuffers.Pool
Expand Down Expand Up @@ -120,7 +132,7 @@ func NewClient(params ...ClientParam) (Client, error) {
// We apply "sane defaults" before applying the provided params.
func NewClientFromRefreshableConfig(ctx context.Context, config RefreshableClientConfig, params ...ClientParam) (Client, error) {
b := newClientBuilder()
if err := newClientBuilderFromRefreshableConfig(ctx, config, b, nil, false); err != nil {
if err := newClientBuilderFromRefreshableConfig(ctx, config, b, nil); err != nil {
return nil, err
}
return newClient(ctx, b, params...)
Expand All @@ -136,7 +148,10 @@ func newClient(ctx context.Context, b *clientBuilder, params ...ClientParam) (Cl
}
}
if b.URIs == nil {
return nil, werror.Error("httpclient URLs must not be empty", werror.SafeParam("serviceName", b.HTTP.ServiceName.CurrentString()))
return nil, werror.ErrorWithContextParams(ctx, "httpclient URLs must be set in configuration or by constructor param", werror.SafeParam("serviceName", b.HTTP.ServiceName.CurrentString()))
}
if !b.AllowEmptyURIs && len(b.URIs.CurrentStringSlice()) == 0 {
return nil, werror.WrapWithContextParams(ctx, ErrEmptyURIs, "", werror.SafeParam("serviceName", b.HTTP.ServiceName.CurrentString()))
}

var edm Middleware
Expand All @@ -163,6 +178,7 @@ func newClient(ctx context.Context, b *clientBuilder, params ...ClientParam) (Cl
return b.URIScorerBuilder(uris)
})
return &clientImpl{
serviceName: b.HTTP.ServiceName,
client: httpClient,
uriScorer: uriScorer,
maxAttempts: b.MaxAttempts,
Expand Down Expand Up @@ -192,7 +208,7 @@ type RefreshableHTTPClient = refreshingclient.RefreshableHTTPClient
// We apply "sane defaults" before applying the provided params.
func NewHTTPClientFromRefreshableConfig(ctx context.Context, config RefreshableClientConfig, params ...HTTPClientParam) (RefreshableHTTPClient, error) {
b := newClientBuilder()
if err := newClientBuilderFromRefreshableConfig(ctx, config, b, nil, true); err != nil {
if err := newClientBuilderFromRefreshableConfig(ctx, config, b, nil); err != nil {
return nil, err
}
return b.HTTP.Build(ctx, params...)
Expand Down Expand Up @@ -242,18 +258,18 @@ func newClientBuilder() *clientBuilder {
}
}

func newClientBuilderFromRefreshableConfig(ctx context.Context, config RefreshableClientConfig, b *clientBuilder, reloadErrorSubmitter func(error), isHTTPClient bool) error {
func newClientBuilderFromRefreshableConfig(ctx context.Context, config RefreshableClientConfig, b *clientBuilder, reloadErrorSubmitter func(error)) error {
refreshingParams, err := refreshable.NewMapValidatingRefreshable(config, func(i interface{}) (interface{}, error) {
p, err := newValidatedClientParamsFromConfig(ctx, i.(ClientConfig), isHTTPClient)
p, err := newValidatedClientParamsFromConfig(ctx, i.(ClientConfig))
if reloadErrorSubmitter != nil {
reloadErrorSubmitter(err)
}
return p, err
})
validParams := refreshingclient.NewRefreshingValidatedClientParams(refreshingParams)
if err != nil {
return err
}
validParams := refreshingclient.NewRefreshingValidatedClientParams(refreshingParams)

b.HTTP.ServiceName = validParams.ServiceName()
b.HTTP.DialerParams = validParams.Dialer()
Expand Down
10 changes: 10 additions & 0 deletions conjure-go-client/httpclient/client_params.go
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,16 @@ func WithRefreshableBaseURLs(urls refreshable.StringSlice) ClientParam {
})
}

// WithAllowCreateWithEmptyURIs prevents NewClient from returning an error when the URI slice is empty.
// This is useful when the URIs are not known at client creation time but will be populated by a refreshable.
// Requests will error if attempted before URIs are populated.
func WithAllowCreateWithEmptyURIs() ClientParam {
return clientParamFunc(func(b *clientBuilder) error {
b.AllowEmptyURIs = true
return nil
})
}

// WithMaxBackoff sets the maximum backoff between retried calls to the same URI.
// Defaults to 2 seconds. <= 0 indicates no limit.
func WithMaxBackoff(maxBackoff time.Duration) ClientParam {
Expand Down
2 changes: 1 addition & 1 deletion conjure-go-client/httpclient/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import (

func TestNoBaseURIs(t *testing.T) {
client, err := httpclient.NewClient()
require.EqualError(t, err, "httpclient URLs must not be empty")
require.EqualError(t, err, "httpclient URLs must be set in configuration or by constructor param")
require.Nil(t, client)
}

Expand Down
10 changes: 3 additions & 7 deletions conjure-go-client/httpclient/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import (
"context"
"io/ioutil"
"net/url"
"sort"
"slices"
"time"

"github.com/palantir/conjure-go-runtime/v2/conjure-go-client/httpclient/internal/refreshingclient"
Expand Down Expand Up @@ -385,7 +385,7 @@ func RefreshableClientConfigFromServiceConfig(servicesConfig RefreshableServices
}))
}

func newValidatedClientParamsFromConfig(ctx context.Context, config ClientConfig, isHTTPClient bool) (refreshingclient.ValidatedClientParams, error) {
func newValidatedClientParamsFromConfig(ctx context.Context, config ClientConfig) (refreshingclient.ValidatedClientParams, error) {
dialer := refreshingclient.DialerParams{
DialTimeout: derefPtr(config.ConnectTimeout, defaultDialTimeout),
KeepAlive: derefPtr(config.KeepAlive, defaultKeepAlive),
Expand Down Expand Up @@ -477,11 +477,7 @@ func newValidatedClientParamsFromConfig(ctx context.Context, config ClientConfig
}
uris = append(uris, uriStr)
}
// Plain HTTP clients do not store URIs
if !isHTTPClient && len(uris) == 0 {
return refreshingclient.ValidatedClientParams{}, werror.ErrorWithContextParams(ctx, "httpclient URLs must not be empty")
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is the bug fix -- this check was too early. It should be possible to "fix" the empty config with WithBaseURLs(...) or a variant, but in that case this would still fail because it operates only on the original ClientConfig struct.

}
sort.Strings(uris)
slices.Sort(uris)

return refreshingclient.ValidatedClientParams{
APIToken: apiToken,
Expand Down
36 changes: 33 additions & 3 deletions conjure-go-client/httpclient/config_refreshable_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,10 +104,40 @@ func TestRefreshableClientConfig(t *testing.T) {
}
return c
}))
refreshableClientConfig := RefreshableClientConfigFromServiceConfig(refreshableServicesConfig, serviceName)
_, err = NewClientFromRefreshableConfig(context.Background(), refreshableClientConfig)
require.EqualError(t, err, "httpclient URLs must not be empty")

t.Run("refreshable config without uris fails", func(t *testing.T) {
getClientURIs := func(client Client) []string {
return client.(*clientImpl).uriScorer.CurrentURIScoringMiddleware().GetURIsInOrderOfIncreasingScore()
}
refreshableClientConfig := RefreshableClientConfigFromServiceConfig(refreshableServicesConfig, serviceName)
client, err := NewClientFromRefreshableConfig(context.Background(), refreshableClientConfig)
require.EqualError(t, err, "httpclient URLs must not be empty")
require.Nil(t, client)

client, err = NewClientFromRefreshableConfig(context.Background(), refreshableClientConfig, WithBaseURLs([]string{"https://localhost"}))
require.NoError(t, err, "expected to successfully create client using WithBaseURL even when config has no URIs")
require.Equal(t, []string{"https://localhost"}, getClientURIs(client), "expected URIs to be set")

client, err = NewClientFromRefreshableConfig(context.Background(), refreshableClientConfig, WithRefreshableBaseURLs(refreshable.NewStringSlice(refreshable.NewDefaultRefreshable([]string{"https://localhost"}))))
require.NoError(t, err, "expected to successfully create client using WithRefreshableBaseURLs even when config has no URIs")
require.Equal(t, []string{"https://localhost"}, getClientURIs(client), "expected URIs to be set")

t.Run("WithAllowCreateWithEmptyURIs", func(t *testing.T) {
client, err := NewClientFromRefreshableConfig(context.Background(), refreshableClientConfig, WithAllowCreateWithEmptyURIs())
require.NoError(t, err, "expected to create a client from empty client config with WithAllowCreateWithEmptyURIs")

// Expect error making request
_, err = client.Get(context.Background())
require.EqualError(t, err, ErrEmptyURIs.Error())
// Update config
initialConfig.Services[serviceName] = ClientConfig{ServiceName: serviceName, URIs: []string{"https://localhost"}}
updateRefreshableBytes(initialConfig)

require.Equal(t, []string{"https://localhost"}, getClientURIs(client), "expected URIs to be set")
})
})

refreshableClientConfig := RefreshableClientConfigFromServiceConfig(refreshableServicesConfig, serviceName)
initialConfig.Services[serviceName] = ClientConfig{ServiceName: serviceName, URIs: []string{"https://localhost"}}
updateRefreshableBytes(initialConfig)
client, err := NewClientFromRefreshableConfig(context.Background(), refreshableClientConfig)
Expand Down