fiat: apply historical cap only to coingecko free plan

Resolve an effective free/pro plan once and use it consistently for default URL selection, request auth headers, and historical day limits.
This commit is contained in:
pragmaxim
2026-02-19 09:50:30 +01:00
parent fc23e72785
commit 0e445744a5
3 changed files with 161 additions and 35 deletions

View File

@@ -21,6 +21,10 @@ const (
DefaultHTTPTimeout = 15 * time.Second
DefaultThrottleDelayMs = 100 // 100 ms delay between requests
coingeckoFreeHistoryDaysLimit = 365
coingeckoPlanFree = "free"
coingeckoPlanPro = "pro"
coingeckoFreeAPIURL = "https://api.coingecko.com/api/v3"
coingeckoProAPIURL = "https://pro-api.coingecko.com/api/v3"
)
// Coingecko is a structure that implements RatesDownloaderInterface
@@ -73,20 +77,15 @@ func NewCoinGeckoDownloader(db *db.RocksDB, network string, url string, coin str
if apiKey == "" {
apiKey = os.Getenv("COINGECKO_API_KEY")
}
hasAPIKey := apiKey != ""
plan = resolveCoinGeckoPlan(plan, url, hasAPIKey)
// use default address if not overridden, with respect to existence of apiKey
if url == "" {
const (
proURL = "https://pro-api.coingecko.com/api/v3"
freeURL = "https://api.coingecko.com/api/v3"
)
plan = strings.ToLower(strings.TrimSpace(plan))
if apiKey != "" && plan != "free" {
url = proURL
if plan == coingeckoPlanPro {
url = coingeckoProAPIURL
} else {
url = freeURL
url = coingeckoFreeAPIURL
}
}
glog.Info("Coingecko downloader url ", url)
@@ -110,6 +109,50 @@ func NewCoinGeckoDownloader(db *db.RocksDB, network string, url string, coin str
}
}
func normalizeCoinGeckoURL(apiURL string) string {
return strings.TrimSuffix(strings.ToLower(strings.TrimSpace(apiURL)), "/")
}
func inferCoinGeckoPlanFromURL(apiURL string) (string, bool) {
switch normalizeCoinGeckoURL(apiURL) {
case coingeckoFreeAPIURL:
return coingeckoPlanFree, true
case coingeckoProAPIURL:
return coingeckoPlanPro, true
default:
return "", false
}
}
func resolveCoinGeckoPlan(plan string, apiURL string, hasAPIKey bool) string {
normalizedPlan := strings.ToLower(strings.TrimSpace(plan))
switch normalizedPlan {
case coingeckoPlanFree:
return coingeckoPlanFree
case coingeckoPlanPro:
return coingeckoPlanPro
case "":
// Continue with inference for backward compatibility.
default:
glog.Warningf("Coingecko unknown plan %q, defaulting by API key presence", plan)
if hasAPIKey {
return coingeckoPlanPro
}
return coingeckoPlanFree
}
if inferredPlan, ok := inferCoinGeckoPlanFromURL(apiURL); ok {
return inferredPlan
}
// Backward compatibility for existing deployments:
// API key implied Pro before plan was introduced.
if hasAPIKey {
return coingeckoPlanPro
}
return coingeckoPlanFree
}
// getAllowedVsCurrenciesMap returns a map of allowed vs currencies
func getAllowedVsCurrenciesMap(currenciesString string) map[string]struct{} {
allowedVsCurrenciesMap := make(map[string]struct{})
@@ -139,7 +182,7 @@ func doReq(req *http.Request, client *http.Client) ([]byte, error) {
}
// makeReq HTTP request helper - will retry the call after 1 minute on error
func (cg *Coingecko) makeReq(url string, endpoint string, plan string) ([]byte, error) {
func (cg *Coingecko) makeReq(url string, endpoint string) ([]byte, error) {
for {
// glog.Infof("Coingecko makeReq %v", url)
req, err := http.NewRequest("GET", url, nil)
@@ -148,7 +191,7 @@ func (cg *Coingecko) makeReq(url string, endpoint string, plan string) ([]byte,
}
req.Header.Set("Content-Type", "application/json")
if cg.apiKey != "" {
if plan == "pro" {
if cg.plan == coingeckoPlanPro {
req.Header.Set("x-cg-pro-api-key", cg.apiKey)
} else {
req.Header.Set("x-cg-demo-api-key", cg.apiKey)
@@ -180,7 +223,7 @@ func (cg *Coingecko) makeReq(url string, endpoint string, plan string) ([]byte,
// SimpleSupportedVSCurrencies /simple/supported_vs_currencies
func (cg *Coingecko) simpleSupportedVSCurrencies() (simpleSupportedVSCurrencies, error) {
url := cg.url + "/simple/supported_vs_currencies"
resp, err := cg.makeReq(url, "supported_vs_currencies", cg.plan)
resp, err := cg.makeReq(url, "supported_vs_currencies")
if err != nil {
return nil, err
}
@@ -211,7 +254,7 @@ func (cg *Coingecko) simplePrice(ids []string, vsCurrencies []string) (*map[stri
params.Add("vs_currencies", vsCurrenciesParam)
url := fmt.Sprintf("%s/simple/price?%s", cg.url, params.Encode())
resp, err := cg.makeReq(url, "simple/price", cg.plan)
resp, err := cg.makeReq(url, "simple/price")
if err != nil {
return nil, err
}
@@ -234,7 +277,7 @@ func (cg *Coingecko) coinsList() (coinList, error) {
}
params.Add("include_platform", platform)
url := fmt.Sprintf("%s/coins/list?%s", cg.url, params.Encode())
resp, err := cg.makeReq(url, "coins/list", cg.plan)
resp, err := cg.makeReq(url, "coins/list")
if err != nil {
return nil, err
}
@@ -261,7 +304,7 @@ func (cg *Coingecko) coinMarketChart(id string, vs_currency string, days string,
params.Add("days", days)
url := fmt.Sprintf("%s/coins/%s/market_chart?%s", cg.url, id, params.Encode())
resp, err := cg.makeReq(url, "market_chart", cg.plan)
resp, err := cg.makeReq(url, "market_chart")
if err != nil {
return nil, err
}
@@ -397,21 +440,10 @@ func (cg *Coingecko) FiveMinutesTickers() (*[]common.CurrencyRatesTicker, error)
}
func (cg *Coingecko) historicalRangeDaysLimit() int {
plan := strings.ToLower(strings.TrimSpace(cg.plan))
if plan == "pro" {
if cg.plan == coingeckoPlanPro {
return 0
}
if plan == "free" {
return coingeckoFreeHistoryDaysLimit
}
// Default public endpoint has historical range limits.
if strings.Contains(cg.url, "pro-api.coingecko.com") {
return 0
}
if strings.Contains(cg.url, "api.coingecko.com") {
return coingeckoFreeHistoryDaysLimit
}
return 0
return coingeckoFreeHistoryDaysLimit
}
func (cg *Coingecko) resolveHistoricalDays(lastTicker *common.CurrencyRatesTicker) (string, bool) {

View File

@@ -10,9 +10,92 @@ import (
"github.com/trezor/blockbook/common"
)
func TestResolveCoinGeckoPlan(t *testing.T) {
tests := []struct {
name string
plan string
url string
hasAPIKey bool
want string
}{
{
name: "explicit free overrides pro url and api key",
plan: "free",
url: coingeckoProAPIURL,
hasAPIKey: true,
want: coingeckoPlanFree,
},
{
name: "explicit pro",
plan: "pro",
url: "",
hasAPIKey: false,
want: coingeckoPlanPro,
},
{
name: "infer pro from pro url",
plan: "",
url: coingeckoProAPIURL,
hasAPIKey: false,
want: coingeckoPlanPro,
},
{
name: "infer pro from pro url with trailing slash and uppercase",
plan: "",
url: "HTTPS://PRO-API.COINGECKO.COM/API/V3/",
hasAPIKey: false,
want: coingeckoPlanPro,
},
{
name: "infer free from public url",
plan: "",
url: coingeckoFreeAPIURL,
hasAPIKey: true,
want: coingeckoPlanFree,
},
{
name: "empty plan with api key stays backward compatible and defaults to pro",
plan: "",
url: "",
hasAPIKey: true,
want: coingeckoPlanPro,
},
{
name: "empty plan without api key defaults to free",
plan: "",
url: "",
hasAPIKey: false,
want: coingeckoPlanFree,
},
{
name: "unknown plan falls back to api key default",
plan: "enterprise",
url: "",
hasAPIKey: true,
want: coingeckoPlanPro,
},
{
name: "unknown plan skips url inference and falls back to api key default",
plan: "enterprise",
url: coingeckoFreeAPIURL,
hasAPIKey: true,
want: coingeckoPlanPro,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := resolveCoinGeckoPlan(tt.plan, tt.url, tt.hasAPIKey)
if got != tt.want {
t.Fatalf("unexpected plan: got %q, want %q", got, tt.want)
}
})
}
}
func TestResolveHistoricalDays_FreeAPIWithoutLastTickerUses365(t *testing.T) {
cg := &Coingecko{
url: "https://api.coingecko.com/api/v3",
plan: coingeckoPlanFree,
}
days, shouldRequest := cg.resolveHistoricalDays(nil)
@@ -26,8 +109,7 @@ func TestResolveHistoricalDays_FreeAPIWithoutLastTickerUses365(t *testing.T) {
func TestResolveHistoricalDays_ProAPIWithoutLastTickerUsesMax(t *testing.T) {
cg := &Coingecko{
url: "https://pro-api.coingecko.com/api/v3",
plan: "pro",
plan: coingeckoPlanPro,
}
days, shouldRequest := cg.resolveHistoricalDays(nil)
@@ -41,7 +123,7 @@ func TestResolveHistoricalDays_ProAPIWithoutLastTickerUsesMax(t *testing.T) {
func TestResolveHistoricalDays_FreeAPICapsLongLookbackTo365(t *testing.T) {
cg := &Coingecko{
url: "https://api.coingecko.com/api/v3",
plan: coingeckoPlanFree,
}
days, shouldRequest := cg.resolveHistoricalDays(&common.CurrencyRatesTicker{
@@ -57,7 +139,7 @@ func TestResolveHistoricalDays_FreeAPICapsLongLookbackTo365(t *testing.T) {
func TestResolveHistoricalDays_SkipsWhenSameDayTickerExists(t *testing.T) {
cg := &Coingecko{
url: "https://api.coingecko.com/api/v3",
plan: coingeckoPlanFree,
}
days, shouldRequest := cg.resolveHistoricalDays(&common.CurrencyRatesTicker{
@@ -71,6 +153,18 @@ func TestResolveHistoricalDays_SkipsWhenSameDayTickerExists(t *testing.T) {
}
}
func TestHistoricalRangeDaysLimit_DependsOnPlan(t *testing.T) {
free := (&Coingecko{plan: coingeckoPlanFree}).historicalRangeDaysLimit()
if free != coingeckoFreeHistoryDaysLimit {
t.Fatalf("unexpected free limit: got %d, want %d", free, coingeckoFreeHistoryDaysLimit)
}
pro := (&Coingecko{plan: coingeckoPlanPro}).historicalRangeDaysLimit()
if pro != 0 {
t.Fatalf("unexpected pro limit: got %d, want %d", pro, 0)
}
}
func TestIsHistoricalRangeLimitError(t *testing.T) {
rangeErr := fmt.Errorf(`{"error":{"status":{"error_code":10012,"error_message":"Your request exceeds the allowed time range. Public API users are limited to querying historical data within the past 365 days."}}}`)
if !isHistoricalRangeLimitError(rangeErr) {

View File

@@ -128,7 +128,7 @@ func TestFiatRates(t *testing.T) {
config := common.Config{
CoinName: "fakecoin",
FiatRates: "coingecko",
FiatRatesParams: `{"url": "` + mockServer.URL + `", "coin": "ethereum","platformIdentifier": "ethereum","platformVsCurrency": "eth","periodSeconds": 60}`,
FiatRatesParams: `{"url": "` + mockServer.URL + `", "coin": "ethereum","platformIdentifier": "ethereum","platformVsCurrency": "eth","periodSeconds": 60,"plan":"pro"}`,
}
d, _, tmp := setupRocksDB(t, &testBitcoinParser{