Repository: hetznercloud/hcloud-go
Pull request #374 (2024-01-12 15:44:27)

feat(error): handle wrapped errors in IsError()
`hcloud.IsError()` currently does a type assertion to get our error type. This breaks if the error was previously wrapped, causing it to always return "false". This happens often if the error is passed through multiple functions before error handling is being done.

Example of code that is currently broken:

```go
// Error returned by hcloud-go
err := hcloud.Error{ Code: hcloud.ErrorCodeUnauthorized }

// Error gets wrapped at some point in the call-chain
err = fmt.Errorf("failed to foobar: %w", err)

// Now the error is not properly identified
hcloud.IsError(err, hcloud.ErrorCodeUnauthorized) // false
```


Patch:
diff --git a/hcloud/error.go b/hcloud/error.go
index 653043e..2b5c493 100644
--- a/hcloud/error.go
+++ b/hcloud/error.go
@@ -1,6 +1,7 @@
 package hcloud
 
 import (
+	"errors"
 	"fmt"
 	"net"
 )
@@ -120,7 +121,8 @@ type ErrorDetailsInvalidInputField struct {
 
 // IsError returns whether err is an API error with the given error code.
 func IsError(err error, code ErrorCode) bool {
-	apiErr, ok := err.(Error)
+	var apiErr Error
+	ok := errors.As(err, &apiErr)
 	return ok && apiErr.Code == code
 }
 


Test patch:
diff --git a/hcloud/error_test.go b/hcloud/error_test.go
new file mode 100644
index 0000000..300adde
--- /dev/null
+++ b/hcloud/error_test.go
@@ -0,0 +1,101 @@
+package hcloud
+
+import (
+	"fmt"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestError_Error(t *testing.T) {
+	type fields struct {
+		Code     ErrorCode
+		Message  string
+		Details  interface{}
+		response *Response
+	}
+	tests := []struct {
+		name   string
+		fields fields
+		want   string
+	}{
+		{
+			name: "simple error",
+			fields: fields{
+				Code:    ErrorCodeUnauthorized,
+				Message: "unable to authenticate",
+			},
+			want: "unable to authenticate (unauthorized)",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			e := Error{
+				Code:     tt.fields.Code,
+				Message:  tt.fields.Message,
+				Details:  tt.fields.Details,
+				response: tt.fields.response,
+			}
+			assert.Equalf(t, tt.want, e.Error(), "Error()")
+		})
+	}
+}
+
+func TestIsError(t *testing.T) {
+	type args struct {
+		err  error
+		code ErrorCode
+	}
+	tests := []struct {
+		name string
+		args args
+		want bool
+	}{
+		{
+			name: "hcloud error with code",
+			args: args{
+				err:  Error{Code: ErrorCodeUnauthorized},
+				code: ErrorCodeUnauthorized,
+			},
+			want: true,
+		},
+		{
+			name: "hcloud error with different code",
+			args: args{
+				err:  Error{Code: ErrorCodeConflict},
+				code: ErrorCodeUnauthorized,
+			},
+			want: false,
+		},
+		{
+			name: "wrapped hcloud error with code",
+			args: args{
+				err:  fmt.Errorf("wrapped: %w", Error{Code: ErrorCodeUnauthorized}),
+				code: ErrorCodeUnauthorized,
+			},
+			want: true,
+		},
+		{
+			name: "wrapped hcloud error with different code",
+			args: args{
+				err:  fmt.Errorf("wrapped: %w", Error{Code: ErrorCodeConflict}),
+				code: ErrorCodeUnauthorized,
+			},
+			want: false,
+		},
+		{
+			name: "non-hcloud error",
+			args: args{
+				err:  fmt.Errorf("something went wrong"),
+				code: ErrorCodeUnauthorized,
+			},
+			want: false,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			assert.Equalf(t, tt.want, IsError(tt.args.err, tt.args.code), "IsError(%v, %v)", tt.args.err, tt.args.code)
+		})
+	}
+}