From 8bbbc2497dcb95b5ff725736cb7d4594e8b5e913 Mon Sep 17 00:00:00 2001 From: andig Date: Mon, 27 Jul 2026 19:00:46 +0200 Subject: [PATCH] api: match permanent sentinel errors after backoff unwrapping (#32197) --- api/error.go | 42 +++++++++++++++++++++++++++++++++++++----- api/error_test.go | 27 +++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 5 deletions(-) create mode 100644 api/error_test.go diff --git a/api/error.go b/api/error.go index 899e45531..856361dd1 100644 --- a/api/error.go +++ b/api/error.go @@ -8,10 +8,26 @@ import ( ) // ErrNotAvailable indicates that a feature is not available -var ErrNotAvailable = backoff.Permanent(errors.New("not available")) +var ErrNotAvailable = backoff.Permanent(errNotAvailable{}) + +type errNotAvailable struct{} + +func (errNotAvailable) Error() string { return "not available" } + +// Is matches ErrNotAvailable. Backoff strips the permanent wrapper, hence the +// unwrapped error must match the wrapped sentinel, too. +func (errNotAvailable) Is(target error) bool { return target == ErrNotAvailable } // ErrUnsupportedPlatform indicates unsupported hardware platform -var ErrUnsupportedPlatform error = backoff.Permanent(errors.New("unsupported platform")) +var ErrUnsupportedPlatform = backoff.Permanent(errUnsupportedPlatform{}) + +type errUnsupportedPlatform struct{} + +func (errUnsupportedPlatform) Error() string { return "unsupported platform" } + +// Is matches ErrUnsupportedPlatform. Backoff strips the permanent wrapper, hence +// the unwrapped error must match the wrapped sentinel, too. +func (errUnsupportedPlatform) Is(target error) bool { return target == ErrUnsupportedPlatform } // ErrMustRetry indicates that a rate-limited operation should be retried var ErrMustRetry = errors.New("must retry") @@ -20,16 +36,32 @@ var ErrMustRetry = errors.New("must retry") var ErrSponsorRequired = errors.New("sponsorship required, see https://docs.evcc.io/docs/sponsorship") // ErrMissingCredentials indicates that user/password are missing -var ErrMissingCredentials = backoff.Permanent(errors.New("missing user/password credentials")) +var ErrMissingCredentials = backoff.Permanent(errMissingCredentials{}) + +type errMissingCredentials struct{} + +func (errMissingCredentials) Error() string { return "missing user/password credentials" } + +// Is matches ErrMissingCredentials. Backoff strips the permanent wrapper, hence +// the unwrapped error must match the wrapped sentinel, too. +func (errMissingCredentials) Is(target error) bool { return target == ErrMissingCredentials } // ErrMissingToken indicates that access/refresh tokens are missing -var ErrMissingToken = backoff.Permanent(errors.New("missing token credentials")) +var ErrMissingToken = backoff.Permanent(errMissingToken{}) + +type errMissingToken struct{} + +func (errMissingToken) Error() string { return "missing token credentials" } + +// Is matches ErrMissingToken. Backoff strips the permanent wrapper, hence the +// unwrapped error must match the wrapped sentinel, too. +func (errMissingToken) Is(target error) bool { return target == ErrMissingToken } // ErrOutdated indicates that result is outdated var ErrOutdated = errors.New("outdated") // ErrTimeout is the error returned when a timeout happened -var ErrTimeout error = errors.New("timeout") +var ErrTimeout = errors.New("timeout") // LoginRequiredError creates a login error for given auth provider func LoginRequiredError(providerAuth string) error { diff --git a/api/error_test.go b/api/error_test.go new file mode 100644 index 000000000..4dfceebe1 --- /dev/null +++ b/api/error_test.go @@ -0,0 +1,27 @@ +package api + +import ( + "fmt" + "testing" + + "github.com/cenkalti/backoff/v4" + "github.com/stretchr/testify/assert" +) + +// Backoff returns permanent errors unwrapped. These must still match the sentinel. +func TestPermanentSentinels(t *testing.T) { + for _, tc := range []struct{ err, other error }{ + {ErrNotAvailable, ErrUnsupportedPlatform}, + {ErrUnsupportedPlatform, ErrNotAvailable}, + {ErrMissingCredentials, ErrMissingToken}, + {ErrMissingToken, ErrMissingCredentials}, + } { + _, unwrapped := backoff.RetryWithData(func() (int, error) { + return 0, tc.err + }, &backoff.StopBackOff{}) + + assert.ErrorIs(t, unwrapped, tc.err) + assert.ErrorIs(t, fmt.Errorf("wrapped: %w", unwrapped), tc.err) + assert.NotErrorIs(t, unwrapped, tc.other) + } +}