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

feat: add concrete errors to public API #36

Merged
merged 7 commits into from
Aug 12, 2021
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
22 changes: 18 additions & 4 deletions dialer.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
"sync"
"time"

"cloud.google.com/go/cloudsqlconn/errtypes"
"cloud.google.com/go/cloudsqlconn/internal/cloudsql"
"cloud.google.com/go/cloudsqlconn/internal/trace"
"golang.org/x/net/proxy"
Expand Down Expand Up @@ -153,22 +154,35 @@ func (d *Dialer) Dial(ctx context.Context, instance string, opts ...DialOption)
if err != nil {
// refresh the instance info in case it caused the connection failure
i.ForceRefresh()
return nil, err
return nil, &errtypes.DialError{ConnName: i.String(),
Message: "failed to dial", Err: err}
}
if c, ok := conn.(*net.TCPConn); ok {
if err := c.SetKeepAlive(true); err != nil {
return nil, fmt.Errorf("failed to set keep-alive: %v", err)
return nil, &errtypes.DialError{
ConnName: i.String(),
Message: "failed to set keep-alive",
Err: err,
}
}
if err := c.SetKeepAlivePeriod(cfg.tcpKeepAlive); err != nil {
return nil, fmt.Errorf("failed to set keep-alive period: %v", err)
return nil, &errtypes.DialError{
ConnName: i.String(),
Message: "failed to set keep-alive period",
Err: err,
}
}
}
tlsConn := tls.Client(conn, tlsCfg)
if err := tlsConn.Handshake(); err != nil {
// refresh the instance info in case it caused the handshake failure
i.ForceRefresh()
_ = tlsConn.Close() // best effort close attempt
return nil, fmt.Errorf("handshake failed: %w", err)
return nil, &errtypes.DialError{
ConnName: i.String(),
Message: "handshake failed",
Err: err,
}
}
return tlsConn, nil
}
Expand Down
41 changes: 16 additions & 25 deletions dialer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@ import (
"context"
"errors"
"io/ioutil"
"strings"
"testing"
"time"

"cloud.google.com/go/cloudsqlconn/errtypes"
"cloud.google.com/go/cloudsqlconn/internal/mock"
)

Expand Down Expand Up @@ -71,13 +71,6 @@ func TestDialerInstantiationErrors(t *testing.T) {
}
}

func errorContains(err error, want string) bool {
if err == nil {
return false
}
return strings.Contains(err.Error(), want)
}

func TestDialWithAdminAPIErrors(t *testing.T) {
inst := mock.NewFakeCSQLInstance("my-project", "my-region", "my-instance")
svc, cleanup, err := mock.NewSQLAdminService(context.Background())
Expand All @@ -98,25 +91,24 @@ func TestDialWithAdminAPIErrors(t *testing.T) {
}
d.sqladmin = svc

// instance name is bad
_, err = d.Dial(context.Background(), "bad-instance-name")
if !errorContains(err, "invalid instance") {
t.Fatalf("expected Dial to fail with bad instance name, but it succeeded.")
var wantErr1 *errtypes.ClientError
if !errors.As(err, &wantErr1) {
t.Fatalf("when instance name is invalid, want = %T, got = %v", wantErr1, err)
}

ctx, cancel := context.WithCancel(context.Background())
cancel()

// context is canceled
_, err = d.Dial(ctx, "my-project:my-region:my-instance")
if !errors.Is(err, context.Canceled) {
t.Fatalf("expected Dial to fail with canceled context, but it succeeded.")
t.Fatalf("when context is canceled, want = %T, got = %v", context.Canceled, err)
}

// failed to retrieve metadata or ephemeral cert (not registered in the mock)
_, err = d.Dial(context.Background(), "my-project:my-region:my-instance")
if !errorContains(err, "fetch metadata failed") {
t.Fatalf("expected Dial to fail with missing metadata")
var wantErr2 *errtypes.APIError
if !errors.As(err, &wantErr2) {
t.Fatalf("when API call fails, want = %T, got = %v", wantErr2, err)
}
}

Expand All @@ -142,24 +134,23 @@ func TestDialWithConfigurationErrors(t *testing.T) {
}
}()

// when failing to find private IP for public-only instance
_, err = d.Dial(context.Background(), "my-project:my-region:my-instance", WithPrivateIP())
if !errorContains(err, "does not have IP of type") {
t.Fatalf("expected Dial to fail with missing metadata")
var wantErr1 *errtypes.ClientError
if !errors.As(err, &wantErr1) {
t.Fatalf("when IP type is invalid, want = %T, got = %v", wantErr1, err)
}

// when Dialing TCP socket fails (no server proxy running)
_, err = d.Dial(context.Background(), "my-project:my-region:my-instance")
if !errorContains(err, "connection refused") {
t.Fatalf("expected Dial to fail with connection error")
var wantErr2 *errtypes.DialError
if !errors.As(err, &wantErr2) {
t.Fatalf("when server proxy socket is unavailable, want = %T, got = %v", wantErr2, err)
}

stop := mock.StartServerProxy(t, inst)
defer stop()

// when TLS handshake fails
_, err = d.Dial(context.Background(), "my-project:my-region:my-instance")
if !errorContains(err, "handshake failed") {
t.Fatalf("expected Dial to fail with connection error")
if !errors.As(err, &wantErr2) {
t.Fatalf("when TLS handshake fails, want = %T, got = %v", wantErr2, err)
}
}
17 changes: 17 additions & 0 deletions errtypes/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// Copyright 2021 Google LLC

// 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

// https://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 errtypes provides a number of concrete types which are used by the
// cloudsqlconn package.
package errtypes // import "cloud.google.com/go/cloudsqlconn/errtypes"
91 changes: 91 additions & 0 deletions errtypes/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// Copyright 2021 Google LLC

// 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

// https://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 errtypes

import "fmt"

type genericError struct {
Message string
ConnName string
}

func (e *genericError) Error() string {
return fmt.Sprintf("%v (connection name = %q)", e.Message, e.ConnName)
}

// NewClientError initializes a ClientError.
func NewClientError(msg, cn string) *ClientError {
return &ClientError{
genericError: &genericError{Message: "Client error: " + msg, ConnName: cn},
}
}

// ClientError represents an incorrect request by the client. Client errors
// usually indicate a semantic error (e.g., the instance connection name is
// malformated, the SQL instance does not support the requested IP type, etc.)
enocom marked this conversation as resolved.
Show resolved Hide resolved
type ClientError struct{ *genericError }

// NewServerError initializes a ServerError.
func NewServerError(msg, cn string) *ServerError {
enocom marked this conversation as resolved.
Show resolved Hide resolved
return &ServerError{
genericError: &genericError{Message: "Server error: " + msg, ConnName: cn},
}
}

// ServerError means the server returned with unexpected or invalid data. In
// general, this is an unexpected error and if a caller receives the error,
// there is likely a problem with the backend API or the instance itself (e.g.,
// missing certificates, invalid certificate encoding, region mismatch with the
// requested instance connection name, etc.)
type ServerError struct{ *genericError }

// APIError represents an error with the underlying network call to the SQL
// Admin API. APIErrors typically wrap Error types from the
// google.golang.org/api/googleapi package.
type APIError struct {
Op string
ConnName string
Message string
Err error
}

func (e *APIError) Error() string {
if e.Err != nil {
return fmt.Sprintf("API error: Operation %s failed (connection name = %q): %v",
e.Op, e.ConnName, e.Err)
}
return fmt.Sprintf("API error: Operation %s failed (connection name = %q)",
e.Op, e.ConnName)
}

func (e *APIError) Unwrap() error { return e.Err }

// DialError represents a problem that occurred when trying to dial a SQL
// instance (e.g., a failure to set the keep-alive property, a TLS handshake
// failure, a missing certificate, etc.)
type DialError struct {
ConnName string
Message string
Err error
}

func (e *DialError) Error() string {
if e.Err == nil {
return fmt.Sprintf("Dial error: %v (connection name = %q)", e.Message, e.ConnName)
}
return fmt.Sprintf("Dial error: %v (connection name = %q): %v", e.Message, e.ConnName, e.Err)
}

func (e *DialError) Unwrap() error { return e.Err }
85 changes: 85 additions & 0 deletions errtypes/errors_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// Copyright 2021 Google LLC

// 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

// https://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 errtypes_test

import (
"errors"
"testing"

"cloud.google.com/go/cloudsqlconn/errtypes"
)

func TestErrorFormatting(t *testing.T) {
tc := []struct {
desc string
err error
want string
}{
{
desc: "client error message",
err: errtypes.NewClientError("error message", "proj:reg:inst"),
want: "Client error: error message (connection name = \"proj:reg:inst\")",
},
{
desc: "server error message",
err: errtypes.NewServerError("error message", "proj:reg:inst"),
want: "Server error: error message (connection name = \"proj:reg:inst\")",
},
{
desc: "API error without inner error",
err: &errtypes.APIError{
Op: "Do.Something",
ConnName: "proj:reg:inst",
Message: "message",
Err: nil, // no error here
},
want: "API error: Operation Do.Something failed (connection name = \"proj:reg:inst\")",
},
{
desc: "API error with inner error",
err: &errtypes.APIError{
Op: "Do.Something",
ConnName: "proj:reg:inst",
Message: "message",
Err: errors.New("inner-error"),
},
want: "API error: Operation Do.Something failed (connection name = \"proj:reg:inst\"): inner-error",
},
{
desc: "Dial error without inner error",
err: &errtypes.DialError{
ConnName: "proj:reg:inst",
Message: "message",
Err: nil, // no error here
},
want: "Dial error: message (connection name = \"proj:reg:inst\")",
},
{
desc: "Dial error with inner error",
err: &errtypes.DialError{
ConnName: "proj:reg:inst",
Message: "message",
Err: errors.New("inner-error"),
},
want: "Dial error: message (connection name = \"proj:reg:inst\"): inner-error",
},
}

for _, c := range tc {
if got := c.err.Error(); got != c.want {
t.Errorf("%v, got = %q, want = %q", c.desc, got, c.want)
}
}
}
13 changes: 11 additions & 2 deletions internal/cloudsql/instance.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"sync"
"time"

"cloud.google.com/go/cloudsqlconn/errtypes"
sqladmin "google.golang.org/api/sqladmin/v1beta4"
)

Expand Down Expand Up @@ -54,7 +55,11 @@ func parseConnName(cn string) (connName, error) {
b := []byte(cn)
m := connNameRegex.FindSubmatch(b)
if m == nil {
return connName{}, fmt.Errorf("invalid instance connection name - expected PROJECT:REGION:ID")
err := errtypes.NewClientError(
"invalid instance connection name, expected PROJECT:REGION:INSTANCE",
cn,
)
return connName{}, err
}

c := connName{
Expand Down Expand Up @@ -178,7 +183,11 @@ func (i *Instance) ConnectInfo(ctx context.Context, ipType string) (string, *tls
}
addr, ok := res.md.ipAddrs[ipType]
if !ok {
return "", nil, fmt.Errorf("instance '%s' does not have IP of type '%s'", i, ipType)
err := errtypes.NewClientError(
fmt.Sprintf("instance does not have IP of type %q", ipType),
i.String(),
)
return "", nil, err
}
return addr, res.tlsCfg, nil
}
Expand Down
6 changes: 4 additions & 2 deletions internal/cloudsql/instance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"testing"
"time"

"cloud.google.com/go/cloudsqlconn/errtypes"
"cloud.google.com/go/cloudsqlconn/internal/mock"
)

Expand Down Expand Up @@ -127,8 +128,9 @@ func TestConnectInfoErrors(t *testing.T) {
}

_, _, err = im.ConnectInfo(ctx, PublicIP)
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("failed to retrieve connect info: %v", err)
var wantErr *errtypes.DialError
if !errors.As(err, &wantErr) {
t.Fatalf("when connect info fails, want = %T, got = %v", wantErr, err)
}

// when client asks for wrong IP address type
Expand Down
Loading