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 }