diff --git a/.chloggen/consumererror-otlp-type.yaml b/.chloggen/consumererror-otlp-type.yaml new file mode 100644 index 00000000000..c741078ba42 --- /dev/null +++ b/.chloggen/consumererror-otlp-type.yaml @@ -0,0 +1,27 @@ +# Use this changelog template to create an entry for release notes. + +# One of 'breaking', 'deprecation', 'new_component', 'enhancement', 'bug_fix' +change_type: enhancement + +# The name of the component, or a single word describing the area of concern, (e.g. otlpreceiver) +component: consumererror + +# A brief description of the change. Surround your text with quotes ("") if it needs to start with a backtick (`). +note: Add `Error` type + +# One or more tracking issues or pull requests related to the change +issues: [7047] + +# (Optional) One or more lines of additional information to render under the primary note. +# These lines will be padded with 2 spaces and then inserted directly into the document. +# Use pipe (|) for multiline entries. +subtext: | + This type can contain information about errors that allow components (e.g. exporters) + to communicate error information back up the pipeline. + +# Optional: The change log or logs in which this entry should be included. +# e.g. '[user]' or '[user, api]' +# Include 'user' if the change is relevant to end users. +# Include 'api' if there is a change to a library API. +# Default: '[user]' +change_logs: [api] diff --git a/consumer/consumererror/error.go b/consumer/consumererror/error.go new file mode 100644 index 00000000000..00f0ddc6296 --- /dev/null +++ b/consumer/consumererror/error.go @@ -0,0 +1,176 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package consumererror // import "go.opentelemetry.io/collector/consumer/consumererror" + +import ( + "errors" + "fmt" + "net/http" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "go.opentelemetry.io/collector/consumer/consumererror/internal/statusconversion" +) + +// Error is intended to be used to encapsulate various information that can add +// context to an error that occurred within a pipeline component. Error objects +// are constructed through calling `New` with the relevant options to capture +// data around the error that occurred. +// +// Error should be obtained from a given `error` object using `errors.As`. +type Error struct { + error + httpStatus int + grpcStatus *status.Status + isRetryable bool +} + +var _ error = (*Error)(nil) + +// NewOTLPHTTPError records an HTTP status code that was received from a server +// during data submission. +// +// NOTE: This function will panic if passed an HTTP status between 200 and 299 inclusive. +// This is to reserve the behavior for handling these codes for the future. +func NewOTLPHTTPError(origErr error, httpStatus int) error { + // Matches what is considered a successful response in the OTLP/HTTP Exporter. + if httpStatus >= 200 && httpStatus <= 299 { + panic("NewOTLPHTTPError should not be called with a success code") + } + var retryable bool + switch httpStatus { + case http.StatusTooManyRequests, http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout: + retryable = true + } + + return &Error{error: origErr, httpStatus: httpStatus, isRetryable: retryable} +} + +// NewOTLPGRPCError records a gRPC status code that was received from a server +// during data submission. +// +// NOTE: This function will panic if passed a *status.Status with an underlying +// code of codes.OK. This is to reserve the behavior for handling this code for +// the future. +func NewOTLPGRPCError(origErr error, status *status.Status) error { + var retryable bool + if status != nil { + switch status.Code() { + case codes.Canceled, codes.DeadlineExceeded, codes.Aborted, codes.OutOfRange, codes.Unavailable, codes.DataLoss: + retryable = true + // Matches what is considered a successful response in the OTLP Exporter. + case codes.OK: + panic("NewOTLPGRPCError should not be called with an OK code") + } + } + + return &Error{error: origErr, grpcStatus: status, isRetryable: retryable} +} + +// NewRetryableError records that this error is retryable according to OTLP specification. +func NewRetryableError(origErr error) error { + return &Error{error: origErr, isRetryable: true} +} + +// Error implements the error interface. +// +// If an error object was given, that is used. +// Otherwise, the gRPC error from the status.Status is used, +// or an error message containing the HTTP status code is given. +func (e *Error) Error() string { + if e.error != nil { + return e.error.Error() + } + + if e.grpcStatus != nil { + return e.grpcStatus.Err().Error() + } else if e.httpStatus > 0 { + return fmt.Sprintf("network error: received HTTP status %d", e.httpStatus) + } + + return "uninitialized consumererror.Error" +} + +// Unwrap returns the wrapped error for use by `errors.Is` and `errors.As`. +// +// If an error object was not passed but a gRPC `status.Status` was passed, +// the underlying error from the status is returned. +func (e *Error) Unwrap() error { + if e.error != nil { + return e.error + } + + if e.grpcStatus != nil { + return e.grpcStatus.Err() + } + + return nil +} + +// IsRetryable returns true if the error was created with NewRetryableError, if +// the HTTP status code is retryable according to OTLP, or if the gRPC status is +// retryable according to OTLP. Otherwise, returns false. +// +// See https://github.com/open-telemetry/opentelemetry-proto/blob/main/docs/specification.md for retryable +// HTTP and gRPC codes. +func (e *Error) IsRetryable() bool { + return e.isRetryable +} + +// ToHTTPStatus returns an HTTP status code either directly set by the source on +// an [Error] object, derived from a gRPC status code set by the source, or +// derived from Retryable. When deriving the value, the OTLP specification is +// used to map to HTTP. See +// https://github.com/open-telemetry/opentelemetry-proto/blob/main/docs/specification.md +// for more details. +// +// If a http status code cannot be derived from these three sources then 500 is +// returned. +func ToHTTPStatus(err error) int { + var e *Error + if errors.As(err, &e) { + if e.httpStatus != 0 { + return e.httpStatus + } + if e.grpcStatus != nil { + return statusconversion.GetHTTPStatusCodeFromStatus(e.grpcStatus) + } + if e.isRetryable { + return http.StatusServiceUnavailable + } + } + return http.StatusInternalServerError +} + +// ToGRPCStatus returns a gRPC status code either directly set by the source on +// an [Error] object, derived from an HTTP status code set by the +// source, or derived from Retryable. When deriving the value, the OTLP +// specification is used to map to gRPC. See +// https://github.com/open-telemetry/opentelemetry-proto/blob/main/docs/specification.md +// for more details. +// +// If an [Error] object is not present, then we attempt to get a status.Status from the +// error tree. +// +// If a status.Status cannot be derived from these sources then INTERNAL is +// returned. +func ToGRPCStatus(err error) *status.Status { + var e *Error + if errors.As(err, &e) { + if e.grpcStatus != nil { + return e.grpcStatus + } + if e.httpStatus != 0 { + return statusconversion.NewStatusFromMsgAndHTTPCode(e.Error(), e.httpStatus) + } + if e.isRetryable { + return status.New(codes.Unavailable, e.Error()) + } + } + if st, ok := status.FromError(err); ok { + return st + } + return status.New(codes.Unknown, e.Error()) +} diff --git a/consumer/consumererror/error_test.go b/consumer/consumererror/error_test.go new file mode 100644 index 00000000000..f95b801748a --- /dev/null +++ b/consumer/consumererror/error_test.go @@ -0,0 +1,379 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package consumererror + +import ( + "errors" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +var errTest = errors.New("consumererror testing error") + +func Test_NewOTLPHTTPError(t *testing.T) { + httpStatus := 500 + wantErr := &Error{ + error: errTest, + httpStatus: httpStatus, + } + + newErr := NewOTLPHTTPError(errTest, httpStatus) + + require.Equal(t, wantErr, newErr) +} + +func Test_NewOTLPGRPCError(t *testing.T) { + grpcStatus := status.New(codes.Aborted, "aborted") + wantErr := &Error{ + error: errTest, + grpcStatus: grpcStatus, + isRetryable: true, + } + + newErr := NewOTLPGRPCError(errTest, grpcStatus) + + require.Equal(t, wantErr, newErr) +} + +func Test_NewRetryableError(t *testing.T) { + wantErr := &Error{ + error: errTest, + isRetryable: true, + } + + newErr := NewRetryableError(errTest) + + require.Equal(t, wantErr, newErr) +} + +func Test_Error(t *testing.T) { + newErr := Error{error: errTest} + + require.Equal(t, errTest.Error(), newErr.Error()) +} + +func TestUnwrap(t *testing.T) { + grpcErr := status.New(codes.InvalidArgument, "not allowed") + testCases := []struct { + name string + err *Error + expected error + }{ + { + name: "Error object", + err: &Error{ + error: errTest, + grpcStatus: grpcErr, + }, + expected: errTest, + }, + { + name: "gRPC error", + err: &Error{ + grpcStatus: grpcErr, + }, + expected: grpcErr.Err(), + }, + { + name: "zero value struct", + err: &Error{}, + expected: nil, + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.expected, tt.err.Unwrap()) + }) + } +} + +func TestAs(t *testing.T) { + err := &Error{ + error: errTest, + } + + secondError := errors.Join(errors.New("test"), err) + + var e *Error + require.ErrorAs(t, secondError, &e) + assert.Equal(t, errTest.Error(), e.Error()) +} + +func TestError_Error(t *testing.T) { + testCases := []struct { + name string + err *Error + expected string + }{ + { + name: "Error object", + err: &Error{ + error: errTest, + grpcStatus: status.New(codes.InvalidArgument, "not allowed"), + httpStatus: 400, + }, + expected: errTest.Error(), + }, + { + name: "gRPC error", + err: &Error{ + grpcStatus: status.New(codes.InvalidArgument, "not allowed"), + }, + expected: "rpc error: code = InvalidArgument desc = not allowed", + }, + { + name: "HTTP error", + err: &Error{ + httpStatus: 400, + }, + expected: "network error: received HTTP status 400", + }, + { + name: "zero value struct", + err: &Error{}, + expected: "uninitialized consumererror.Error", + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.expected, tt.err.Error()) + }) + } +} + +func TestError_Unwrap(t *testing.T) { + err := &Error{ + error: errTest, + } + + require.Equal(t, errTest, err.Unwrap()) +} + +func TestError_ToHTTPStatus(t *testing.T) { + serverErr := http.StatusTooManyRequests + testCases := []struct { + name string + httpStatus int + grpcStatus *status.Status + retryable bool + want int + hasCode bool + }{ + { + name: "Passes through HTTP status", + httpStatus: serverErr, + want: serverErr, + hasCode: true, + }, + { + name: "Converts gRPC status", + grpcStatus: status.New(codes.ResourceExhausted, errTest.Error()), + want: serverErr, + hasCode: true, + }, + { + name: "Passes through HTTP status when gRPC status also present", + httpStatus: serverErr, + grpcStatus: status.New(codes.OK, errTest.Error()), + want: serverErr, + hasCode: true, + }, + { + name: "Passes through HTTP status when retryable also present", + httpStatus: serverErr, + retryable: true, + want: serverErr, + }, + { + name: "No statuses set with retryable", + retryable: true, + want: http.StatusServiceUnavailable, + }, + { + name: "No statuses set", + want: http.StatusInternalServerError, + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + err := &Error{ + error: errTest, + httpStatus: tt.httpStatus, + grpcStatus: tt.grpcStatus, + isRetryable: tt.retryable, + } + + s := ToHTTPStatus(err) + + require.Equal(t, tt.want, s) + }) + } +} + +func TestError_ToGRPCStatus(t *testing.T) { + httpStatus := http.StatusTooManyRequests + otherOTLPHTTPStatus := http.StatusOK + serverErr := status.New(codes.ResourceExhausted, errTest.Error()) + testCases := []struct { + name string + httpStatus int + grpcStatus *status.Status + retryable bool + want *status.Status + hasCode bool + }{ + { + name: "Converts HTTP status", + httpStatus: httpStatus, + want: serverErr, + hasCode: true, + }, + { + name: "Passes through gRPC status", + grpcStatus: serverErr, + want: serverErr, + hasCode: true, + }, + { + name: "Passes through gRPC status when HTTP status also present", + httpStatus: otherOTLPHTTPStatus, + grpcStatus: serverErr, + want: serverErr, + hasCode: true, + }, + { + name: "Passes through gRPC status when retryable also present", + grpcStatus: serverErr, + retryable: true, + want: serverErr, + }, + { + name: "No statuses set with retryable", + retryable: true, + want: status.New(codes.Unavailable, errTest.Error()), + }, + { + name: "No statuses set", + want: status.New(codes.Unknown, errTest.Error()), + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + err := &Error{ + error: errTest, + httpStatus: tt.httpStatus, + grpcStatus: tt.grpcStatus, + isRetryable: tt.retryable, + } + + s := ToGRPCStatus(err) + + require.Equal(t, tt.want, s) + }) + } +} + +func TestStatus_ToGRPCStatus(t *testing.T) { + st := status.New(codes.ResourceExhausted, errTest.Error()) + require.Equal(t, st, ToGRPCStatus(st.Err())) +} + +func TestError_Retryable(t *testing.T) { + retryableCodes := []codes.Code{codes.Canceled, codes.DeadlineExceeded, codes.Aborted, codes.OutOfRange, codes.Unavailable, codes.DataLoss} + retryableStatuses := []*status.Status{} + + for _, code := range retryableCodes { + retryableStatuses = append(retryableStatuses, status.New(code, errTest.Error())) + } + + nonretryableCodes := []codes.Code{codes.Unauthenticated, codes.Unknown, codes.NotFound, codes.InvalidArgument} + nonretryableStatuses := []*status.Status{} + + for _, code := range nonretryableCodes { + nonretryableStatuses = append(nonretryableStatuses, status.New(code, errTest.Error())) + } + + testCases := []struct { + name string + httpStatuses []int + grpcStatuses []*status.Status + want bool + }{ + { + name: "HTTP statuses: retryable", + httpStatuses: []int{http.StatusTooManyRequests, http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout}, + want: true, + }, + { + name: "HTTP statuses: non-retryable", + httpStatuses: []int{0, http.StatusInternalServerError, http.StatusNotFound, http.StatusUnauthorized}, + want: false, + }, + { + name: "gRPC statuses: retryable", + grpcStatuses: retryableStatuses, + want: true, + }, + { + name: "gRPC statuses: non-retryable", + grpcStatuses: nonretryableStatuses, + want: false, + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + for _, httpStatus := range tt.httpStatuses { + err := NewOTLPHTTPError(errTest, httpStatus) + var httpErr *Error + if errors.As(err, &httpErr) { + require.Equal(t, tt.want, httpErr.IsRetryable(), "Expected %d to be retryable=%t", httpStatus, tt.want) + } else { + require.Fail(t, "NewOTLPHTTPError didn't return an *Error") + } + } + + for _, grpcStatus := range tt.grpcStatuses { + err := NewOTLPGRPCError(errTest, grpcStatus) + var grpcErr *Error + + if errors.As(err, &grpcErr) { + require.Equal(t, tt.want, grpcErr.IsRetryable(), "Expected %q to be retryable=%t", grpcStatus.Code().String(), tt.want) + } else { + require.Fail(t, "NewOTLPGRPCError didn't return an *Error") + } + } + }) + } +} + +func TestSuccessCodes(t *testing.T) { + require.Panics(t, func() { + _ = NewOTLPHTTPError(nil, 200) + }) + require.Panics(t, func() { + _ = NewOTLPHTTPError(nil, 299) + }) + require.NotPanics(t, func() { + require.Error(t, NewOTLPHTTPError(nil, 199)) + }) + require.NotPanics(t, func() { + require.Error(t, NewOTLPHTTPError(nil, 300)) + }) + require.Panics(t, func() { + _ = NewOTLPGRPCError(nil, status.New(codes.OK, "")) + }) + require.NotPanics(t, func() { + require.Error(t, NewOTLPGRPCError(nil, status.New(codes.AlreadyExists, ""))) + }) +} diff --git a/consumer/consumererror/go.mod b/consumer/consumererror/go.mod index 020bc914e79..30d3f66f189 100644 --- a/consumer/consumererror/go.mod +++ b/consumer/consumererror/go.mod @@ -8,6 +8,7 @@ require ( go.opentelemetry.io/collector/pdata/pprofile v0.129.0 go.opentelemetry.io/collector/pdata/testdata v0.129.0 go.uber.org/goleak v1.3.0 + google.golang.org/grpc v1.73.0 ) require ( @@ -22,7 +23,6 @@ require ( golang.org/x/sys v0.32.0 // indirect golang.org/x/text v0.24.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect - google.golang.org/grpc v1.73.0 // indirect google.golang.org/protobuf v1.36.6 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/consumer/consumererror/internal/statusconversion/conversion.go b/consumer/consumererror/internal/statusconversion/conversion.go new file mode 100644 index 00000000000..4c34cd0681c --- /dev/null +++ b/consumer/consumererror/internal/statusconversion/conversion.go @@ -0,0 +1,64 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package statusconversion // import "go.opentelemetry.io/collector/consumer/consumererror/internal/statusconversion" + +import ( + "net/http" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func GetHTTPStatusCodeFromStatus(s *status.Status) int { + // See https://github.com/open-telemetry/opentelemetry-proto/blob/main/docs/specification.md#failures + // to see if a code is retryable. + // See https://github.com/open-telemetry/opentelemetry-proto/blob/main/docs/specification.md#failures-1 + // to see a list of retryable http status codes. + switch s.Code() { + // Retryable + case codes.Canceled, codes.DeadlineExceeded, codes.Aborted, codes.OutOfRange, codes.Unavailable, codes.DataLoss: + return http.StatusServiceUnavailable + // Retryable + case codes.ResourceExhausted: + return http.StatusTooManyRequests + // Not Retryable + case codes.InvalidArgument: + return http.StatusBadRequest + // Not Retryable + case codes.Unauthenticated: + return http.StatusUnauthorized + // Not Retryable + case codes.PermissionDenied: + return http.StatusForbidden + // Not Retryable + case codes.Unimplemented: + return http.StatusNotFound + // Not Retryable + default: + return http.StatusInternalServerError + } +} + +func NewStatusFromMsgAndHTTPCode(errMsg string, statusCode int) *status.Status { + var c codes.Code + // Mapping based on https://github.com/grpc/grpc/blob/master/doc/http-grpc-status-mapping.md + // 429 mapping to ResourceExhausted and 400 mapping to StatusBadRequest are exceptions. + switch statusCode { + case http.StatusBadRequest: + c = codes.InvalidArgument + case http.StatusUnauthorized: + c = codes.Unauthenticated + case http.StatusForbidden: + c = codes.PermissionDenied + case http.StatusNotFound: + c = codes.Unimplemented + case http.StatusTooManyRequests: + c = codes.ResourceExhausted + case http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout: + c = codes.Unavailable + default: + c = codes.Unknown + } + return status.New(c, errMsg) +} diff --git a/consumer/consumererror/internal/statusconversion/conversion_test.go b/consumer/consumererror/internal/statusconversion/conversion_test.go new file mode 100644 index 00000000000..99316253c96 --- /dev/null +++ b/consumer/consumererror/internal/statusconversion/conversion_test.go @@ -0,0 +1,113 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package statusconversion // import "go.opentelemetry.io/collector/consumer/consumererror/internal/statusconversion" + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func Test_GetHTTPStatusCodeFromStatus(t *testing.T) { + tests := []struct { + name string + input *status.Status + expected int + }{ + { + name: "Retryable Status", + input: status.New(codes.Unavailable, "test"), + expected: http.StatusServiceUnavailable, + }, + { + name: "Non-retryable Status", + input: status.New(codes.InvalidArgument, "test"), + expected: http.StatusBadRequest, + }, + { + name: "Specifically 429", + input: status.New(codes.ResourceExhausted, "test"), + expected: http.StatusTooManyRequests, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := GetHTTPStatusCodeFromStatus(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func Test_ErrorMsgAndHTTPCodeToStatus(t *testing.T) { + tests := []struct { + name string + errMsg string + statusCode int + expected *status.Status + }{ + { + name: "Bad Request", + errMsg: "test", + statusCode: http.StatusBadRequest, + expected: status.New(codes.InvalidArgument, "test"), + }, + { + name: "Unauthorized", + errMsg: "test", + statusCode: http.StatusUnauthorized, + expected: status.New(codes.Unauthenticated, "test"), + }, + { + name: "Forbidden", + errMsg: "test", + statusCode: http.StatusForbidden, + expected: status.New(codes.PermissionDenied, "test"), + }, + { + name: "Not Found", + errMsg: "test", + statusCode: http.StatusNotFound, + expected: status.New(codes.Unimplemented, "test"), + }, + { + name: "Too Many Requests", + errMsg: "test", + statusCode: http.StatusTooManyRequests, + expected: status.New(codes.ResourceExhausted, "test"), + }, + { + name: "Bad Gateway", + errMsg: "test", + statusCode: http.StatusBadGateway, + expected: status.New(codes.Unavailable, "test"), + }, + { + name: "Service Unavailable", + errMsg: "test", + statusCode: http.StatusServiceUnavailable, + expected: status.New(codes.Unavailable, "test"), + }, + { + name: "Gateway Timeout", + errMsg: "test", + statusCode: http.StatusGatewayTimeout, + expected: status.New(codes.Unavailable, "test"), + }, + { + name: "Unsupported Media Type", + errMsg: "test", + statusCode: http.StatusUnsupportedMediaType, + expected: status.New(codes.Unknown, "test"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := NewStatusFromMsgAndHTTPCode(tt.errMsg, tt.statusCode) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/consumer/consumererror/signalerrors.go b/consumer/consumererror/signalerrors.go index 69af253dae7..3ee068532c7 100644 --- a/consumer/consumererror/signalerrors.go +++ b/consumer/consumererror/signalerrors.go @@ -20,7 +20,7 @@ type Traces struct { func NewTraces(err error, data ptrace.Traces) error { return Traces{ Retryable: internal.Retryable[ptrace.Traces]{ - Err: err, + Err: NewRetryableError(err), Value: data, }, } @@ -36,7 +36,7 @@ type Logs struct { func NewLogs(err error, data plog.Logs) error { return Logs{ Retryable: internal.Retryable[plog.Logs]{ - Err: err, + Err: NewRetryableError(err), Value: data, }, } @@ -52,7 +52,7 @@ type Metrics struct { func NewMetrics(err error, data pmetric.Metrics) error { return Metrics{ Retryable: internal.Retryable[pmetric.Metrics]{ - Err: err, + Err: NewRetryableError(err), Value: data, }, } diff --git a/consumer/consumererror/signalerrors_test.go b/consumer/consumererror/signalerrors_test.go index d311bb2726c..904877a60c5 100644 --- a/consumer/consumererror/signalerrors_test.go +++ b/consumer/consumererror/signalerrors_test.go @@ -35,6 +35,9 @@ func TestTraces_Unwrap(t *testing.T) { // Unwrapping traceErr for err and assigning to target. require.ErrorAs(t, traceErr, &target) require.Equal(t, err, target) + var e *Error + require.ErrorAs(t, traceErr, &e) + assert.True(t, e.IsRetryable()) } func TestLogs(t *testing.T) { @@ -59,6 +62,9 @@ func TestLogs_Unwrap(t *testing.T) { // Unwrapping logsErr for err and assigning to target. require.ErrorAs(t, logsErr, &target) require.Equal(t, err, target) + var e *Error + require.ErrorAs(t, logsErr, &e) + assert.True(t, e.IsRetryable()) } func TestMetrics(t *testing.T) { @@ -83,4 +89,7 @@ func TestMetrics_Unwrap(t *testing.T) { // Unwrapping metricErr for err and assigning to target. require.ErrorAs(t, metricErr, &target) require.Equal(t, err, target) + var e *Error + require.ErrorAs(t, metricErr, &e) + assert.True(t, e.IsRetryable()) }