diff --git a/CHANGELOG.md b/CHANGELOG.md index 80f912b..8920e16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,10 @@ Ref: https://keepachangelog.com/en/1.0.0/ ## Unreleased +### Features + +* [47](https://github.com/JulianToledano/goingecko/pull/47) Adds a rate limited client with configurable request limits and exponential backoff retry policy. + ## [v3.0.3](https://github.com/JulianToledano/goingecko/releases/tag/v3.0.3) - 2025-04-18 ### Improvements diff --git a/api/assetPlatforms/client.go b/api/assetPlatforms/client.go index 248ba80..49702b8 100644 --- a/api/assetPlatforms/client.go +++ b/api/assetPlatforms/client.go @@ -9,7 +9,7 @@ type AssetPlatformsClient struct { *internal.Client } -func NewClient(c *geckohttp.Client, url string) *AssetPlatformsClient { +func NewClient(c geckohttp.HttpClient, url string) *AssetPlatformsClient { return &AssetPlatformsClient{ internal.NewClient(c, url), } diff --git a/api/categories/client.go b/api/categories/client.go index 4634fd7..07d65bf 100644 --- a/api/categories/client.go +++ b/api/categories/client.go @@ -9,7 +9,7 @@ type CategoriesClient struct { *internal.Client } -func NewClient(c *geckohttp.Client, url string) *CategoriesClient { +func NewClient(c geckohttp.HttpClient, url string) *CategoriesClient { return &CategoriesClient{ internal.NewClient(c, url), } diff --git a/api/client.go b/api/client.go index 323faa9..91c275c 100644 --- a/api/client.go +++ b/api/client.go @@ -2,6 +2,7 @@ package api import ( "net/http" + "time" "github.com/JulianToledano/goingecko/v3/api/assetPlatforms" "github.com/JulianToledano/goingecko/v3/api/categories" @@ -20,17 +21,22 @@ import ( geckohttp "github.com/JulianToledano/goingecko/v3/http" ) +const ( + proApiKeyHeaderKey = "x-cg-pro-api-key" + demoApiKeyHeaderKey = "x-cg-demo-api-key" +) + // proApiHeader returns a function that sets the Pro API key header on requests func proApiHeader(apiKey string) geckohttp.ApiHeaderFn { return func(r *http.Request) { - r.Header.Set("x-cg-pro-api-key", apiKey) + r.Header.Set(proApiKeyHeaderKey, apiKey) } } // demoApiHeader returns a function that sets the Demo API key header on requests func demoApiHeader(apiKey string) geckohttp.ApiHeaderFn { return func(r *http.Request) { - r.Header.Set("x-cg-demo-api-key", apiKey) + r.Header.Set(demoApiKeyHeaderKey, apiKey) } } @@ -52,10 +58,29 @@ type Client struct { *companies.CompaniesClient } +// NewClient creates a new Client with the given gecko HTTP client and base URL. +func NewClient(c geckohttp.HttpClient, url string) *Client { + return newClient(c, url) +} + // NewDefaultClient creates a new Client using the default HTTP client and base URL func NewDefaultClient() *Client { return newClient( - geckohttp.NewClient(geckohttp.WithHttpClient(http.DefaultClient)), + geckohttp.NewClient( + geckohttp.WithHttpClient[*geckohttp.Client](http.DefaultClient), + ), + BaseURL, + ) +} + +// NewDefaultRateLimitedClient creates a new Client using the default HTTP client and base URL +func NewDefaultRateLimitedClient() *Client { + return newClient( + geckohttp.NewRateLimitedClient( + geckohttp.WithHttpClient[*geckohttp.RateLimitedClient](http.DefaultClient), + geckohttp.WithRateLimit[*geckohttp.RateLimitedClient](15), + geckohttp.WithRetryPolicy[*geckohttp.RateLimitedClient](5, 2), + ), BaseURL, ) } @@ -63,7 +88,28 @@ func NewDefaultClient() *Client { // NewDemoApiClient creates a new Client configured for the Demo API with the provided API key and HTTP client func NewDemoApiClient(apiKey string, c *http.Client) *Client { return newClient( - geckohttp.NewClient(geckohttp.WithHttpClient(c), geckohttp.WithApiHeaderFn(demoApiHeader(apiKey))), + geckohttp.NewClient( + geckohttp.WithHttpClient[*geckohttp.Client](c), + geckohttp.WithApiHeaderFn[*geckohttp.Client](demoApiHeader(apiKey)), + ), + BaseURL, + ) +} + +// NewDemoApiRateLimitedClient creates a new Client configured for the Demo API with rate limiting. +// It takes an API key, HTTP client, and rate limiting configuration: +// - apiKey: The API key for authentication +// - c: The HTTP client to use for requests +// - reqPerMinute: Maximum number of requests allowed per minute +// +// The client is configured with 5 retry attempts and a base delay of 2 seconds between retries. +func NewDemoApiRateLimitedClient(apiKey string, c *http.Client, reqPerMinute int) *Client { + return newClient( + geckohttp.NewRateLimitedClient( + geckohttp.WithHttpClient[*geckohttp.RateLimitedClient](c), + geckohttp.WithApiHeaderFn[*geckohttp.RateLimitedClient](demoApiHeader(apiKey)), + geckohttp.WithRateLimit[*geckohttp.RateLimitedClient](reqPerMinute), + geckohttp.WithRetryPolicy[*geckohttp.RateLimitedClient](5, 2)), BaseURL, ) } @@ -71,13 +117,33 @@ func NewDemoApiClient(apiKey string, c *http.Client) *Client { // NewProApiClient creates a new Client configured for the Pro API with the provided API key and HTTP client func NewProApiClient(apiKey string, c *http.Client) *Client { return newClient( - geckohttp.NewClient(geckohttp.WithHttpClient(c), geckohttp.WithApiHeaderFn(proApiHeader(apiKey))), + geckohttp.NewClient( + geckohttp.WithHttpClient[*geckohttp.Client](c), + geckohttp.WithApiHeaderFn[*geckohttp.Client](proApiHeader(apiKey)), + ), + ProBaseURL, + ) +} + +// NewProApiRateLimitedClient creates a new Client configured for the Pro API with rate limiting. +// It takes an API key, HTTP client, and rate limiting configuration parameters: +// - reqPerMinute: Maximum number of requests allowed per minute +// - maxRetries: Maximum number of retry attempts for failed requests +// - baseDelay: Initial delay between retries, which will be exponentially increased +func NewProApiRateLimitedClient(apiKey string, c *http.Client, reqPerMinute, maxRetries int, baseDelay time.Duration) *Client { + return newClient( + geckohttp.NewRateLimitedClient( + geckohttp.WithHttpClient[*geckohttp.RateLimitedClient](c), + geckohttp.WithApiHeaderFn[*geckohttp.RateLimitedClient](proApiHeader(apiKey)), + geckohttp.WithRateLimit[*geckohttp.RateLimitedClient](reqPerMinute), + geckohttp.WithRetryPolicy[*geckohttp.RateLimitedClient](maxRetries, baseDelay), + ), ProBaseURL, ) } // newClient creates a new Client with the provided HTTP client and base URL -func newClient(c *geckohttp.Client, url string) *Client { +func newClient(c geckohttp.HttpClient, url string) *Client { return &Client{ PingClient: ping.NewClient(c, url), SimpleClient: simple.NewClient(c, url), diff --git a/api/coins/client.go b/api/coins/client.go index 09490eb..3a666c6 100644 --- a/api/coins/client.go +++ b/api/coins/client.go @@ -9,7 +9,7 @@ type CoinsClient struct { *internal.Client } -func NewClient(c *geckohttp.Client, url string) *CoinsClient { +func NewClient(c geckohttp.HttpClient, url string) *CoinsClient { return &CoinsClient{ internal.NewClient(c, url), } diff --git a/api/coins/id_test.go b/api/coins/id_test.go index f35da05..27e1b82 100644 --- a/api/coins/id_test.go +++ b/api/coins/id_test.go @@ -2,11 +2,9 @@ package coins import ( "context" - "net/http" "testing" "github.com/JulianToledano/goingecko/v3/api/internal" - geckohttp "github.com/JulianToledano/goingecko/v3/http" ) func TestCoinsId(t *testing.T) { @@ -23,7 +21,7 @@ func TestCoinsId(t *testing.T) { t.Run(tt.name, func(t *testing.T) { c := &CoinsClient{ internal.NewClient( - geckohttp.NewClient(geckohttp.WithHttpClient(http.DefaultClient)), + internal.CommonTestClient, internal.BaseURL, ), } diff --git a/api/coins/list_test.go b/api/coins/list_test.go index 9235bb9..9964036 100644 --- a/api/coins/list_test.go +++ b/api/coins/list_test.go @@ -2,11 +2,9 @@ package coins import ( "context" - "net/http" "testing" "github.com/JulianToledano/goingecko/v3/api/internal" - geckohttp "github.com/JulianToledano/goingecko/v3/http" ) func TestCoinsClient_CoinsList(t *testing.T) { @@ -21,7 +19,7 @@ func TestCoinsClient_CoinsList(t *testing.T) { t.Run(tt.name, func(t *testing.T) { c := &CoinsClient{ internal.NewClient( - geckohttp.NewClient(geckohttp.WithHttpClient(http.DefaultClient)), + internal.CommonTestClient, internal.BaseURL, ), } diff --git a/api/companies/client.go b/api/companies/client.go index 9c71a48..a38b2ea 100644 --- a/api/companies/client.go +++ b/api/companies/client.go @@ -9,7 +9,7 @@ type CompaniesClient struct { *internal.Client } -func NewClient(c *geckohttp.Client, url string) *CompaniesClient { +func NewClient(c geckohttp.HttpClient, url string) *CompaniesClient { return &CompaniesClient{ internal.NewClient(c, url), } diff --git a/api/contract/client.go b/api/contract/client.go index a36ab4b..4de2a73 100644 --- a/api/contract/client.go +++ b/api/contract/client.go @@ -9,7 +9,7 @@ type ContractClient struct { *internal.Client } -func NewClient(c *geckohttp.Client, url string) *ContractClient { +func NewClient(c geckohttp.HttpClient, url string) *ContractClient { return &ContractClient{ internal.NewClient(c, url), } diff --git a/api/derivatives/client.go b/api/derivatives/client.go index ffba190..5133493 100644 --- a/api/derivatives/client.go +++ b/api/derivatives/client.go @@ -9,7 +9,7 @@ type DerivativesClient struct { *internal.Client } -func NewClient(c *geckohttp.Client, url string) *DerivativesClient { +func NewClient(c geckohttp.HttpClient, url string) *DerivativesClient { return &DerivativesClient{ internal.NewClient(c, url), } diff --git a/api/exchangeRates/client.go b/api/exchangeRates/client.go index 20da396..e1f6e8a 100644 --- a/api/exchangeRates/client.go +++ b/api/exchangeRates/client.go @@ -9,7 +9,7 @@ type ExchangeRatesClient struct { *internal.Client } -func NewClient(c *geckohttp.Client, url string) *ExchangeRatesClient { +func NewClient(c geckohttp.HttpClient, url string) *ExchangeRatesClient { return &ExchangeRatesClient{ internal.NewClient(c, url), } diff --git a/api/exchanges/client.go b/api/exchanges/client.go index 373fed0..38928bb 100644 --- a/api/exchanges/client.go +++ b/api/exchanges/client.go @@ -11,7 +11,7 @@ type ExchangesClient struct { *internal.Client } -func NewClient(c *geckohttp.Client, url string) *ExchangesClient { +func NewClient(c geckohttp.HttpClient, url string) *ExchangesClient { return &ExchangesClient{ internal.NewClient(c, url), } diff --git a/api/global/client.go b/api/global/client.go index 868b715..029a0ad 100644 --- a/api/global/client.go +++ b/api/global/client.go @@ -9,7 +9,7 @@ type GlobalClient struct { *internal.Client } -func NewClient(c *geckohttp.Client, url string) *GlobalClient { +func NewClient(c geckohttp.HttpClient, url string) *GlobalClient { return &GlobalClient{ internal.NewClient(c, url), } diff --git a/api/internal/client.go b/api/internal/client.go index 48eedfb..df491dc 100644 --- a/api/internal/client.go +++ b/api/internal/client.go @@ -1,16 +1,27 @@ package internal -import geckohttp "github.com/JulianToledano/goingecko/v3/http" +import ( + "net/http" + + geckohttp "github.com/JulianToledano/goingecko/v3/http" +) type Client struct { - *geckohttp.Client + geckohttp.HttpClient URL string } -func NewClient(httpClient *geckohttp.Client, url string) *Client { +func NewClient(httpClient geckohttp.HttpClient, url string) *Client { return &Client{ - Client: httpClient, - URL: url, + HttpClient: httpClient, + URL: url, } } + +// CommonTestClient is a test client for use in tests so rate limiting is not an issue +var CommonTestClient = geckohttp.NewRateLimitedClient( + geckohttp.WithHttpClient[*geckohttp.RateLimitedClient](http.DefaultClient), + geckohttp.WithRateLimit[*geckohttp.RateLimitedClient](15), + geckohttp.WithRetryPolicy[*geckohttp.RateLimitedClient](5, 2), +) diff --git a/api/nfts/client.go b/api/nfts/client.go index ed430a5..5be90c8 100644 --- a/api/nfts/client.go +++ b/api/nfts/client.go @@ -15,7 +15,7 @@ type NftsClient struct { *internal.Client } -func NewClient(c *geckohttp.Client, url string) *NftsClient { +func NewClient(c geckohttp.HttpClient, url string) *NftsClient { return &NftsClient{ internal.NewClient(c, url), } diff --git a/api/ping/client.go b/api/ping/client.go index 40ed405..d741bc4 100644 --- a/api/ping/client.go +++ b/api/ping/client.go @@ -14,7 +14,7 @@ type PingClient struct { *internal.Client } -func NewClient(c *geckohttp.Client, url string) *PingClient { +func NewClient(c geckohttp.HttpClient, url string) *PingClient { return &PingClient{ internal.NewClient(c, url), } diff --git a/api/search/client.go b/api/search/client.go index ffcff15..48fbe18 100644 --- a/api/search/client.go +++ b/api/search/client.go @@ -9,7 +9,7 @@ type SearchClient struct { *internal.Client } -func NewClient(c *geckohttp.Client, url string) *SearchClient { +func NewClient(c geckohttp.HttpClient, url string) *SearchClient { return &SearchClient{ internal.NewClient(c, url), } diff --git a/api/simple/client.go b/api/simple/client.go index d5cecd7..c690072 100644 --- a/api/simple/client.go +++ b/api/simple/client.go @@ -9,7 +9,7 @@ type SimpleClient struct { *internal.Client } -func NewClient(c *geckohttp.Client, url string) *SimpleClient { +func NewClient(c geckohttp.HttpClient, url string) *SimpleClient { return &SimpleClient{ internal.NewClient(c, url), } diff --git a/api/trending/client.go b/api/trending/client.go index 3397049..47b7ce7 100644 --- a/api/trending/client.go +++ b/api/trending/client.go @@ -9,7 +9,7 @@ type TrendingClient struct { *internal.Client } -func NewClient(c *geckohttp.Client, url string) *TrendingClient { +func NewClient(c geckohttp.HttpClient, url string) *TrendingClient { return &TrendingClient{ internal.NewClient(c, url), } diff --git a/docs/examples/go.mod b/docs/examples/go.mod index ec7fe11..0ae6062 100644 --- a/docs/examples/go.mod +++ b/docs/examples/go.mod @@ -2,9 +2,8 @@ module goingeckoExamples go 1.22.4 -require github.com/JulianToledano/goingecko/v3 v3.0.0-20241230130119-db5fd588cc6e // indirect +require github.com/JulianToledano/goingecko/v3 v3.0.3 +require golang.org/x/time v0.5.0 // indirect -replace ( - github.com/JulianToledano/goingecko/v3 => ../.. -) \ No newline at end of file +replace github.com/JulianToledano/goingecko/v3 => ../.. diff --git a/docs/examples/go.sum b/docs/examples/go.sum index 751ce42..a2652c5 100644 --- a/docs/examples/go.sum +++ b/docs/examples/go.sum @@ -1,2 +1,2 @@ -github.com/JulianToledano/goingecko/v3 v3.0.0-20241230130119-db5fd588cc6e h1:fM3bCPQ4Rc3nnso5VJSa52UvbSO1NeNM6gknQdH0iFA= -github.com/JulianToledano/goingecko/v3 v3.0.0-20241230130119-db5fd588cc6e/go.mod h1:yNA3hc3eMxa0nrQGknJZDTCXo/azd9gEJyPofbJ7vTw= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= diff --git a/docs/examples/rateLimitClient/main.go b/docs/examples/rateLimitClient/main.go new file mode 100644 index 0000000..14704fb --- /dev/null +++ b/docs/examples/rateLimitClient/main.go @@ -0,0 +1,22 @@ +package main + +import ( + "context" + "fmt" + + "github.com/JulianToledano/goingecko/v3/api" + "github.com/JulianToledano/goingecko/v3/api/coins" +) + +func main() { + cgClient := api.NewDefaultRateLimitedClient() + + for i := 0; i < 100; i++ { + data, err := cgClient.CoinsId(context.Background(), "bitcoin", coins.WithTickers(false)) + if err != nil { + fmt.Printf("Error: %v\n", err) + continue + } + fmt.Printf("%d | Bitcoin price is: %f$\n", i, data.MarketData.CurrentPrice.Usd) + } +} diff --git a/go.mod b/go.mod index e9ac66d..d1e5aa5 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,9 @@ module github.com/JulianToledano/goingecko/v3 go 1.22.4 +require golang.org/x/time v0.5.0 + retract ( + v3.0.1 // Wrong Categories endpoint. v3.0.0 // Wrong Categories endpoint. - v3.0.1 // Wrong Categories endpoint. -) \ No newline at end of file +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a2652c5 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= diff --git a/http/client.go b/http/client.go index ccd17b5..2d29f13 100644 --- a/http/client.go +++ b/http/client.go @@ -2,36 +2,26 @@ package http import ( "context" - "fmt" "io" "net/http" + "time" ) -type ( - // ApiHeaderFn is a function type that sets headers on HTTP requests - ApiHeaderFn func(*http.Request) +// ApiHeaderFn is a function type that sets headers on HTTP requests +type ApiHeaderFn func(*http.Request) - // option is a function type that configures a Client instance - option func(*Client) *Client -) - -// WithHttpClient returns an option that sets the HTTP client used by the Client -func WithHttpClient(client *http.Client) option { - return func(c *Client) *Client { - c.httpClient = client - return c - } +// HttpClient defines the interface for making HTTP requests. +// It provides a method to make GET requests and return the response body as bytes. +type HttpClient interface { + // MakeReq makes a GET request to the given URL. + // It returns the response body as bytes and any error that occurred. + // The context can be used to cancel the request or set a timeout. + MakeReq(ctx context.Context, url string) ([]byte, error) } -// WithApiHeaderFn returns an option that sets a function to add API headers to requests -func WithApiHeaderFn(fn ApiHeaderFn) option { - return func(c *Client) *Client { - c.apiHeaderSetter = fn - return c - } -} +var _ HttpClient = &Client{} -// Client is an HTTP client that can make API requests with optional headers +// Client is an HTTP client that can make API requests with optional headers and rate limiting type Client struct { // httpClient is the underlying HTTP client used to make requests httpClient *http.Client @@ -40,24 +30,21 @@ type Client struct { } // NewClient creates a new Client with the given options -func NewClient(opts ...option) *Client { +func NewClient(opts ...option[*Client]) *Client { c := &Client{} for _, opt := range opts { c = opt(c) } - return c -} - -// APIError is an error returned by the client when the API returns a non-200 status code -type APIError struct { - StatusCode int - Body []byte -} + // Set default HTTP client if none provided + if c.httpClient == nil { + c.httpClient = &http.Client{ + Timeout: 30 * time.Second, + } + } -func (e *APIError) Error() string { - return fmt.Sprintf("api returned status code %d", e.StatusCode) + return c } // MakeReq makes a GET request to the given URL with the configured client diff --git a/http/error.go b/http/error.go new file mode 100644 index 0000000..5553ed3 --- /dev/null +++ b/http/error.go @@ -0,0 +1,13 @@ +package http + +import "fmt" + +// APIError is an error returned by the client when the API returns a non-200 status code +type APIError struct { + StatusCode int + Body []byte +} + +func (e *APIError) Error() string { + return fmt.Sprintf("api returned status code %d", e.StatusCode) +} diff --git a/http/option.go b/http/option.go new file mode 100644 index 0000000..7292c05 --- /dev/null +++ b/http/option.go @@ -0,0 +1,98 @@ +package http + +import ( + "net/http" + "time" + + "golang.org/x/time/rate" +) + +type ( + // ClientLike is a constraint interface that defines the common behavior + // that both Client and RateLimitedClient should have + ClientLike interface { + *Client | *RateLimitedClient + } + + // option is a generic function type that configures a client instance + option[T ClientLike] func(T) T +) + +// WithHttpClient returns an option that sets the HTTP client used by the client +func WithHttpClient[T ClientLike](client *http.Client) option[T] { + return func(c T) T { + switch v := any(c).(type) { + case *Client: + v.httpClient = client + case *RateLimitedClient: + v.httpClient = client + } + return c + } +} + +// WithApiHeaderFn returns an option that sets a function to add API headers to requests +func WithApiHeaderFn[T ClientLike](fn ApiHeaderFn) option[T] { + return func(c T) T { + switch v := any(c).(type) { + case *Client: + v.apiHeaderSetter = fn + case *RateLimitedClient: + v.apiHeaderSetter = fn + } + return c + } +} + +// WithRateLimit returns an option that sets a custom rate limiter for RateLimitedClient +// This option only works with RateLimitedClient - it's a no-op for regular Client +func WithRateLimit[T ClientLike](requestsPerMinute int) option[T] { + return func(c T) T { + switch v := any(c).(type) { + case *RateLimitedClient: + // Convert requests per minute to requests per second for the rate limiter + rps := rate.Limit(float64(requestsPerMinute) / 60.0) + v.rateLimiter = rate.NewLimiter(rps, 1) + case *Client: + // No-op for regular Client + } + return c + } +} + +// WithRateLimiter returns an option that sets a custom rate limiter instance for RateLimitedClient +// This option only works with RateLimitedClient - it's a no-op for regular Client +func WithRateLimiter[T ClientLike](limiter *rate.Limiter) option[T] { + return func(c T) T { + if v, ok := any(c).(*RateLimitedClient); ok { + v.rateLimiter = limiter + } + return c + } +} + +// WithRetryPolicy returns an option that sets a custom retry policy for RateLimitedClient. +// It configures how many times to retry failed requests and the base delay between retries. +// This option only works with RateLimitedClient - it's a no-op for regular Client. +// +// The retry policy uses exponential backoff, where each retry waits longer than the previous one. +// The delay for retry n is calculated as: baseDelay * 2^n +// +// For example, with baseDelay=2s: +// - First retry: 2s +// - Second retry: 4s +// - Third retry: 8s +// - Fourth retry: 16s +// - Fifth retry: 32s +func WithRetryPolicy[T ClientLike](maxRetries int, baseDelay time.Duration) option[T] { + return func(c T) T { + if v, ok := any(c).(*RateLimitedClient); ok { + v.retryPolicy = RetryPolicy{ + maxRetries: maxRetries, + baseDelay: baseDelay, + withRetry: true, + } + } + return c + } +} diff --git a/http/rateLimitedClient.go b/http/rateLimitedClient.go new file mode 100644 index 0000000..7e00f4f --- /dev/null +++ b/http/rateLimitedClient.go @@ -0,0 +1,138 @@ +package http + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "golang.org/x/time/rate" +) + +// HttpRateLimitedlient is an interface that extends HttpClient to provide rate limiting functionality. +// It adds the ability to query the current rate limit configuration and state. +type HttpRateLimitedlient interface { + HttpClient + // GetRateLimitInfo returns the current rate limit configuration and state. + // It returns: + // - limit: The maximum number of requests allowed per second + // - burst: The maximum number of requests that can be made in a single burst + // - tokens: The current number of available tokens + GetRateLimitInfo() (limit rate.Limit, burst, tokens int) +} + +var ( + _ HttpClient = &RateLimitedClient{} + _ HttpRateLimitedlient = &RateLimitedClient{} +) + +// RetryPolicy defines the configuration for retrying failed requests. +// It specifies how many times to retry and the base delay between retries. +type RetryPolicy struct { + // maxRetries is the maximum number of times to retry a failed request + maxRetries int + // baseDelay is the initial delay between retries, which will be exponentially increased + baseDelay time.Duration + // withRetry is a boolean that indicates if the retry policy should be used + withRetry bool +} + +// defaultRetryPolicy defines the default configuration for retrying failed requests. +// It allows up to 5 retries with an initial delay of 2 seconds between attempts. +// The delay will be exponentially increased for each subsequent retry. +var defaultRetryPolicy = RetryPolicy{ + maxRetries: 5, + baseDelay: 2 * time.Second, + withRetry: true, +} + +// RateLimitedClient extends Client to add rate limiting functionality. +// It uses a token bucket rate limiter to control request frequency. +type RateLimitedClient struct { + Client + rateLimiter *rate.Limiter + retryPolicy RetryPolicy +} + +// NewRateLimitedClient creates a new RateLimitedClient with the given options +// By default, it applies CoinGecko's free tier rate limit of 30 requests per minute +func NewRateLimitedClient(opts ...option[*RateLimitedClient]) *RateLimitedClient { + // Default rate limiter for CoinGecko free tier: 30 requests per minute + // Using conservative 10 requests per minute to avoid 429s + defaultRateLimit := rate.NewLimiter(rate.Limit(10.0/60.0), 1) + + c := &RateLimitedClient{ + rateLimiter: defaultRateLimit, + retryPolicy: defaultRetryPolicy, + } + + for _, opt := range opts { + c = opt(c) + } + + // Set default HTTP client if none provided + if c.httpClient == nil { + c.httpClient = &http.Client{ + Timeout: 30 * time.Second, + } + } + + return c +} + +// MakeReq makes a GET request to the given URL with the configured client +// It respects the configured rate limit before making the request +// It returns the response body as bytes or an error if the request fails +func (c *RateLimitedClient) MakeReq(ctx context.Context, url string) ([]byte, error) { + for attempt := 0; attempt <= c.retryPolicy.maxRetries; attempt++ { + // Wait for rate limiter permission before making the request + if err := c.rateLimiter.Wait(ctx); err != nil { + return nil, fmt.Errorf("rate limiter wait failed: %w", err) + } + + resp, err := c.Client.MakeReq(ctx, url) + if err != nil { + if !c.retryPolicy.withRetry { + return resp, err + } + // Check if this is a 429 error (rate limit exceeded) + var apiErr *APIError + if errors.As(err, &apiErr) && apiErr.StatusCode == 429 { + if attempt < c.retryPolicy.maxRetries { + // Calculate exponential backoff delay + delay := c.retryPolicy.baseDelay * time.Duration(1<