Repository: prebid/prebid-server
Pull request #1182 (2020-01-31 20:18:32)

Adds timeout notifications for Facebook

hhhjort: We can only generate one timeout notification per timeout, and we already have metrics on timeout, so I don't see the need for an additional metric for explicit notifications

Patch:
diff --git a/adapters/adpone/adpone.go b/adapters/adpone/adpone.go
index 345a4988..b1822a0a 100644
--- a/adapters/adpone/adpone.go
+++ b/adapters/adpone/adpone.go
@@ -3,9 +3,10 @@ package adpone
 import (
 	"encoding/json"
 	"fmt"
-	"github.com/prebid/prebid-server/openrtb_ext"
 	"net/http"
 
+	"github.com/prebid/prebid-server/openrtb_ext"
+
 	"github.com/mxmCherry/openrtb"
 	"github.com/prebid/prebid-server/adapters"
 	"github.com/prebid/prebid-server/errortypes"
diff --git a/adapters/audienceNetwork/facebook.go b/adapters/audienceNetwork/facebook.go
index 706673cb..3ece7bb9 100644
--- a/adapters/audienceNetwork/facebook.go
+++ b/adapters/audienceNetwork/facebook.go
@@ -447,3 +447,20 @@ func NewFacebookBidder(client *http.Client, platformID string, appSecret string)
 		appSecret:    appSecret,
 	}
 }
+
+func (fa *FacebookAdapter) MakeTimeoutNotification(req *adapters.RequestData) (*adapters.RequestData, []error) {
+	// Note, facebook creates one request per imp, so all these requests will only have one imp in them
+	auction_id, err := jsonparser.GetString(req.Body, "imp", "[0]", "id")
+	if err != nil {
+		return &adapters.RequestData{}, []error{err}
+	}
+
+	uri := fmt.Sprintf("https://www.facebook.com/audiencenetwork/nurl/?partner=%s&app=%s&auction=%s&ortb_loss_code=2", fa.platformID, fa.platformID, auction_id)
+	timeoutReq := adapters.RequestData{
+		Method:  "GET",
+		Uri:     uri,
+		Body:    nil,
+		Headers: http.Header{},
+	}
+	return &timeoutReq, nil
+}
diff --git a/adapters/bidder.go b/adapters/bidder.go
index 9d3ffb75..baec4135 100644
--- a/adapters/bidder.go
+++ b/adapters/bidder.go
@@ -39,6 +39,19 @@ type Bidder interface {
 	MakeBids(internalRequest *openrtb.BidRequest, externalRequest *RequestData, response *ResponseData) (*BidderResponse, []error)
 }
 
+// TimeoutBidder is used to identify bidders that support timeout notifications.
+type TimeoutBidder interface {
+	Bidder
+
+	// MakeTimeoutNotice functions much the same as MakeRequests, except it is fed the bidder request that timed out,
+	// and expects that only one notification "request" will be generated. A use case for multiple timeout notifications
+	// has not been anticipated.
+	//
+	// Do note that if MakeRequests returns multiple requests, and more than one of these times out, MakeTimeoutNotice will be called
+	// once for each timed out request.
+	MakeTimeoutNotification(req *RequestData) (*RequestData, []error)
+}
+
 type MisconfiguredBidder struct {
 	Name  string
 	Error error
diff --git a/config/config.go b/config/config.go
index 2f302cc6..943d18a9 100644
--- a/config/config.go
+++ b/config/config.go
@@ -23,6 +23,7 @@ type Configuration struct {
 	Host        string     `mapstructure:"host"`
 	Port        int        `mapstructure:"port"`
 	Client      HTTPClient `mapstructure:"http_client"`
+	CacheClient HTTPClient `mapstructure:"http_client_cache"`
 	AdminPort   int        `mapstructure:"admin_port"`
 	EnableGzip  bool       `mapstructure:"enable_gzip"`
 	// StatusResponse is the string which will be returned by the /status endpoint when things are OK.
@@ -583,6 +584,9 @@ func SetupViper(v *viper.Viper, filename string) {
 	v.SetDefault("http_client.max_idle_connections", 400)
 	v.SetDefault("http_client.max_idle_connections_per_host", 10)
 	v.SetDefault("http_client.idle_connection_timeout_seconds", 60)
+	v.SetDefault("http_client_cache.max_idle_connections", 10)
+	v.SetDefault("http_client_cache.max_idle_connections_per_host", 2)
+	v.SetDefault("http_client_cache.idle_connection_timeout_seconds", 60)
 	// no metrics configured by default (metrics{host|database|username|password})
 	v.SetDefault("metrics.disabled_metrics.account_adapter_details", false)
 	v.SetDefault("metrics.influxdb.host", "")
diff --git a/exchange/bidder.go b/exchange/bidder.go
index 57086600..d9a28fee 100644
--- a/exchange/bidder.go
+++ b/exchange/bidder.go
@@ -8,6 +8,7 @@ import (
 	"fmt"
 	"io/ioutil"
 	"net/http"
+	"time"
 
 	"github.com/mxmCherry/openrtb"
 	nativeRequests "github.com/mxmCherry/openrtb/native/request"
@@ -295,6 +296,14 @@ func (bidder *bidderAdapter) doRequest(ctx context.Context, req *adapters.Reques
 	if err != nil {
 		if err == context.DeadlineExceeded {
 			err = &errortypes.Timeout{Message: err.Error()}
+			if tb, ok := bidder.Bidder.(adapters.TimeoutBidder); ok {
+				// Toss the timeout notification call into a go routine, as we are out of time'
+				// and cannot delay processing. We don't do anything result, as there is not much
+				// we can do about a timeout notification failure. We do not want to get stuck in
+				// a loop of trying to report timeouts to the timeout notifications.
+				go bidder.doTimeoutNotification(tb, req)
+			}
+
 		}
 		return &httpCallInfo{
 			request: req,
@@ -328,6 +337,21 @@ func (bidder *bidderAdapter) doRequest(ctx context.Context, req *adapters.Reques
 	}
 }
 
+func (bidder *bidderAdapter) doTimeoutNotification(timeoutBidder adapters.TimeoutBidder, req *adapters.RequestData) {
+	ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
+	defer cancel()
+	toReq, errL := timeoutBidder.MakeTimeoutNotification(req)
+	if toReq != nil && len(errL) == 0 {
+		httpReq, err := http.NewRequest(toReq.Method, toReq.Uri, bytes.NewBuffer(toReq.Body))
+		if err == nil {
+			httpReq.Header = req.Headers
+			ctxhttp.Do(ctx, bidder.Client, httpReq)
+			// No validation yet on sending notifications
+		}
+	}
+
+}
+
 type httpCallInfo struct {
 	request  *adapters.RequestData
 	response *adapters.ResponseData
diff --git a/exchange/exchange.go b/exchange/exchange.go
index 3d9055ca..8c6cac4c 100644
--- a/exchange/exchange.go
+++ b/exchange/exchange.go
@@ -4,6 +4,7 @@ import (
 	"bytes"
 	"context"
 	"encoding/json"
+	"errors"
 	"fmt"
 	"math/rand"
 	"net/http"
@@ -146,10 +147,14 @@ func (e *exchange) HoldAuction(ctx context.Context, bidRequest *openrtb.BidReque
 		//If includebrandcategory is present in ext then CE feature is on.
 		if requestExt.Prebid.Targeting != nil && requestExt.Prebid.Targeting.IncludeBrandCategory != nil {
 			var err error
-			bidCategory, adapterBids, err = applyCategoryMapping(ctx, requestExt, adapterBids, *categoriesFetcher, targData)
+			var rejections []string
+			bidCategory, adapterBids, rejections, err = applyCategoryMapping(ctx, requestExt, adapterBids, *categoriesFetcher, targData)
 			if err != nil {
 				return nil, fmt.Errorf("Error in category mapping : %s", err.Error())
 			}
+			for _, message := range rejections {
+				errs = append(errs, errors.New(message))
+			}
 		}
 
 		auc = newAuction(adapterBids, len(bidRequest.Imp))
@@ -340,7 +345,7 @@ func (e *exchange) buildBidResponse(ctx context.Context, liveAdapters []openrtb_
 	return bidResponse, err
 }
 
-func applyCategoryMapping(ctx context.Context, requestExt openrtb_ext.ExtRequest, seatBids map[openrtb_ext.BidderName]*pbsOrtbSeatBid, categoriesFetcher stored_requests.CategoryFetcher, targData *targetData) (map[string]string, map[openrtb_ext.BidderName]*pbsOrtbSeatBid, error) {
+func applyCategoryMapping(ctx context.Context, requestExt openrtb_ext.ExtRequest, seatBids map[openrtb_ext.BidderName]*pbsOrtbSeatBid, categoriesFetcher stored_requests.CategoryFetcher, targData *targetData) (map[string]string, map[openrtb_ext.BidderName]*pbsOrtbSeatBid, []string, error) {
 	res := make(map[string]string)
 
 	type bidDedupe struct {
@@ -359,6 +364,7 @@ func applyCategoryMapping(ctx context.Context, requestExt openrtb_ext.ExtRequest
 	var primaryAdServer string
 	var publisher string
 	var err error
+	var rejections []string
 	var translateCategories = true
 
 	if includeBrandCategory && brandCatExt.WithCategory {
@@ -370,7 +376,7 @@ func applyCategoryMapping(ctx context.Context, requestExt openrtb_ext.ExtRequest
 			//if ext.prebid.targeting.includebrandcategory present but primaryadserver/publisher not present then error out the request right away.
 			primaryAdServer, err = getPrimaryAdServer(brandCatExt.PrimaryAdServer) //1-Freewheel 2-DFP
 			if err != nil {
-				return res, seatBids, err
+				return res, seatBids, rejections, err
 			}
 			publisher = brandCatExt.Publisher
 		}
@@ -382,6 +388,7 @@ func applyCategoryMapping(ctx context.Context, requestExt openrtb_ext.ExtRequest
 		bidsToRemove := make([]int, 0)
 		for bidInd := range seatBid.bids {
 			bid := seatBid.bids[bidInd]
+			bidID := bid.bid.ID
 			var duration int
 			var category string
 			var pb string
@@ -396,6 +403,7 @@ func applyCategoryMapping(ctx context.Context, requestExt openrtb_ext.ExtRequest
 					//TODO: add metrics
 					//on receiving bids from adapters if no unique IAB category is returned  or if no ad server category is returned discard the bid
 					bidsToRemove = append(bidsToRemove, bidInd)
+					rejections = updateRejections(rejections, bidID, "Bid did not contain a category")
 					continue
 				}
 				if translateCategories {
@@ -405,6 +413,8 @@ func applyCategoryMapping(ctx context.Context, requestExt openrtb_ext.ExtRequest
 						//TODO: add metrics
 						//if mapping required but no mapping file is found then discard the bid
 						bidsToRemove = append(bidsToRemove, bidInd)
+						reason := fmt.Sprintf("Category mapping file for primary ad server: '%s', publisher: '%s' not found", primaryAdServer, publisher)
+						rejections = updateRejections(rejections, bidID, reason)
 						continue
 					}
 				} else {
@@ -424,6 +434,7 @@ func applyCategoryMapping(ctx context.Context, requestExt openrtb_ext.ExtRequest
 				//if the bid is above the range of the listed durations (and outside the buffer), reject the bid
 				if duration > durationRange[len(durationRange)-1] {
 					bidsToRemove = append(bidsToRemove, bidInd)
+					rejections = updateRejections(rejections, bidID, "Bid duration exceeds maximum allowed")
 					continue
 				}
 				for _, dur := range durationRange {
@@ -447,11 +458,13 @@ func applyCategoryMapping(ctx context.Context, requestExt openrtb_ext.ExtRequest
 					if dupe.bidderName == bidderName {
 						// An older bid from the current bidder
 						bidsToRemove = append(bidsToRemove, dupe.bidIndex)
+						rejections = updateRejections(rejections, dupe.bidID, "Bid was deduplicated")
 					} else {
 						// An older bid from a different seatBid we've already finished with
 						oldSeatBid := (seatBids)[dupe.bidderName]
 						if len(oldSeatBid.bids) == 1 {
 							seatBidsToRemove = append(seatBidsToRemove, bidderName)
+							rejections = updateRejections(rejections, dupe.bidID, "Bid was deduplicated")
 						} else {
 							oldSeatBid.bids = append(oldSeatBid.bids[:dupe.bidIndex], oldSeatBid.bids[dupe.bidIndex+1:]...)
 						}
@@ -460,11 +473,12 @@ func applyCategoryMapping(ctx context.Context, requestExt openrtb_ext.ExtRequest
 				} else {
 					// Remove this bid
 					bidsToRemove = append(bidsToRemove, bidInd)
+					rejections = updateRejections(rejections, bidID, "Bid was deduplicated")
 					continue
 				}
 			}
-			res[bid.bid.ID] = categoryDuration
-			dedupe[categoryDuration] = bidDedupe{bidderName: bidderName, bidIndex: bidInd, bidID: bid.bid.ID}
+			res[bidID] = categoryDuration
+			dedupe[categoryDuration] = bidDedupe{bidderName: bidderName, bidIndex: bidInd, bidID: bidID}
 		}
 
 		if len(bidsToRemove) > 0 {
@@ -483,19 +497,16 @@ func applyCategoryMapping(ctx context.Context, requestExt openrtb_ext.ExtRequest
 		}
 
 	}
-	if len(seatBidsToRemove) > 0 {
-		if len(seatBidsToRemove) == len(seatBids) {
-			//delete all seat bids
-			seatBids = nil
-		} else {
-			for _, seatBidInd := range seatBidsToRemove {
-				delete(seatBids, seatBidInd)
-			}
-
-		}
+	for _, seatBidInd := range seatBidsToRemove {
+		seatBids[seatBidInd].bids = nil
 	}
 
-	return res, seatBids, nil
+	return res, seatBids, rejections, nil
+}
+
+func updateRejections(rejections []string, bidID string, reason string) []string {
+	message := fmt.Sprintf("bid rejected [bid ID: %s] reason: %s", bidID, reason)
+	return append(rejections, message)
 }
 
 func getPrimaryAdServer(adServerId int) (string, error) {
diff --git a/prebid_cache_client/client.go b/prebid_cache_client/client.go
index 58e2734e..a5730ce7 100644
--- a/prebid_cache_client/client.go
+++ b/prebid_cache_client/client.go
@@ -47,14 +47,9 @@ type Cacheable struct {
 	Key        string
 }
 
-func NewClient(conf *config.Cache, extCache *config.ExternalCache, metrics pbsmetrics.MetricsEngine) Client {
+func NewClient(httpClient *http.Client, conf *config.Cache, extCache *config.ExternalCache, metrics pbsmetrics.MetricsEngine) Client {
 	return &clientImpl{
-		httpClient: &http.Client{
-			Transport: &http.Transport{
-				MaxIdleConns:    10,
-				IdleConnTimeout: 65,
-			},
-		},
+		httpClient:        httpClient,
 		putUrl:            conf.GetBaseURL() + "/cache",
 		externalCacheHost: extCache.Host,
 		externalCachePath: extCache.Path,
diff --git a/router/router.go b/router/router.go
index 19946391..449ab65a 100644
--- a/router/router.go
+++ b/router/router.go
@@ -183,7 +183,7 @@ func New(cfg *config.Configuration, rateConvertor *currencies.RateConverter) (r
 		glog.Infof("Could not read certificates file: %s \n", readCertErr.Error())
 	}
 
-	theClient := &http.Client{
+	generalHttpClient := &http.Client{
 		Transport: &http.Transport{
 			MaxIdleConns:        cfg.Client.MaxIdleConns,
 			MaxIdleConnsPerHost: cfg.Client.MaxIdleConnsPerHost,
@@ -191,13 +191,22 @@ func New(cfg *config.Configuration, rateConvertor *currencies.RateConverter) (r
 			TLSClientConfig:     &tls.Config{RootCAs: certPool},
 		},
 	}
+
+	cacheHttpClient := &http.Client{
+		Transport: &http.Transport{
+			MaxIdleConns:        cfg.CacheClient.MaxIdleConns,
+			MaxIdleConnsPerHost: cfg.CacheClient.MaxIdleConnsPerHost,
+			IdleConnTimeout:     time.Duration(cfg.CacheClient.IdleConnTimeout) * time.Second,
+		},
+	}
+
 	// Hack because of how legacy handles districtm
 	legacyBidderList := openrtb_ext.BidderList()
 	legacyBidderList = append(legacyBidderList, openrtb_ext.BidderName("districtm"))
 
 	// Metrics engine
 	r.MetricsEngine = metricsConf.NewMetricsEngine(cfg, legacyBidderList)
-	db, shutdown, fetcher, ampFetcher, categoriesFetcher, videoFetcher := storedRequestsConf.NewStoredRequests(cfg, r.MetricsEngine, theClient, r.Router)
+	db, shutdown, fetcher, ampFetcher, categoriesFetcher, videoFetcher := storedRequestsConf.NewStoredRequests(cfg, r.MetricsEngine, generalHttpClient, r.Router)
 
 	// todo(zachbadgett): better shutdown
 	r.Shutdown = shutdown
@@ -223,10 +232,11 @@ func New(cfg *config.Configuration, rateConvertor *currencies.RateConverter) (r
 	defaultAliases, defReqJSON := readDefaultRequest(cfg.DefReqConfig)
 
 	syncers := usersyncers.NewSyncerMap(cfg)
-	gdprPerms := gdpr.NewPermissions(context.Background(), cfg.GDPR, adapters.GDPRAwareSyncerIDs(syncers), theClient)
+	gdprPerms := gdpr.NewPermissions(context.Background(), cfg.GDPR, adapters.GDPRAwareSyncerIDs(syncers), generalHttpClient)
 
 	exchanges = newExchangeMap(cfg)
-	theExchange := exchange.NewExchange(theClient, pbc.NewClient(&cfg.CacheURL, &cfg.ExtCacheURL, r.MetricsEngine), cfg, r.MetricsEngine, bidderInfos, gdprPerms, rateConvertor)
+	cacheClient := pbc.NewClient(cacheHttpClient, &cfg.CacheURL, &cfg.ExtCacheURL, r.MetricsEngine)
+	theExchange := exchange.NewExchange(generalHttpClient, cacheClient, cfg, r.MetricsEngine, bidderInfos, gdprPerms, rateConvertor)
 
 	openrtbEndpoint, err := openrtb2.NewEndpoint(theExchange, paramsValidator, fetcher, categoriesFetcher, cfg, r.MetricsEngine, pbsAnalytics, disabledBidders, defReqJSON, activeBiddersMap)
 


Test patch:
diff --git a/adapters/audienceNetwork/facebook_test.go b/adapters/audienceNetwork/facebook_test.go
index 2ce0ef3b..1edaabd4 100644
--- a/adapters/audienceNetwork/facebook_test.go
+++ b/adapters/audienceNetwork/facebook_test.go
@@ -4,7 +4,9 @@ import (
 	"testing"
 	"time"
 
+	"github.com/prebid/prebid-server/adapters"
 	"github.com/prebid/prebid-server/adapters/adapterstest"
+	"github.com/stretchr/testify/assert"
 )
 
 type tagInfo struct {
@@ -40,3 +42,38 @@ type FacebookExt struct {
 func TestJsonSamples(t *testing.T) {
 	adapterstest.RunJSONBidderTest(t, "audienceNetworktest", NewFacebookBidder(nil, "test-platform-id", "test-app-secret"))
 }
+
+func TestMakeTimeoutNotice(t *testing.T) {
+	req := adapters.RequestData{
+		Body: []byte(`{"imp":[{"id":"1234"}]}}`),
+	}
+	fba := NewFacebookBidder(nil, "test-platform-id", "test-app-secret")
+
+	tb, ok := fba.(adapters.TimeoutBidder)
+	if !ok {
+		t.Error("Facebook adapter is not a TimeoutAdapter")
+	}
+
+	toReq, err := tb.MakeTimeoutNotification(&req)
+	assert.Nil(t, err, "Facebook MakeTimeoutNotification() return an error %v", err)
+	expectedUri := "https://www.facebook.com/audiencenetwork/nurl/?partner=test-platform-id&app=test-platform-id&auction=1234&ortb_loss_code=2"
+	assert.Equal(t, expectedUri, toReq.Uri, "Facebook timeout notification not returning the expected URI.")
+
+}
+
+func TestMakeTimeoutNoticeBadRequest(t *testing.T) {
+	req := adapters.RequestData{
+		Body: []byte(`{"imp":[{{"id":"1234"}}`),
+	}
+	fba := NewFacebookBidder(nil, "test-platform-id", "test-app-secret")
+
+	tb, ok := fba.(adapters.TimeoutBidder)
+	if !ok {
+		t.Error("Facebook adapter is not a TimeoutAdapter")
+	}
+
+	toReq, err := tb.MakeTimeoutNotification(&req)
+	assert.Empty(t, toReq.Uri, "Facebook MakeTimeoutNotification() did not return nil", err)
+	assert.NotNil(t, err, "Facebook MakeTimeoutNotification() did not return an error")
+
+}
diff --git a/config/config_test.go b/config/config_test.go
index 182a46ee..78630e07 100644
--- a/config/config_test.go
+++ b/config/config_test.go
@@ -68,6 +68,10 @@ http_client:
   max_idle_connections: 500
   max_idle_connections_per_host: 20
   idle_connection_timeout_seconds: 30
+http_client_cache:
+  max_idle_connections: 1
+  max_idle_connections_per_host: 2
+  idle_connection_timeout_seconds: 3
 currency_converter:
   fetch_url: https://currency.prebid.org
   fetch_interval_seconds: 1800
@@ -214,6 +218,9 @@ func TestFullConfig(t *testing.T) {
 	cmpInts(t, "http_client.max_idle_connections", cfg.Client.MaxIdleConns, 500)
 	cmpInts(t, "http_client.max_idle_connections_per_host", cfg.Client.MaxIdleConnsPerHost, 20)
 	cmpInts(t, "http_client.idle_connection_timeout_seconds", cfg.Client.IdleConnTimeout, 30)
+	cmpInts(t, "http_client_cache.max_idle_connections", cfg.CacheClient.MaxIdleConns, 1)
+	cmpInts(t, "http_client_cache.max_idle_connections_per_host", cfg.CacheClient.MaxIdleConnsPerHost, 2)
+	cmpInts(t, "http_client_cache.idle_connection_timeout_seconds", cfg.CacheClient.IdleConnTimeout, 3)
 	cmpInts(t, "gdpr.host_vendor_id", cfg.GDPR.HostVendorID, 15)
 	cmpBools(t, "gdpr.usersync_if_ambiguous", cfg.GDPR.UsersyncIfAmbiguous, true)
 
diff --git a/exchange/exchange_test.go b/exchange/exchange_test.go
index 31dddae4..7e199d4b 100644
--- a/exchange/exchange_test.go
+++ b/exchange/exchange_test.go
@@ -9,6 +9,7 @@ import (
 	"io/ioutil"
 	"net/http"
 	"net/http/httptest"
+	"regexp"
 	"strconv"
 	"strings"
 	"testing"
@@ -170,7 +171,7 @@ func TestGetBidCacheInfo(t *testing.T) {
 	server := httptest.NewServer(http.HandlerFunc(handlerNoBidServer))
 	defer server.Close()
 
-	e := NewExchange(server.Client(), pbc.NewClient(&cfg.CacheURL, &cfg.ExtCacheURL, testEngine), cfg, pbsmetrics.NewMetrics(metrics.NewRegistry(), openrtb_ext.BidderList(), config.DisabledMetrics{}), adapters.ParseBidderInfos(cfg.Adapters, "../static/bidder-info", openrtb_ext.BidderList()), gdpr.AlwaysAllow{}, currencies.NewRateConverterDefault()).(*exchange)
+	e := NewExchange(server.Client(), pbc.NewClient(&http.Client{}, &cfg.CacheURL, &cfg.ExtCacheURL, testEngine), cfg, pbsmetrics.NewMetrics(metrics.NewRegistry(), openrtb_ext.BidderList(), config.DisabledMetrics{}), adapters.ParseBidderInfos(cfg.Adapters, "../static/bidder-info", openrtb_ext.BidderList()), gdpr.AlwaysAllow{}, currencies.NewRateConverterDefault()).(*exchange)
 
 	/* 	3) Build all the parameters e.buildBidResponse(ctx.Background(), liveA... ) needs */
 	liveAdapters := []openrtb_ext.BidderName{bidderName}
@@ -930,9 +931,11 @@ func TestCategoryMapping(t *testing.T) {
 
 	adapterBids[bidderName1] = &seatBid
 
-	bidCategory, adapterBids, err := applyCategoryMapping(nil, requestExt, adapterBids, categoriesFetcher, targData)
+	bidCategory, adapterBids, rejections, err := applyCategoryMapping(nil, requestExt, adapterBids, categoriesFetcher, targData)
 
 	assert.Equal(t, nil, err, "Category mapping error should be empty")
+	assert.Equal(t, 1, len(rejections), "There should be 1 bid rejection message")
+	assert.Equal(t, "bid rejected [bid ID: bid_id4] reason: Category mapping file for primary ad server: 'freewheel', publisher: '' not found", rejections[0], "Rejection message did not match expected")
 	assert.Equal(t, "10.00_Electronics_30s", bidCategory["bid_id1"], "Category mapping doesn't match")
 	assert.Equal(t, "20.00_Sports_50s", bidCategory["bid_id2"], "Category mapping doesn't match")
 	assert.Equal(t, "20.00_AdapterOverride_30s", bidCategory["bid_id3"], "Category mapping override from adapter didn't take")
@@ -983,9 +986,10 @@ func TestCategoryMappingNoIncludeBrandCategory(t *testing.T) {
 
 	adapterBids[bidderName1] = &seatBid
 
-	bidCategory, adapterBids, err := applyCategoryMapping(nil, requestExt, adapterBids, categoriesFetcher, targData)
+	bidCategory, adapterBids, rejections, err := applyCategoryMapping(nil, requestExt, adapterBids, categoriesFetcher, targData)
 
 	assert.Equal(t, nil, err, "Category mapping error should be empty")
+	assert.Empty(t, rejections, "There should be no bid rejection messages")
 	assert.Equal(t, "10.00_30s", bidCategory["bid_id1"], "Category mapping doesn't match")
 	assert.Equal(t, "20.00_40s", bidCategory["bid_id2"], "Category mapping doesn't match")
 	assert.Equal(t, "20.00_30s", bidCategory["bid_id3"], "Category mapping doesn't match")
@@ -1034,9 +1038,11 @@ func TestCategoryMappingTranslateCategoriesNil(t *testing.T) {
 
 	adapterBids[bidderName1] = &seatBid
 
-	bidCategory, adapterBids, err := applyCategoryMapping(nil, requestExt, adapterBids, categoriesFetcher, targData)
+	bidCategory, adapterBids, rejections, err := applyCategoryMapping(nil, requestExt, adapterBids, categoriesFetcher, targData)
 
 	assert.Equal(t, nil, err, "Category mapping error should be empty")
+	assert.Equal(t, 1, len(rejections), "There should be 1 bid rejection message")
+	assert.Equal(t, "bid rejected [bid ID: bid_id3] reason: Category mapping file for primary ad server: 'freewheel', publisher: '' not found", rejections[0], "Rejection message did not match expected")
 	assert.Equal(t, "10.00_Electronics_30s", bidCategory["bid_id1"], "Category mapping doesn't match")
 	assert.Equal(t, "20.00_Sports_50s", bidCategory["bid_id2"], "Category mapping doesn't match")
 	assert.Equal(t, 2, len(adapterBids[bidderName1].bids), "Bidders number doesn't match")
@@ -1114,9 +1120,10 @@ func TestCategoryMappingTranslateCategoriesFalse(t *testing.T) {
 
 	adapterBids[bidderName1] = &seatBid
 
-	bidCategory, adapterBids, err := applyCategoryMapping(nil, requestExt, adapterBids, categoriesFetcher, targData)
+	bidCategory, adapterBids, rejections, err := applyCategoryMapping(nil, requestExt, adapterBids, categoriesFetcher, targData)
 
 	assert.Equal(t, nil, err, "Category mapping error should be empty")
+	assert.Empty(t, rejections, "There should be no bid rejection messages")
 	assert.Equal(t, "10.00_IAB1-3_30s", bidCategory["bid_id1"], "Category should not be translated")
 	assert.Equal(t, "20.00_IAB1-4_50s", bidCategory["bid_id2"], "Category should not be translated")
 	assert.Equal(t, "20.00_IAB1-1000_30s", bidCategory["bid_id3"], "Bid should not be rejected")
@@ -1179,9 +1186,12 @@ func TestCategoryDedupe(t *testing.T) {
 
 		adapterBids[bidderName1] = &seatBid
 
-		bidCategory, adapterBids, err := applyCategoryMapping(nil, requestExt, adapterBids, categoriesFetcher, targData)
+		bidCategory, adapterBids, rejections, err := applyCategoryMapping(nil, requestExt, adapterBids, categoriesFetcher, targData)
 
 		assert.Equal(t, nil, err, "Category mapping error should be empty")
+		assert.Equal(t, 2, len(rejections), "There should be 2 bid rejection messages")
+		assert.Regexpf(t, regexp.MustCompile(`bid rejected \[bid ID: bid_id(1|3)\] reason: Bid was deduplicated`), rejections[0], "Rejection message did not match expected")
+		assert.Equal(t, "bid rejected [bid ID: bid_id4] reason: Category mapping file for primary ad server: 'freewheel', publisher: '' not found", rejections[1], "Rejection message did not match expected")
 		assert.Equal(t, 2, len(adapterBids[bidderName1].bids), "Bidders number doesn't match")
 		assert.Equal(t, 2, len(bidCategory), "Bidders category mapping doesn't match")
 
@@ -1196,6 +1206,125 @@ func TestCategoryDedupe(t *testing.T) {
 	assert.NotEqual(t, numIterations, selectedBids["bid_id3"], "Bid 3 made it through every time")
 }
 
+func TestBidRejectionErrors(t *testing.T) {
+	categoriesFetcher, error := newCategoryFetcher("./test/category-mapping")
+	if error != nil {
+		t.Errorf("Failed to create a category Fetcher: %v", error)
+	}
+
+	requestExt := newExtRequest()
+	requestExt.Prebid.Targeting.DurationRangeSec = []int{15, 30, 50}
+
+	targData := &targetData{
+		priceGranularity: requestExt.Prebid.Targeting.PriceGranularity,
+		includeWinners:   true,
+	}
+
+	invalidReqExt := newExtRequest()
+	invalidReqExt.Prebid.Targeting.DurationRangeSec = []int{15, 30, 50}
+	invalidReqExt.Prebid.Targeting.IncludeBrandCategory.PrimaryAdServer = 2
+	invalidReqExt.Prebid.Targeting.IncludeBrandCategory.Publisher = "some_publisher"
+
+	adapterBids := make(map[openrtb_ext.BidderName]*pbsOrtbSeatBid)
+	bidderName := openrtb_ext.BidderName("appnexus")
+
+	testCases := []struct {
+		description        string
+		reqExt             openrtb_ext.ExtRequest
+		bids               []*openrtb.Bid
+		duration           int
+		expectedRejections []string
+		expectedCatDur     string
+	}{
+		{
+			description: "Bid should be rejected due to not containing a category",
+			reqExt:      requestExt,
+			bids: []*openrtb.Bid{
+				{ID: "bid_id1", ImpID: "imp_id1", Price: 10.0000, Cat: []string{}, W: 1, H: 1},
+			},
+			duration: 30,
+			expectedRejections: []string{
+				"bid rejected [bid ID: bid_id1] reason: Bid did not contain a category",
+			},
+		},
+		{
+			description: "Bid should be rejected due to missing category mapping file",
+			reqExt:      invalidReqExt,
+			bids: []*openrtb.Bid{
+				{ID: "bid_id1", ImpID: "imp_id1", Price: 10.0000, Cat: []string{"IAB1-1"}, W: 1, H: 1},
+			},
+			duration: 30,
+			expectedRejections: []string{
+				"bid rejected [bid ID: bid_id1] reason: Category mapping file for primary ad server: 'dfp', publisher: 'some_publisher' not found",
+			},
+		},
+		{
+			description: "Bid should be rejected due to duration exceeding maximum",
+			reqExt:      requestExt,
+			bids: []*openrtb.Bid{
+				{ID: "bid_id1", ImpID: "imp_id1", Price: 10.0000, Cat: []string{"IAB1-1"}, W: 1, H: 1},
+			},
+			duration: 70,
+			expectedRejections: []string{
+				"bid rejected [bid ID: bid_id1] reason: Bid duration exceeds maximum allowed",
+			},
+		},
+		{
+			description: "Bid should be rejected due to duplicate bid",
+			reqExt:      requestExt,
+			bids: []*openrtb.Bid{
+				{ID: "bid_id1", ImpID: "imp_id1", Price: 10.0000, Cat: []string{"IAB1-1"}, W: 1, H: 1},
+				{ID: "bid_id1", ImpID: "imp_id1", Price: 10.0000, Cat: []string{"IAB1-1"}, W: 1, H: 1},
+			},
+			duration: 30,
+			expectedRejections: []string{
+				"bid rejected [bid ID: bid_id1] reason: Bid was deduplicated",
+			},
+			expectedCatDur: "10.00_VideoGames_30s",
+		},
+	}
+
+	for _, test := range testCases {
+		innerBids := []*pbsOrtbBid{}
+		for _, bid := range test.bids {
+			currentBid := pbsOrtbBid{
+				bid, "video", nil, &openrtb_ext.ExtBidPrebidVideo{Duration: test.duration},
+			}
+			innerBids = append(innerBids, &currentBid)
+		}
+
+		seatBid := pbsOrtbSeatBid{innerBids, "USD", nil, nil}
+
+		adapterBids[bidderName] = &seatBid
+
+		bidCategory, adapterBids, rejections, err := applyCategoryMapping(nil, test.reqExt, adapterBids, categoriesFetcher, targData)
+
+		if len(test.expectedCatDur) > 0 {
+			// Bid deduplication case
+			assert.Equal(t, 1, len(adapterBids[bidderName].bids), "Bidders number doesn't match")
+			assert.Equal(t, 1, len(bidCategory), "Bidders category mapping doesn't match")
+			assert.Equal(t, test.expectedCatDur, bidCategory["bid_id1"], "Bid category did not contain expected hb_pb_cat_dur")
+		} else {
+			assert.Empty(t, adapterBids[bidderName].bids, "Bidders number doesn't match")
+			assert.Empty(t, bidCategory, "Bidders category mapping doesn't match")
+		}
+
+		assert.Empty(t, err, "Category mapping error should be empty")
+		assert.Equal(t, test.expectedRejections, rejections, test.description)
+	}
+}
+
+func TestUpdateRejections(t *testing.T) {
+	rejections := []string{}
+
+	rejections = updateRejections(rejections, "bid_id1", "some reason 1")
+	rejections = updateRejections(rejections, "bid_id2", "some reason 2")
+
+	assert.Equal(t, 2, len(rejections), "Rejections should contain 2 rejection messages")
+	assert.Containsf(t, rejections, "bid rejected [bid ID: bid_id1] reason: some reason 1", "Rejection message did not match expected")
+	assert.Containsf(t, rejections, "bid rejected [bid ID: bid_id2] reason: some reason 2", "Rejection message did not match expected")
+}
+
 type exchangeSpec struct {
 	IncomingRequest  exchangeRequest        `json:"incomingRequest"`
 	OutgoingRequests map[string]*bidderSpec `json:"outgoingRequests"`
diff --git a/prebid_cache_client/client_test.go b/prebid_cache_client/client_test.go
index 1b5b4e38..5840d4ea 100644
--- a/prebid_cache_client/client_test.go
+++ b/prebid_cache_client/client_test.go
@@ -233,11 +233,9 @@ func TestStripCacheHostAndPath(t *testing.T) {
 		},
 	}
 	for _, test := range testInput {
-		//start client
-		cacheClient := NewClient(&inCacheURL, &test.inExtCacheURL, &metricsConf.DummyMetricsEngine{})
+		cacheClient := NewClient(&http.Client{}, &inCacheURL, &test.inExtCacheURL, &metricsConf.DummyMetricsEngine{})
 		cHost, cPath := cacheClient.GetExtCacheData()
 
-		//assert
 		assert.Equal(t, test.expectedHost, cHost)
 		assert.Equal(t, test.expectedPath, cPath)
 	}