Skip to content

Commit 85dfcb5

Browse files
committed
fix(wallet): treat unmapped market tokens as non-critical errors
* fall back to the Ethereum sibling for chart history
1 parent ea007e9 commit 85dfcb5

12 files changed

Lines changed: 323 additions & 47 deletions

File tree

internal/circuitbreaker/circuit_breaker.go

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,10 @@ func (cr *CommandResult) addCallStatus(providerName string, err error, startTime
5555
}
5656

5757
type Command struct {
58-
ctx context.Context
59-
functors []*Functor
60-
cancel atomic.Bool
58+
ctx context.Context
59+
functors []*Functor
60+
cancel atomic.Bool
61+
nonFailureErrorClassifier func(error) bool
6162
}
6263

6364
func NewCommand(ctx context.Context, functors []*Functor) *Command {
@@ -79,6 +80,14 @@ func (cmd *Command) Cancel() {
7980
cmd.cancel.Store(true)
8081
}
8182

83+
func (cmd *Command) SetNonFailureErrorClassifier(classifier func(error) bool) {
84+
cmd.nonFailureErrorClassifier = classifier
85+
}
86+
87+
func (cmd *Command) shouldIgnoreFailure(err error) bool {
88+
return err != nil && cmd.nonFailureErrorClassifier != nil && cmd.nonFailureErrorClassifier(err)
89+
}
90+
8291
type Config struct {
8392
Timeout int
8493
MaxConcurrentRequests int
@@ -185,6 +194,7 @@ func (cb *CircuitBreaker) Execute(cmd *Command) CommandResult {
185194
}
186195

187196
var err error
197+
var skippedErr error
188198
circuitName := f.circuitName
189199
providerName := f.providerName
190200
if cb.circuitNameHandler != nil {
@@ -231,6 +241,10 @@ func (cb *CircuitBreaker) Execute(cmd *Command) CommandResult {
231241
shared.markCancelled()
232242
return nil
233243
}
244+
if cmd.shouldIgnoreFailure(err) {
245+
skippedErr = err
246+
return nil
247+
}
234248
if err != nil {
235249
logutils.ZapLogger().Warn("hystrix error",
236250
zap.String("provider", f.providerName),
@@ -240,6 +254,9 @@ func (cb *CircuitBreaker) Execute(cmd *Command) CommandResult {
240254
return err
241255
}, nil)
242256
}
257+
if err == nil && skippedErr != nil {
258+
err = skippedErr
259+
}
243260
if err == nil {
244261
break
245262
}

internal/circuitbreaker/circuit_breaker_test.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -647,3 +647,32 @@ func TestCircuitBreaker_ErrorLogging(t *testing.T) {
647647
assert.True(t, duration >= 0)
648648
})
649649
}
650+
651+
func TestExecuteIgnoresNonFailureErrorForCircuitAndTriesNextProvider(t *testing.T) {
652+
cb := NewCircuitBreaker(Config{
653+
Timeout: 1000,
654+
MaxConcurrentRequests: 10,
655+
SleepWindow: 1000,
656+
ErrorPercentThreshold: 1,
657+
})
658+
mappingErr := errors.New("token not mapped")
659+
660+
first := NewFunctor(func() ([]any, error) {
661+
return nil, mappingErr
662+
}, "market-provider-1", "provider-1")
663+
second := NewFunctor(func() ([]any, error) {
664+
return []any{"ok"}, nil
665+
}, "market-provider-2", "provider-2")
666+
667+
cmd := NewCommand(context.Background(), []*Functor{first, second})
668+
cmd.SetNonFailureErrorClassifier(func(err error) bool {
669+
return errors.Is(err, mappingErr)
670+
})
671+
672+
result := cb.Execute(cmd)
673+
require.NoError(t, result.Error())
674+
require.Equal(t, []any{"ok"}, result.Result())
675+
require.Len(t, result.FunctorCallStatuses(), 2)
676+
require.Error(t, result.FunctorCallStatuses()[0].Err)
677+
require.NoError(t, result.FunctorCallStatuses()[1].Err)
678+
}

internal/healthmanager/provider_errors/provider_errors.go

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ const (
2626
ProviderErrorTypeNone ProviderErrorType = "none"
2727
ProviderErrorTypeContextCanceled ProviderErrorType = "context_canceled"
2828
ProviderErrorTypeContextDeadlineExceeded ProviderErrorType = "context_deadline"
29+
ProviderErrorTypeDataUnavailable ProviderErrorType = "data_unavailable"
2930
ProviderErrorTypeConnection ProviderErrorType = "connection"
3031
ProviderErrorTypeNotAuthorized ProviderErrorType = "not_authorized"
3132
ProviderErrorTypeForbidden ProviderErrorType = "forbidden"
@@ -38,6 +39,8 @@ const (
3839
ProviderErrorTypeOther ProviderErrorType = "other"
3940
)
4041

42+
var ErrDataUnavailable = errors.New("requested data not available from provider")
43+
4144
// IsConnectionError checks if the error is related to network issues.
4245
func IsConnectionError(err error) bool {
4346
if err == nil {
@@ -225,6 +228,9 @@ func determineProviderErrorType(err error) ProviderErrorType {
225228
if errors.Is(err, puzzleauth.ErrAuthRotating) {
226229
return ProviderErrorTypeAuthRotating
227230
}
231+
if errors.Is(err, ErrDataUnavailable) {
232+
return ProviderErrorTypeDataUnavailable
233+
}
228234
if IsConnectionError(err) {
229235
return ProviderErrorTypeConnection
230236
}
@@ -258,9 +264,20 @@ func IsNonCriticalProviderError(err error) bool {
258264
errorType := determineProviderErrorType(err)
259265

260266
switch errorType {
261-
case ProviderErrorTypeNone, ProviderErrorTypeContextCanceled, ProviderErrorTypeContentTooLarge, ProviderErrorTypeRateLimit, ProviderErrorTypeAuthRotating:
267+
case ProviderErrorTypeNone, ProviderErrorTypeContextCanceled, ProviderErrorTypeContentTooLarge, ProviderErrorTypeRateLimit, ProviderErrorTypeAuthRotating, ProviderErrorTypeDataUnavailable:
262268
return true
263269
default:
264270
return false
265271
}
266272
}
273+
274+
// IsIgnorableForConnectivity reports whether the error should not flip
275+
// provider/chain connectivity status to "down".
276+
func IsIgnorableForConnectivity(err error) bool {
277+
if err == nil {
278+
return false
279+
}
280+
return errors.Is(err, context.Canceled) ||
281+
IsNonCriticalRpcError(err) ||
282+
IsNonCriticalProviderError(err)
283+
}

internal/healthmanager/provider_errors/provider_errors_test.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,3 +139,33 @@ func TestIsNonCriticalProviderError_PuzzleAuthRotating(t *testing.T) {
139139
t.Error("IsNonCriticalProviderError should be false for unrelated errors")
140140
}
141141
}
142+
143+
func TestDetermineProviderErrorType_DataUnavailable(t *testing.T) {
144+
if got := determineProviderErrorType(ErrDataUnavailable); got != ProviderErrorTypeDataUnavailable {
145+
t.Errorf("determineProviderErrorType(ErrDataUnavailable) = %q, want %q", got, ProviderErrorTypeDataUnavailable)
146+
}
147+
148+
wrapped := fmt.Errorf("wrapped: %w", ErrDataUnavailable)
149+
if got := determineProviderErrorType(wrapped); got != ProviderErrorTypeDataUnavailable {
150+
t.Errorf("determineProviderErrorType(wrapped ErrDataUnavailable) = %q, want %q", got, ProviderErrorTypeDataUnavailable)
151+
}
152+
}
153+
154+
func TestIsNonCriticalProviderError_DataUnavailable(t *testing.T) {
155+
requireErr := fmt.Errorf("wrapped: %w", ErrDataUnavailable)
156+
if !IsNonCriticalProviderError(requireErr) {
157+
t.Error("IsNonCriticalProviderError should be true for wrapped ErrDataUnavailable")
158+
}
159+
if !IsNonCriticalProviderError(ErrDataUnavailable) {
160+
t.Error("IsNonCriticalProviderError should be true for bare ErrDataUnavailable")
161+
}
162+
}
163+
164+
func TestIsIgnorableForConnectivity(t *testing.T) {
165+
if !IsIgnorableForConnectivity(fmt.Errorf("wrapped: %w", ErrDataUnavailable)) {
166+
t.Error("IsIgnorableForConnectivity should be true for wrapped ErrDataUnavailable")
167+
}
168+
if IsIgnorableForConnectivity(errors.New("fatal provider error")) {
169+
t.Error("IsIgnorableForConnectivity should be false for unrelated errors")
170+
}
171+
}

services/wallet/collectibles/collectibles_status.go

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package collectibles
22

33
import (
4-
"context"
54
"errors"
65
"sync"
76

@@ -86,19 +85,7 @@ func (o *Manager) setChainConnected(chainID walletCommon.ChainID, connected bool
8685
}
8786

8887
func isCollectiblesIgnorableError(err error) bool {
89-
if err == nil {
90-
return false
91-
}
92-
if errors.Is(err, context.Canceled) {
93-
return true
94-
}
95-
if provider_errors.IsNonCriticalRpcError(err) {
96-
return true
97-
}
98-
if provider_errors.IsNonCriticalProviderError(err) {
99-
return true
100-
}
101-
return false
88+
return provider_errors.IsIgnorableForConnectivity(err)
10289
}
10390

10491
func logProviderSearchErr(method, providerID string, chainID walletCommon.ChainID, err error) {

services/wallet/market/market.go

Lines changed: 45 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package market
22

33
import (
44
"context"
5+
"errors"
56
"sync"
67
"time"
78

@@ -11,6 +12,7 @@ import (
1112
"github.com/ethereum/go-ethereum/event"
1213

1314
"github.com/status-im/status-go/internal/circuitbreaker"
15+
provider_errors "github.com/status-im/status-go/internal/healthmanager/provider_errors"
1416
"github.com/status-im/status-go/internal/logutils"
1517
"github.com/status-im/status-go/services/wallet/thirdparty"
1618
tokentypes "github.com/status-im/status-go/services/wallet/token/types"
@@ -100,6 +102,7 @@ func (pm *Manager) setIsConnected(value bool) {
100102

101103
func (pm *Manager) makeCall(providers []thirdparty.MarketDataProvider, f func(provider thirdparty.MarketDataProvider) (interface{}, error)) (interface{}, error) {
102104
cmd := circuitbreaker.NewCommand(context.Background(), nil)
105+
cmd.SetNonFailureErrorClassifier(provider_errors.IsIgnorableForConnectivity)
103106
for _, provider := range providers {
104107
provider := provider
105108
// FIXME: we might want a different circuitName. See other uses of NewFunctor
@@ -111,16 +114,52 @@ func (pm *Manager) makeCall(providers []thirdparty.MarketDataProvider, f func(pr
111114
}
112115

113116
result := pm.circuitbreaker.Execute(cmd)
114-
pm.setIsConnected(result.Error() == nil)
117+
isConnectivityError := result.Error() != nil && !provider_errors.IsIgnorableForConnectivity(result.Error())
118+
pm.setIsConnected(!isConnectivityError)
115119

116120
if result.Error() != nil {
117-
logutils.ZapLogger().Error("Error fetching prices", zap.Error(result.Error()))
121+
if provider_errors.IsIgnorableForConnectivity(result.Error()) {
122+
logutils.ZapLogger().Warn("market data unavailable for token mapping", zap.Error(result.Error()))
123+
} else {
124+
logutils.ZapLogger().Error("Error fetching prices", zap.Error(result.Error()))
125+
}
118126
return nil, result.Error()
119127
}
120128

121129
return result.Result()[0], nil
122130
}
123131

132+
func (pm *Manager) fetchHistoricalPricesWithFallback(
133+
token *tokentypes.Token,
134+
call func(provider thirdparty.MarketDataProvider, token *tokentypes.Token) ([]thirdparty.HistoricalPrice, error),
135+
) ([]thirdparty.HistoricalPrice, error) {
136+
var sibling *tokentypes.Token
137+
if allTokens, err := pm.tokenManager.GetTokensForFetchingMarketData(); err == nil {
138+
sibling = tokentypes.EthereumMainnetSibling(token, allTokens)
139+
}
140+
141+
for _, t := range []*tokentypes.Token{token, sibling} {
142+
if t == nil {
143+
continue
144+
}
145+
146+
result, err := pm.makeCall(pm.providers, func(provider thirdparty.MarketDataProvider) (interface{}, error) {
147+
return call(provider, t)
148+
})
149+
if err == nil {
150+
return result.([]thirdparty.HistoricalPrice), nil
151+
}
152+
if !errors.Is(err, thirdparty.ErrTokenNotMapped) {
153+
return nil, err
154+
}
155+
}
156+
157+
logutils.ZapLogger().Warn("no mapped market history token found",
158+
zap.String("tokenKey", token.Key()),
159+
zap.String("crossChainID", token.CrossChainID))
160+
return []thirdparty.HistoricalPrice{}, nil
161+
}
162+
124163
func (pm *Manager) getTokensByKeys(tokensKeys []string) (tokens []*tokentypes.Token, err error) {
125164
if len(tokensKeys) > 0 {
126165
tokens, err = pm.tokenManager.GetTokensByKeysForFetchingMarketData(tokensKeys)
@@ -136,17 +175,9 @@ func (pm *Manager) FetchHistoricalDailyPrices(tokenKey string, currency string,
136175
return nil, err
137176
}
138177

139-
result, err := pm.makeCall(pm.providers, func(provider thirdparty.MarketDataProvider) (interface{}, error) {
140-
return provider.FetchHistoricalDailyPrices(token, currency, limit, allData, aggregate)
178+
return pm.fetchHistoricalPricesWithFallback(token, func(provider thirdparty.MarketDataProvider, t *tokentypes.Token) ([]thirdparty.HistoricalPrice, error) {
179+
return provider.FetchHistoricalDailyPrices(t, currency, limit, allData, aggregate)
141180
})
142-
143-
if err != nil {
144-
logutils.ZapLogger().Error("Error fetching prices", zap.Error(err))
145-
return nil, err
146-
}
147-
148-
prices := result.([]thirdparty.HistoricalPrice)
149-
return prices, nil
150181
}
151182

152183
func (pm *Manager) FetchHistoricalHourlyPrices(tokenKey string, currency string, limit int, aggregate int) ([]thirdparty.HistoricalPrice, error) {
@@ -155,17 +186,9 @@ func (pm *Manager) FetchHistoricalHourlyPrices(tokenKey string, currency string,
155186
return nil, err
156187
}
157188

158-
result, err := pm.makeCall(pm.providers, func(provider thirdparty.MarketDataProvider) (interface{}, error) {
159-
return provider.FetchHistoricalHourlyPrices(token, currency, limit, aggregate)
189+
return pm.fetchHistoricalPricesWithFallback(token, func(provider thirdparty.MarketDataProvider, t *tokentypes.Token) ([]thirdparty.HistoricalPrice, error) {
190+
return provider.FetchHistoricalHourlyPrices(t, currency, limit, aggregate)
160191
})
161-
162-
if err != nil {
163-
logutils.ZapLogger().Error("Error fetching prices", zap.Error(err))
164-
return nil, err
165-
}
166-
167-
prices := result.([]thirdparty.HistoricalPrice)
168-
return prices, nil
169192
}
170193

171194
// FetchTokenMarketValues fetches market values for a given token keys and currency. If no tokens are provided, all tokens are fetched.

services/wallet/market/market_feed_test.go

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,8 @@ func (s *MarketTestSuite) TestEventOnRpsError() {
5050
// WHEN
5151
_, err := manager.FetchPrices(s.tokensKeys, s.currencies)
5252
s.Require().Error(err, "expected error from FetchPrices due to MockPriceProviderWithError")
53-
event, ok := s.feedSub.WaitForEvent(5 * time.Second)
54-
s.Require().True(ok, "expected an event, but none was received")
55-
56-
// THEN
57-
s.Require().Equal(event.Type, EventMarketStatusChanged)
53+
_, ok := s.feedSub.WaitForEvent(500 * time.Millisecond)
54+
s.Require().False(ok, "expected no status event for non-critical rate limit error")
5855
}
5956

6057
func (s *MarketTestSuite) TestEventOnNetworkError() {

0 commit comments

Comments
 (0)