github.com/prebid/prebid-server/v2@v2.18.0/floors/fetcher.go (about)

     1  package floors
     2  
     3  import (
     4  	"container/heap"
     5  	"context"
     6  	"encoding/json"
     7  	"errors"
     8  	"io"
     9  	"math"
    10  	"net/http"
    11  	"strconv"
    12  	"time"
    13  
    14  	"github.com/alitto/pond"
    15  	validator "github.com/asaskevich/govalidator"
    16  	"github.com/coocood/freecache"
    17  	"github.com/golang/glog"
    18  	"github.com/prebid/prebid-server/v2/config"
    19  	"github.com/prebid/prebid-server/v2/metrics"
    20  	"github.com/prebid/prebid-server/v2/openrtb_ext"
    21  	"github.com/prebid/prebid-server/v2/util/timeutil"
    22  )
    23  
    24  var refetchCheckInterval = 300
    25  
    26  type fetchInfo struct {
    27  	config.AccountFloorFetch
    28  	fetchTime      int64
    29  	refetchRequest bool
    30  	retryCount     int
    31  }
    32  
    33  type WorkerPool interface {
    34  	TrySubmit(task func()) bool
    35  	Stop()
    36  }
    37  
    38  type FloorFetcher interface {
    39  	Fetch(configs config.AccountPriceFloors) (*openrtb_ext.PriceFloorRules, string)
    40  	Stop()
    41  }
    42  
    43  type PriceFloorFetcher struct {
    44  	pool            WorkerPool            // Goroutines worker pool
    45  	fetchQueue      FetchQueue            // Priority Queue to fetch floor data
    46  	fetchInProgress map[string]bool       // Map of URL with fetch status
    47  	configReceiver  chan fetchInfo        // Channel which recieves URLs to be fetched
    48  	done            chan struct{}         // Channel to close fetcher
    49  	cache           *freecache.Cache      // cache
    50  	httpClient      *http.Client          // http client to fetch data from url
    51  	time            timeutil.Time         // time interface to record request timings
    52  	metricEngine    metrics.MetricsEngine // Records malfunctions in dynamic fetch
    53  	maxRetries      int                   // Max number of retries for failing URLs
    54  }
    55  
    56  type FetchQueue []*fetchInfo
    57  
    58  func (fq FetchQueue) Len() int {
    59  	return len(fq)
    60  }
    61  
    62  func (fq FetchQueue) Less(i, j int) bool {
    63  	return fq[i].fetchTime < fq[j].fetchTime
    64  }
    65  
    66  func (fq FetchQueue) Swap(i, j int) {
    67  	fq[i], fq[j] = fq[j], fq[i]
    68  }
    69  
    70  func (fq *FetchQueue) Push(element interface{}) {
    71  	fetchInfo := element.(*fetchInfo)
    72  	*fq = append(*fq, fetchInfo)
    73  }
    74  
    75  func (fq *FetchQueue) Pop() interface{} {
    76  	old := *fq
    77  	n := len(old)
    78  	fetchInfo := old[n-1]
    79  	old[n-1] = nil
    80  	*fq = old[0 : n-1]
    81  	return fetchInfo
    82  }
    83  
    84  func (fq *FetchQueue) Top() *fetchInfo {
    85  	old := *fq
    86  	if len(old) == 0 {
    87  		return nil
    88  	}
    89  	return old[0]
    90  }
    91  
    92  func workerPanicHandler(p interface{}) {
    93  	glog.Errorf("floor fetcher worker panicked: %v", p)
    94  }
    95  
    96  func NewPriceFloorFetcher(config config.PriceFloors, httpClient *http.Client, metricEngine metrics.MetricsEngine) *PriceFloorFetcher {
    97  	if !config.Enabled {
    98  		return nil
    99  	}
   100  
   101  	floorFetcher := PriceFloorFetcher{
   102  		pool:            pond.New(config.Fetcher.Worker, config.Fetcher.Capacity, pond.PanicHandler(workerPanicHandler)),
   103  		fetchQueue:      make(FetchQueue, 0, 100),
   104  		fetchInProgress: make(map[string]bool),
   105  		configReceiver:  make(chan fetchInfo, config.Fetcher.Capacity),
   106  		done:            make(chan struct{}),
   107  		cache:           freecache.NewCache(config.Fetcher.CacheSize * 1024 * 1024),
   108  		httpClient:      httpClient,
   109  		time:            &timeutil.RealTime{},
   110  		metricEngine:    metricEngine,
   111  		maxRetries:      config.Fetcher.MaxRetries,
   112  	}
   113  
   114  	go floorFetcher.Fetcher()
   115  
   116  	return &floorFetcher
   117  }
   118  
   119  func (f *PriceFloorFetcher) SetWithExpiry(key string, value json.RawMessage, cacheExpiry int) {
   120  	f.cache.Set([]byte(key), value, cacheExpiry)
   121  }
   122  
   123  func (f *PriceFloorFetcher) Get(key string) (json.RawMessage, bool) {
   124  	data, err := f.cache.Get([]byte(key))
   125  	if err != nil {
   126  		return nil, false
   127  	}
   128  
   129  	return data, true
   130  }
   131  
   132  func (f *PriceFloorFetcher) Fetch(config config.AccountPriceFloors) (*openrtb_ext.PriceFloorRules, string) {
   133  	if f == nil || !config.UseDynamicData || len(config.Fetcher.URL) == 0 || !validator.IsURL(config.Fetcher.URL) {
   134  		return nil, openrtb_ext.FetchNone
   135  	}
   136  
   137  	// Check for floors JSON in cache
   138  	if result, found := f.Get(config.Fetcher.URL); found {
   139  		var fetchedFloorData openrtb_ext.PriceFloorRules
   140  		if err := json.Unmarshal(result, &fetchedFloorData); err != nil || fetchedFloorData.Data == nil {
   141  			return nil, openrtb_ext.FetchError
   142  		}
   143  		return &fetchedFloorData, openrtb_ext.FetchSuccess
   144  	}
   145  
   146  	//miss: push to channel to fetch and return empty response
   147  	if config.Enabled && config.Fetcher.Enabled && config.Fetcher.Timeout > 0 {
   148  		fetchConfig := fetchInfo{AccountFloorFetch: config.Fetcher, fetchTime: f.time.Now().Unix(), refetchRequest: false, retryCount: 0}
   149  		f.configReceiver <- fetchConfig
   150  	}
   151  
   152  	return nil, openrtb_ext.FetchInprogress
   153  }
   154  
   155  func (f *PriceFloorFetcher) worker(fetchConfig fetchInfo) {
   156  	floorData, fetchedMaxAge := f.fetchAndValidate(fetchConfig.AccountFloorFetch)
   157  	if floorData != nil {
   158  		// Reset retry count when data is successfully fetched
   159  		fetchConfig.retryCount = 0
   160  
   161  		// Update cache with new floor rules
   162  		cacheExpiry := fetchConfig.AccountFloorFetch.MaxAge
   163  		if fetchedMaxAge != 0 {
   164  			cacheExpiry = fetchedMaxAge
   165  		}
   166  		floorData, err := json.Marshal(floorData)
   167  		if err != nil {
   168  			glog.Errorf("Error while marshaling fetched floor data for url %s", fetchConfig.AccountFloorFetch.URL)
   169  		} else {
   170  			f.SetWithExpiry(fetchConfig.AccountFloorFetch.URL, floorData, cacheExpiry)
   171  		}
   172  	} else {
   173  		fetchConfig.retryCount++
   174  	}
   175  
   176  	// Send to refetch channel
   177  	if fetchConfig.retryCount < f.maxRetries {
   178  		fetchConfig.fetchTime = f.time.Now().Add(time.Duration(fetchConfig.AccountFloorFetch.Period) * time.Second).Unix()
   179  		fetchConfig.refetchRequest = true
   180  		f.configReceiver <- fetchConfig
   181  	}
   182  }
   183  
   184  // Stop terminates price floor fetcher
   185  func (f *PriceFloorFetcher) Stop() {
   186  	if f == nil {
   187  		return
   188  	}
   189  
   190  	close(f.done)
   191  	f.pool.Stop()
   192  	close(f.configReceiver)
   193  }
   194  
   195  func (f *PriceFloorFetcher) submit(fetchConfig *fetchInfo) {
   196  	status := f.pool.TrySubmit(func() {
   197  		f.worker(*fetchConfig)
   198  	})
   199  	if !status {
   200  		heap.Push(&f.fetchQueue, fetchConfig)
   201  	}
   202  }
   203  
   204  func (f *PriceFloorFetcher) Fetcher() {
   205  	//Create Ticker of 5 minutes
   206  	ticker := time.NewTicker(time.Duration(refetchCheckInterval) * time.Second)
   207  
   208  	for {
   209  		select {
   210  		case fetchConfig := <-f.configReceiver:
   211  			if fetchConfig.refetchRequest {
   212  				heap.Push(&f.fetchQueue, &fetchConfig)
   213  			} else {
   214  				if _, ok := f.fetchInProgress[fetchConfig.URL]; !ok {
   215  					f.fetchInProgress[fetchConfig.URL] = true
   216  					f.submit(&fetchConfig)
   217  				}
   218  			}
   219  		case <-ticker.C:
   220  			currentTime := f.time.Now().Unix()
   221  			for top := f.fetchQueue.Top(); top != nil && top.fetchTime <= currentTime; top = f.fetchQueue.Top() {
   222  				nextFetch := heap.Pop(&f.fetchQueue)
   223  				f.submit(nextFetch.(*fetchInfo))
   224  			}
   225  		case <-f.done:
   226  			ticker.Stop()
   227  			glog.Info("Price Floor fetcher terminated")
   228  			return
   229  		}
   230  	}
   231  }
   232  
   233  func (f *PriceFloorFetcher) fetchAndValidate(config config.AccountFloorFetch) (*openrtb_ext.PriceFloorRules, int) {
   234  	floorResp, maxAge, err := f.fetchFloorRulesFromURL(config)
   235  	if floorResp == nil || err != nil {
   236  		glog.Errorf("Error while fetching floor data from URL: %s, reason : %s", config.URL, err.Error())
   237  		return nil, 0
   238  	}
   239  
   240  	if len(floorResp) > (config.MaxFileSizeKB * 1024) {
   241  		glog.Errorf("Recieved invalid floor data from URL: %s, reason : floor file size is greater than MaxFileSize", config.URL)
   242  		return nil, 0
   243  	}
   244  
   245  	var priceFloors openrtb_ext.PriceFloorRules
   246  	if err = json.Unmarshal(floorResp, &priceFloors.Data); err != nil {
   247  		glog.Errorf("Recieved invalid price floor json from URL: %s", config.URL)
   248  		return nil, 0
   249  	}
   250  
   251  	if err := validateRules(config, &priceFloors); err != nil {
   252  		glog.Errorf("Validation failed for floor JSON from URL: %s, reason: %s", config.URL, err.Error())
   253  		return nil, 0
   254  	}
   255  
   256  	return &priceFloors, maxAge
   257  }
   258  
   259  // fetchFloorRulesFromURL returns a price floor JSON and time for which this JSON is valid
   260  // from provided URL with timeout constraints
   261  func (f *PriceFloorFetcher) fetchFloorRulesFromURL(config config.AccountFloorFetch) ([]byte, int, error) {
   262  	ctx, cancel := context.WithTimeout(context.Background(), time.Duration(config.Timeout)*time.Millisecond)
   263  	defer cancel()
   264  
   265  	httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, config.URL, nil)
   266  	if err != nil {
   267  		return nil, 0, errors.New("error while forming http fetch request : " + err.Error())
   268  	}
   269  
   270  	httpResp, err := f.httpClient.Do(httpReq)
   271  	if err != nil {
   272  		return nil, 0, errors.New("error while getting response from url : " + err.Error())
   273  	}
   274  
   275  	if httpResp.StatusCode != http.StatusOK {
   276  		return nil, 0, errors.New("no response from server")
   277  	}
   278  
   279  	var maxAge int
   280  	if maxAgeStr := httpResp.Header.Get("max-age"); maxAgeStr != "" {
   281  		maxAge, err = strconv.Atoi(maxAgeStr)
   282  		if err != nil {
   283  			glog.Errorf("max-age in header is malformed for url %s", config.URL)
   284  		}
   285  		if maxAge <= config.Period || maxAge > math.MaxInt32 {
   286  			glog.Errorf("Invalid max-age = %s provided, value should be valid integer and should be within (%v, %v)", maxAgeStr, config.Period, math.MaxInt32)
   287  		}
   288  	}
   289  
   290  	respBody, err := io.ReadAll(httpResp.Body)
   291  	if err != nil {
   292  		return nil, 0, errors.New("unable to read response")
   293  	}
   294  	defer httpResp.Body.Close()
   295  
   296  	return respBody, maxAge, nil
   297  }
   298  
   299  func validateRules(config config.AccountFloorFetch, priceFloors *openrtb_ext.PriceFloorRules) error {
   300  	if priceFloors.Data == nil {
   301  		return errors.New("empty data in floor JSON")
   302  	}
   303  
   304  	if len(priceFloors.Data.ModelGroups) == 0 {
   305  		return errors.New("no model groups found in price floor data")
   306  	}
   307  
   308  	if priceFloors.Data.SkipRate < 0 || priceFloors.Data.SkipRate > 100 {
   309  		return errors.New("skip rate should be greater than or equal to 0 and less than 100")
   310  	}
   311  
   312  	if priceFloors.Data.FetchRate != nil && (*priceFloors.Data.FetchRate < dataRateMin || *priceFloors.Data.FetchRate > dataRateMax) {
   313  		return errors.New("FetchRate should be greater than or equal to 0 and less than or equal to 100")
   314  	}
   315  
   316  	for _, modelGroup := range priceFloors.Data.ModelGroups {
   317  		if len(modelGroup.Values) == 0 || len(modelGroup.Values) > config.MaxRules {
   318  			return errors.New("invalid number of floor rules, floor rules should be greater than zero and less than MaxRules specified in account config")
   319  		}
   320  
   321  		if modelGroup.ModelWeight != nil && (*modelGroup.ModelWeight < 1 || *modelGroup.ModelWeight > 100) {
   322  			return errors.New("modelGroup[].modelWeight should be greater than or equal to 1 and less than 100")
   323  		}
   324  
   325  		if modelGroup.SkipRate < 0 || modelGroup.SkipRate > 100 {
   326  			return errors.New("model group skip rate should be greater than or equal to 0 and less than 100")
   327  		}
   328  
   329  		if modelGroup.Default < 0 {
   330  			return errors.New("modelGroup.Default should be greater than 0")
   331  		}
   332  	}
   333  
   334  	return nil
   335  }