mirror of
https://github.com/trezor/blockbook.git
synced 2026-02-20 00:51:39 +01:00
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:
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user