github.com/prebid/prebid-server/v2@v2.18.0/currency/rate_converter.go (about)

     1  package currency
     2  
     3  import (
     4  	"fmt"
     5  	"io"
     6  	"net/http"
     7  	"sync/atomic"
     8  	"time"
     9  
    10  	"github.com/golang/glog"
    11  	"github.com/prebid/prebid-server/v2/errortypes"
    12  	"github.com/prebid/prebid-server/v2/util/jsonutil"
    13  	"github.com/prebid/prebid-server/v2/util/timeutil"
    14  )
    15  
    16  // RateConverter holds the currencies conversion rates dictionary
    17  type RateConverter struct {
    18  	httpClient          httpClient
    19  	staleRatesThreshold time.Duration
    20  	syncSourceURL       string
    21  	rates               atomic.Value // Should only hold Rates struct
    22  	lastUpdated         atomic.Value // Should only hold time.Time
    23  	constantRates       Conversions
    24  	time                timeutil.Time
    25  }
    26  
    27  // NewRateConverter returns a new RateConverter
    28  func NewRateConverter(
    29  	httpClient httpClient,
    30  	syncSourceURL string,
    31  	staleRatesThreshold time.Duration,
    32  ) *RateConverter {
    33  	return &RateConverter{
    34  		httpClient:          httpClient,
    35  		staleRatesThreshold: staleRatesThreshold,
    36  		syncSourceURL:       syncSourceURL,
    37  		rates:               atomic.Value{},
    38  		lastUpdated:         atomic.Value{},
    39  		constantRates:       NewConstantRates(),
    40  		time:                &timeutil.RealTime{},
    41  	}
    42  }
    43  
    44  // fetch allows to retrieve the currencies rates from the syncSourceURL provided
    45  func (rc *RateConverter) fetch() (*Rates, error) {
    46  	request, err := http.NewRequest("GET", rc.syncSourceURL, nil)
    47  	if err != nil {
    48  		return nil, err
    49  	}
    50  
    51  	response, err := rc.httpClient.Do(request)
    52  	if err != nil {
    53  		return nil, err
    54  	}
    55  
    56  	if response.StatusCode >= 400 {
    57  		message := fmt.Sprintf("The currency rates request failed with status code %d", response.StatusCode)
    58  		return nil, &errortypes.BadServerResponse{Message: message}
    59  	}
    60  
    61  	defer response.Body.Close()
    62  
    63  	bytesJSON, err := io.ReadAll(response.Body)
    64  	if err != nil {
    65  		return nil, err
    66  	}
    67  
    68  	updatedRates := &Rates{}
    69  	err = jsonutil.UnmarshalValid(bytesJSON, updatedRates)
    70  	if err != nil {
    71  		return nil, err
    72  	}
    73  
    74  	return updatedRates, err
    75  }
    76  
    77  // Update updates the internal currencies rates from remote sources
    78  func (rc *RateConverter) update() error {
    79  	rates, err := rc.fetch()
    80  	if err == nil {
    81  		rc.rates.Store(rates)
    82  		rc.lastUpdated.Store(rc.time.Now())
    83  	} else {
    84  		if rc.checkStaleRates() {
    85  			rc.clearRates()
    86  			glog.Errorf("Error updating conversion rates, falling back to constant rates: %v", err)
    87  		} else {
    88  			glog.Errorf("Error updating conversion rates: %v", err)
    89  		}
    90  	}
    91  
    92  	return err
    93  }
    94  
    95  func (rc *RateConverter) Run() error {
    96  	return rc.update()
    97  }
    98  
    99  // LastUpdated returns time when currencies rates were updated
   100  func (rc *RateConverter) LastUpdated() time.Time {
   101  	if lastUpdated := rc.lastUpdated.Load(); lastUpdated != nil {
   102  		return lastUpdated.(time.Time)
   103  	}
   104  	return time.Time{}
   105  }
   106  
   107  // Rates returns current conversions rates
   108  func (rc *RateConverter) Rates() Conversions {
   109  	// atomic.Value field rates is an empty interface and will be of type *Rates the first time rates are stored
   110  	// or nil if the rates have never been stored
   111  	if rates := rc.rates.Load(); rates != (*Rates)(nil) && rates != nil {
   112  		return rates.(*Rates)
   113  	}
   114  	return rc.constantRates
   115  }
   116  
   117  // clearRates sets the rates to nil
   118  func (rc *RateConverter) clearRates() {
   119  	// atomic.Value field rates must be of type *Rates so we cast nil to that type
   120  	rc.rates.Store((*Rates)(nil))
   121  }
   122  
   123  // checkStaleRates checks if loaded third party conversion rates are stale
   124  func (rc *RateConverter) checkStaleRates() bool {
   125  	if rc.staleRatesThreshold <= 0 {
   126  		return false
   127  	}
   128  
   129  	currentTime := rc.time.Now().UTC()
   130  	if lastUpdated := rc.lastUpdated.Load(); lastUpdated != nil {
   131  		delta := currentTime.Sub(lastUpdated.(time.Time).UTC())
   132  		if delta.Seconds() > rc.staleRatesThreshold.Seconds() {
   133  			return true
   134  		}
   135  	}
   136  	return false
   137  }
   138  
   139  // GetInfo returns setup information about the converter
   140  func (rc *RateConverter) GetInfo() ConverterInfo {
   141  	var rates *map[string]map[string]float64
   142  	rates = rc.Rates().GetRates()
   143  	return converterInfo{
   144  		source:      rc.syncSourceURL,
   145  		lastUpdated: rc.LastUpdated(),
   146  		rates:       rates,
   147  	}
   148  }
   149  
   150  type httpClient interface {
   151  	Do(req *http.Request) (*http.Response, error)
   152  }
   153  
   154  // Conversions allows to get a conversion rate between two currencies.
   155  // if one of the currency string is not a currency or if there is not conversion between those
   156  // currencies, then an err is returned and rate is 0.
   157  type Conversions interface {
   158  	GetRate(from string, to string) (float64, error)
   159  	GetRates() *map[string]map[string]float64
   160  }