Skip to content

Commit 532ce2e

Browse files
authored
Implement fallback FX rate system with exchangerate-api.com as the new provider (#165)
1 parent e4e3581 commit 532ce2e

3 files changed

Lines changed: 225 additions & 12 deletions

File tree

currency/exchangerateapi/client.go

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
package exchangerateapi
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"net/http"
8+
"strings"
9+
"time"
10+
11+
"github.com/pkg/errors"
12+
13+
"github.com/code-payments/ocp-server/currency"
14+
"github.com/code-payments/ocp-server/metrics"
15+
"github.com/code-payments/ocp-server/retry"
16+
"github.com/code-payments/ocp-server/retry/backoff"
17+
)
18+
19+
const (
20+
metricsStructName = "currency.exchangerateapi.client"
21+
)
22+
23+
const (
24+
baseUrl = "https://v6.exchangerate-api.com/v6"
25+
latestUrlFormat = baseUrl + "/%s/latest/%s"
26+
historicalUrlFormat = baseUrl + "/%s/history/%s/%d/%d/%d"
27+
)
28+
29+
const (
30+
resultSuccess = "success"
31+
errorUnsupportedCode = "unsupported-code"
32+
)
33+
34+
// API Documentation: https://www.exchangerate-api.com/docs/overview
35+
type client struct {
36+
apiKey string
37+
httpClient *http.Client
38+
retrier retry.Retrier
39+
}
40+
41+
func NewClient(apiKey string) currency.Client {
42+
return &client{
43+
apiKey: apiKey,
44+
httpClient: &http.Client{
45+
Timeout: 15 * time.Second,
46+
},
47+
retrier: retry.NewRetrier(
48+
retry.NonRetriableErrors(context.Canceled),
49+
retry.Limit(3),
50+
retry.BackoffWithJitter(backoff.BinaryExponential(time.Second), 10*time.Second, 0.1),
51+
),
52+
}
53+
}
54+
55+
// GetCurrentRates implements currency.Client.GetCurrentRates
56+
func (c *client) GetCurrentRates(ctx context.Context, base string) (*currency.ExchangeData, error) {
57+
tracer := metrics.TraceMethodCall(ctx, metricsStructName, "GetCurrentRates")
58+
defer tracer.End()
59+
60+
var resp response
61+
url := fmt.Sprintf(latestUrlFormat, c.apiKey, strings.ToUpper(base))
62+
err := c.submitRequest(ctx, url, &resp)
63+
if err != nil {
64+
tracer.OnError(err)
65+
return nil, err
66+
}
67+
68+
err = checkCustomError(resp)
69+
if err != nil {
70+
tracer.OnError(err)
71+
return nil, err
72+
}
73+
74+
return resp.toExchangeData()
75+
}
76+
77+
// GetHistoricalRates implements currency.Client.GetHistoricalRates
78+
func (c *client) GetHistoricalRates(ctx context.Context, base string, timestamp time.Time) (*currency.ExchangeData, error) {
79+
tracer := metrics.TraceMethodCall(ctx, metricsStructName, "GetHistoricalRates")
80+
defer tracer.End()
81+
82+
var resp response
83+
url := fmt.Sprintf(historicalUrlFormat, c.apiKey, strings.ToUpper(base), timestamp.Year(), int(timestamp.Month()), timestamp.Day())
84+
err := c.submitRequest(ctx, url, &resp)
85+
if err != nil {
86+
tracer.OnError(err)
87+
return nil, err
88+
}
89+
90+
err = checkCustomError(resp)
91+
if err != nil {
92+
tracer.OnError(err)
93+
return nil, err
94+
}
95+
96+
return resp.toExchangeData()
97+
}
98+
99+
func (c *client) submitRequest(ctx context.Context, url string, resp interface{}) error {
100+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody)
101+
if err != nil {
102+
return errors.Wrap(err, "failed to create request")
103+
}
104+
105+
var httpResp *http.Response
106+
_, err = c.retrier.Retry(
107+
func() error {
108+
httpResp, err = c.httpClient.Do(req)
109+
return err
110+
},
111+
)
112+
if err != nil {
113+
return errors.Wrap(err, "failed to make request")
114+
}
115+
defer httpResp.Body.Close()
116+
117+
if httpResp.StatusCode != http.StatusOK {
118+
return errors.Errorf("received non-200 status code: %d", httpResp.StatusCode)
119+
}
120+
121+
err = json.NewDecoder(httpResp.Body).Decode(resp)
122+
if err != nil {
123+
return errors.Wrap(err, "failed to decode response")
124+
}
125+
126+
return nil
127+
}
128+
129+
func checkCustomError(resp response) error {
130+
if resp.Result == resultSuccess {
131+
return nil
132+
}
133+
134+
switch resp.ErrorType {
135+
case errorUnsupportedCode:
136+
return currency.ErrInvalidBase
137+
case "":
138+
return errors.New("unknown error from exchangerate-api without error type")
139+
default:
140+
return errors.Errorf("exchangerate-api error: %s", resp.ErrorType)
141+
}
142+
}
143+
144+
type response struct {
145+
Result string `json:"result"`
146+
ErrorType string `json:"error-type"`
147+
BaseCode string `json:"base_code"`
148+
TimeLastUpdate int64 `json:"time_last_update_unix"`
149+
ConversionRates map[string]float64 `json:"conversion_rates"`
150+
}
151+
152+
func (r response) toExchangeData() (*currency.ExchangeData, error) {
153+
if r.Result != resultSuccess {
154+
return nil, errors.New("cannot convert a failed response")
155+
}
156+
157+
rates := make(map[string]float64)
158+
for symbol, rate := range r.ConversionRates {
159+
rates[strings.ToLower(symbol)] = rate
160+
}
161+
rates = excludeUnsupported(rates)
162+
163+
return &currency.ExchangeData{
164+
Base: strings.ToLower(r.BaseCode),
165+
Rates: rates,
166+
Timestamp: time.Unix(r.TimeLastUpdate, 0),
167+
}, nil
168+
}
169+
170+
func excludeUnsupported(data map[string]float64) map[string]float64 {
171+
res := make(map[string]float64, 0)
172+
173+
for symbol, rate := range data {
174+
if len(symbol) == 3 {
175+
res[symbol] = rate
176+
}
177+
}
178+
179+
return res
180+
}

ocp/data/config.go

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,16 @@ import (
66
)
77

88
const (
9-
FixerApiKeyConfigEnvName = "FIXER_API_KEY"
10-
defaultFixerApiKey = ""
9+
FixerApiKeyConfigEnvName = "FIXER_API_KEY"
10+
ExchangeRateApiKeyConfigEnvName = "EXCHANGE_RATE_API_KEY"
11+
defaultFixerApiKey = ""
12+
defaultExchangeRateApiKey = ""
1113
)
1214

1315
// todo: Add other data store configs here (eg. postgres, solana, etc).
1416
type conf struct {
15-
fixerApiKey config.String
17+
fixerApiKey config.String
18+
exchangeRateApiKey config.String
1619
}
1720

1821
// ConfigProvider defines how config values are pulled
@@ -24,7 +27,8 @@ type ConfigProvider func() *conf
2427
func WithEnvConfigs() ConfigProvider {
2528
return func() *conf {
2629
return &conf{
27-
fixerApiKey: env.NewStringConfig(FixerApiKeyConfigEnvName, defaultFixerApiKey),
30+
fixerApiKey: env.NewStringConfig(FixerApiKeyConfigEnvName, defaultFixerApiKey),
31+
exchangeRateApiKey: env.NewStringConfig(ExchangeRateApiKeyConfigEnvName, defaultExchangeRateApiKey),
2832
}
2933
}
3034
}

ocp/data/external.go

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77

88
currency_lib "github.com/code-payments/ocp-server/currency"
99
"github.com/code-payments/ocp-server/currency/coingecko"
10+
"github.com/code-payments/ocp-server/currency/exchangerateapi"
1011
"github.com/code-payments/ocp-server/currency/fixer"
1112
"github.com/code-payments/ocp-server/metrics"
1213
"github.com/code-payments/ocp-server/ocp/config"
@@ -29,15 +30,17 @@ type WebData interface {
2930
}
3031

3132
type WebProvider struct {
32-
coinGecko currency_lib.Client
33-
fixer currency_lib.Client
33+
coinGecko currency_lib.Client
34+
fixer currency_lib.Client
35+
exchangeRateApi currency_lib.Client
3436
}
3537

3638
func NewWebProvider(configProvider ConfigProvider) (WebData, error) {
3739
conf := configProvider()
3840
return &WebProvider{
39-
coinGecko: coingecko.NewClient(),
40-
fixer: fixer.NewClient(conf.fixerApiKey.Get(context.Background())),
41+
coinGecko: coingecko.NewClient(),
42+
fixer: fixer.NewClient(conf.fixerApiKey.Get(context.Background())),
43+
exchangeRateApi: exchangerateapi.NewClient(conf.exchangeRateApiKey.Get(context.Background())),
4144
}, nil
4245
}
4346

@@ -61,12 +64,12 @@ func (dp *WebProvider) GetCurrentExchangeRatesFromExternalProviders(ctx context.
6164
coinGeckoRates = coinGeckoData.Rates
6265
}
6366

64-
fixerData, err := dp.fixer.GetCurrentRates(ctx, string(currency_lib.USD))
67+
fiatRates, err := dp.getCurrentFiatRates(ctx)
6568
if err != nil {
6669
return nil, err
6770
}
6871

69-
rates, err := computeAllExchangeRates(coinGeckoRates, fixerData.Rates)
72+
rates, err := computeAllExchangeRates(coinGeckoRates, fiatRates)
7073
if err != nil {
7174
return nil, err
7275
}
@@ -95,12 +98,12 @@ func (dp *WebProvider) GetPastExchangeRatesFromExternalProviders(ctx context.Con
9598
ts = coinGeckoData.Timestamp
9699
}
97100

98-
fixerData, err := dp.fixer.GetHistoricalRates(ctx, string(currency_lib.USD), t.UTC())
101+
fiatRates, err := dp.getHistoricalFiatRates(ctx, t.UTC())
99102
if err != nil {
100103
return nil, err
101104
}
102105

103-
rates, err := computeAllExchangeRates(coinGeckoRates, fixerData.Rates)
106+
rates, err := computeAllExchangeRates(coinGeckoRates, fiatRates)
104107
if err != nil {
105108
return nil, err
106109
}
@@ -111,6 +114,32 @@ func (dp *WebProvider) GetPastExchangeRatesFromExternalProviders(ctx context.Con
111114
}, nil
112115
}
113116

117+
func (dp *WebProvider) getCurrentFiatRates(ctx context.Context) (map[string]float64, error) {
118+
fixerData, err := dp.fixer.GetCurrentRates(ctx, string(currency_lib.USD))
119+
if err == nil {
120+
return fixerData.Rates, nil
121+
}
122+
123+
exchangeRateData, err := dp.exchangeRateApi.GetCurrentRates(ctx, string(currency_lib.USD))
124+
if err != nil {
125+
return nil, err
126+
}
127+
return exchangeRateData.Rates, nil
128+
}
129+
130+
func (dp *WebProvider) getHistoricalFiatRates(ctx context.Context, t time.Time) (map[string]float64, error) {
131+
fixerData, err := dp.fixer.GetHistoricalRates(ctx, string(currency_lib.USD), t)
132+
if err == nil {
133+
return fixerData.Rates, nil
134+
}
135+
136+
exchangeRateData, err := dp.exchangeRateApi.GetHistoricalRates(ctx, string(currency_lib.USD), t)
137+
if err != nil {
138+
return nil, err
139+
}
140+
return exchangeRateData.Rates, nil
141+
}
142+
114143
func computeAllExchangeRates(coreMintRates map[string]float64, usdRates map[string]float64) (map[string]float64, error) {
115144
coreMintToUsd, ok := coreMintRates[string(currency_lib.USD)]
116145
if !ok {

0 commit comments

Comments
 (0)