diff --git a/src/main/java/org/prebid/server/auction/ExchangeService.java b/src/main/java/org/prebid/server/auction/ExchangeService.java index 428510be069..c7258a8faa7 100644 --- a/src/main/java/org/prebid/server/auction/ExchangeService.java +++ b/src/main/java/org/prebid/server/auction/ExchangeService.java @@ -98,6 +98,7 @@ import org.prebid.server.util.BidderUtil; import org.prebid.server.util.HttpUtil; import org.prebid.server.util.ListUtil; +import org.prebid.server.util.MapUtil; import org.prebid.server.util.PbsUtil; import org.prebid.server.util.StreamUtil; @@ -114,6 +115,9 @@ import java.util.Objects; import java.util.Optional; import java.util.Set; +import java.util.TreeMap; +import java.util.function.Function; +import java.util.function.Predicate; import java.util.stream.Collectors; public class ExchangeService { @@ -257,17 +261,7 @@ private Future runAuction(AuctionContext receivedContext) { .map(receivedContext::with)) .map(context -> updateRequestMetric(context, uidsCookie, aliases, account, requestTypeMetric)) - .compose(context -> Future.join( - context.getAuctionParticipations().stream() - .map(auctionParticipation -> processAndRequestBids( - context, - auctionParticipation.getBidderRequest(), - timeout, - aliases) - .map(auctionParticipation::with)) - .toList()) - // send all the requests to the bidders and gathers results - .map(CompositeFuture::list) + .compose(context -> processAndRequestBids(context, timeout, aliases) .map(storedResponseProcessor::updateStoredBidResponse) .map(auctionParticipations -> storedResponseProcessor.mergeWithBidderResponses( auctionParticipations, @@ -457,13 +451,10 @@ private void makeBidRejectionTrackers(Map bidReject bidderToImpIds.computeIfAbsent(bidder, bidderName -> new HashSet<>()).add(impId)); } - bidderToImpIds.forEach((bidder, impIds) -> { - if (bidRejectionTrackers.containsKey(bidder)) { - bidRejectionTrackers.put(bidder, new BidRejectionTracker(bidRejectionTrackers.get(bidder), impIds)); - } else { - bidRejectionTrackers.put(bidder, new BidRejectionTracker(bidder, impIds, logSamplingRate)); - } - }); + bidderToImpIds.forEach((bidder, impIds) -> bidRejectionTrackers.put(bidder, + bidRejectionTrackers.containsKey(bidder) + ? BidRejectionTracker.withAdditionalImpIds(bidRejectionTrackers.get(bidder), impIds) + : new BidRejectionTracker(bidder, impIds, logSamplingRate))); } private static StoredResponseResult populateStoredResponse(StoredResponseResult storedResponseResult, @@ -1132,10 +1123,50 @@ private AuctionContext updateRequestMetric(AuctionContext context, return context; } - private Future processAndRequestBids(AuctionContext auctionContext, - BidderRequest bidderRequest, - Timeout timeout, - BidderAliases aliases) { + private Future> processAndRequestBids(AuctionContext auctionContext, + Timeout timeout, + BidderAliases aliases) { + + // when we ignore Futures from uncompleted secondary bidders, + // they continue running in the background and can write errors to BidRejectionTrackers. + // To prevent any issues, we provide them with a copy of BidRejectionTracker + // and only merge this copy back if secondary bidder has completed in time + final Map copiedBidRejectionTrackers = + new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + for (Map.Entry entry: auctionContext.getBidRejectionTrackers().entrySet()) { + copiedBidRejectionTrackers.put(entry.getKey(), BidRejectionTracker.copyOf(entry.getValue())); + } + + final Map bidderToAuctionParticipation = auctionContext + .getAuctionParticipations().stream().collect(Collectors.toMap( + AuctionParticipation::getBidder, + Function.identity())); + + final Map> bidderToFutureResponse = MapUtil.mapValues( + bidderToAuctionParticipation, + auctionParticipation -> processAndRequestBidsForSingleBidder( + auctionContext.with(copiedBidRejectionTrackers), + auctionParticipation.getBidderRequest(), + timeout, + aliases)); + + return buildPrimaryBiddersCompositeFuture(auctionContext, bidderToFutureResponse).transform(ignored -> { + mergeBidRejectionTrackers(auctionContext, copiedBidRejectionTrackers, bidderToFutureResponse); + + return Future.succeededFuture(bidderToFutureResponse.entrySet().stream() + .filter(entry -> !entry.getValue().failed()) + .map(MapUtil.mapEntryValueMapper((bidder, futureResponse) -> ObjectUtils.defaultIfNull( + futureResponse.result(), responseForUncompletedSecondaryBidder(bidder)))) + .map(MapUtil.mapEntryMapper((bidder, bidderResponse) -> + bidderToAuctionParticipation.get(bidder).with(bidderResponse))) + .toList()); + }); + } + + private Future processAndRequestBidsForSingleBidder(AuctionContext auctionContext, + BidderRequest bidderRequest, + Timeout timeout, + BidderAliases aliases) { return bidderRequestPostProcessor.process(bidderRequest, aliases, auctionContext) .compose(result -> invokeHooksAndRequestBids(auctionContext, result.getValue(), timeout, aliases) @@ -1144,32 +1175,6 @@ private Future processAndRequestBids(AuctionContext auctionConte auctionContext, bidderRequest.getBidder(), throwable)); } - private static BidderSeatBid addWarnings(BidderSeatBid seatBid, List warnings) { - return CollectionUtils.isNotEmpty(warnings) - ? seatBid.toBuilder() - .warnings(ListUtil.union(warnings, seatBid.getWarnings())) - .build() - : seatBid; - } - - private static Future recoverBidderRequestRejection(AuctionContext auctionContext, - String bidderName, - Throwable throwable) { - - if (throwable instanceof BidderRequestRejectedException rejection) { - auctionContext.getBidRejectionTrackers() - .get(bidderName) - .rejectAll(rejection.getRejectionReason()); - final BidderSeatBid bidderSeatBid = BidderSeatBid.builder() - .warnings(rejection.getErrors()) - .build(); - - return Future.succeededFuture(BidderResponse.of(bidderName, bidderSeatBid, 0)); - } - - return Future.failedFuture(throwable); - } - private Future invokeHooksAndRequestBids(AuctionContext auctionContext, BidderRequest bidderRequest, Timeout timeout, @@ -1244,6 +1249,27 @@ private Future requestBids(BidderRequest bidderRequest, .map(seatBid -> BidderResponse.of(bidderName, seatBid, responseTime(bidderRequestStartTime))); } + private BidRequest adjustTmax(BidRequest bidRequest, + long startTime, + int adjustmentFactor, + long currentTime, + long bidderTmaxDeductionMs) { + + final long tmax = timeoutResolver.limitToMax(bidRequest.getTmax()); + final long adjustedTmax = timeoutResolver.adjustForBidder( + tmax, adjustmentFactor, currentTime - startTime, bidderTmaxDeductionMs); + + return tmax != adjustedTmax + ? bidRequest.toBuilder().tmax(adjustedTmax).build() + : bidRequest; + } + + private Timeout adjustTimeout(Timeout timeout, long startTime, long currentTime) { + final long adjustedTmax = timeoutResolver.adjustForRequest( + timeout.getDeadline() - startTime, currentTime - startTime); + return timeoutFactory.create(currentTime, adjustedTmax); + } + private BidderSeatBid populateBidderCode(BidderSeatBid seatBid, String bidderName, String resolvedBidderName) { return seatBid.with(seatBid.getBids().stream() .map(bidderBid -> bidderBid.toBuilder() @@ -1277,27 +1303,6 @@ private ObjectNode objectNodeFromOrNew(ObjectNode parent, String key) { : (ObjectNode) childNode; } - private BidRequest adjustTmax(BidRequest bidRequest, - long startTime, - int adjustmentFactor, - long currentTime, - long bidderTmaxDeductionMs) { - - final long tmax = timeoutResolver.limitToMax(bidRequest.getTmax()); - final long adjustedTmax = timeoutResolver.adjustForBidder( - tmax, adjustmentFactor, currentTime - startTime, bidderTmaxDeductionMs); - - return tmax != adjustedTmax - ? bidRequest.toBuilder().tmax(adjustedTmax).build() - : bidRequest; - } - - private Timeout adjustTimeout(Timeout timeout, long startTime, long currentTime) { - final long adjustedTmax = timeoutResolver.adjustForRequest( - timeout.getDeadline() - startTime, currentTime - startTime); - return timeoutFactory.create(currentTime, adjustedTmax); - } - private BidderResponse rejectBidderResponseOrProceed(HookStageExecutionResult stageResult, BidderResponse bidderResponse) { @@ -1308,6 +1313,101 @@ private BidderResponse rejectBidderResponseOrProceed(HookStageExecutionResult warnings) { + return CollectionUtils.isNotEmpty(warnings) + ? seatBid.toBuilder() + .warnings(ListUtil.union(warnings, seatBid.getWarnings())) + .build() + : seatBid; + } + + private static Future recoverBidderRequestRejection(AuctionContext auctionContext, + String bidderName, + Throwable throwable) { + + if (throwable instanceof BidderRequestRejectedException rejection) { + auctionContext.getBidRejectionTrackers() + .get(bidderName) + .rejectAll(rejection.getRejectionReason()); + final BidderSeatBid bidderSeatBid = BidderSeatBid.builder() + .warnings(rejection.getErrors()) + .build(); + + return Future.succeededFuture(BidderResponse.of(bidderName, bidderSeatBid, 0)); + } + + logger.error("Encountered unexpected error while processing bids for bidder {}", throwable, bidderName); + return Future.failedFuture(throwable); + } + + private CompositeFuture buildPrimaryBiddersCompositeFuture( + AuctionContext auctionContext, + Map> bidderToFutureResponse) { + + final Set secondaryBidders = resolveSecondaryBidders(auctionContext); + + final List> primaryBiddersFutureResponses = bidderToFutureResponse.keySet().stream() + .filter(Predicate.not(secondaryBidders::contains)) + .map(bidderToFutureResponse::get) + .toList(); + + return Future.join(CollectionUtils.isNotEmpty(primaryBiddersFutureResponses) + ? primaryBiddersFutureResponses + : bidderToFutureResponse.values().stream().toList()); + } + + private static Set resolveSecondaryBidders(AuctionContext auctionContext) { + return Optional.ofNullable(auctionContext.getBidRequest()) + .map(BidRequest::getExt) + .map(ExtRequest::getPrebid) + .map(ExtRequestPrebid::getSecondaryBidders) + .orElseGet(() -> getAccountSecondaryBidders(auctionContext)); + } + + private static Set getAccountSecondaryBidders(AuctionContext auctionContext) { + return Optional.of(auctionContext) + .map(AuctionContext::getAccount) + .map(Account::getAuction) + .map(AccountAuctionConfig::getSecondaryBidders) + .orElse(Collections.emptySet()); + } + + private void mergeBidRejectionTrackers(AuctionContext auctionContext, + Map newBidRejectionTrackers, + Map> bidderToFutureResponse) { + + final Map mergedBidRejectionTrackers = newBidRejectionTrackers.keySet().stream() + .collect(Collectors.toMap(Function.identity(), bidder -> mergeBidRejectionTrackersForSingleBidder( + bidderToFutureResponse.get(bidder), + auctionContext.getBidRejectionTrackers().get(bidder), + newBidRejectionTrackers.get(bidder)))); + + auctionContext.getBidRejectionTrackers().clear(); + auctionContext.getBidRejectionTrackers().putAll(mergedBidRejectionTrackers); + } + + private BidRejectionTracker mergeBidRejectionTrackersForSingleBidder(Future futureResponse, + BidRejectionTracker oldBidRejectionTracker, + BidRejectionTracker newBidRejectionTracker) { + + if (futureResponse == null) { + return oldBidRejectionTracker; + } + + return futureResponse.isComplete() + ? newBidRejectionTracker + : oldBidRejectionTracker.rejectAll(BidRejectionReason.ERROR_TIMED_OUT); + } + + private BidderResponse responseForUncompletedSecondaryBidder(String bidderName) { + final BidderSeatBid bidderSeatBid = BidderSeatBid.builder() + .warnings(Collections.singletonList( + BidderError.of("secondary bidder timed out, auction proceeded", BidderError.Type.timeout))) + .build(); + + return BidderResponse.of(bidderName, bidderSeatBid, 0); + } + private List dropZeroNonDealBids(List auctionParticipations, List debugWarnings, boolean isDebugEnabled) { diff --git a/src/main/java/org/prebid/server/auction/externalortb/StoredResponseProcessor.java b/src/main/java/org/prebid/server/auction/externalortb/StoredResponseProcessor.java index b44939a38e6..09f4a39b587 100644 --- a/src/main/java/org/prebid/server/auction/externalortb/StoredResponseProcessor.java +++ b/src/main/java/org/prebid/server/auction/externalortb/StoredResponseProcessor.java @@ -224,8 +224,8 @@ private List resolveSeatBids(StoredResponse storedResponse, Map idToStoredResponses, String impId) { - if (storedResponse instanceof StoredResponse.StoredResponseObject storedResponseObject) { - return Collections.singletonList(storedResponseObject.seatBid()); + if (storedResponse instanceof StoredResponse.StoredResponseObject(SeatBid seatBid)) { + return Collections.singletonList(seatBid); } final String storedResponseId = ((StoredResponse.StoredResponseId) storedResponse).id(); @@ -313,7 +313,7 @@ private static AuctionParticipation updateStoredBidResponse(AuctionParticipation final BidRequest bidRequest = bidderRequest.getBidRequest(); final List imps = bidRequest.getImp(); - // Аor now, Stored Bid Response works only for bid requests with single imp + // For now, Stored Bid Response works only for bid requests with single imp if (imps.size() > 1 || StringUtils.isEmpty(bidderRequest.getStoredResponse())) { return auctionParticipation; } diff --git a/src/main/java/org/prebid/server/auction/model/AuctionContext.java b/src/main/java/org/prebid/server/auction/model/AuctionContext.java index 3ee60aab4fa..f4c1fffc444 100644 --- a/src/main/java/org/prebid/server/auction/model/AuctionContext.java +++ b/src/main/java/org/prebid/server/auction/model/AuctionContext.java @@ -83,6 +83,10 @@ public AuctionContext with(BidResponse bidResponse) { return this.toBuilder().bidResponse(bidResponse).build(); } + public AuctionContext with(Map bidRejectionTrackers) { + return this.toBuilder().bidRejectionTrackers(bidRejectionTrackers).build(); + } + public AuctionContext with(List auctionParticipations) { return this.toBuilder().auctionParticipations(auctionParticipations).build(); } diff --git a/src/main/java/org/prebid/server/auction/model/BidRejectionTracker.java b/src/main/java/org/prebid/server/auction/model/BidRejectionTracker.java index c9fd9adf00b..c0a1d1c846d 100644 --- a/src/main/java/org/prebid/server/auction/model/BidRejectionTracker.java +++ b/src/main/java/org/prebid/server/auction/model/BidRejectionTracker.java @@ -45,24 +45,48 @@ public BidRejectionTracker(String bidder, Set involvedImpIds, double log rejections = new HashMap<>(); } - public BidRejectionTracker(BidRejectionTracker anotherTracker, Set additionalImpIds) { - this.bidder = anotherTracker.bidder; - this.logSamplingRate = anotherTracker.logSamplingRate; - this.involvedImpIds = new HashSet<>(anotherTracker.involvedImpIds); - this.involvedImpIds.addAll(additionalImpIds); - - this.succeededBidsIds = new HashMap<>(anotherTracker.succeededBidsIds); - this.rejections = new HashMap<>(anotherTracker.rejections); + private BidRejectionTracker( + String bidder, + Set involvedImpIds, + Map> succeededBidsIds, + Map> rejections, + double logSamplingRate) { + + this.bidder = bidder; + this.involvedImpIds = new HashSet<>(involvedImpIds); + this.logSamplingRate = logSamplingRate; + + this.succeededBidsIds = MapUtil.mapValues(succeededBidsIds, v -> new HashSet<>(v)); + this.rejections = MapUtil.mapValues(rejections, ArrayList::new); } - public void succeed(Collection bids) { + public static BidRejectionTracker copyOf(BidRejectionTracker anotherTracker) { + return new BidRejectionTracker( + anotherTracker.bidder, + anotherTracker.involvedImpIds, + anotherTracker.succeededBidsIds, + anotherTracker.rejections, + anotherTracker.logSamplingRate); + } + + public static BidRejectionTracker withAdditionalImpIds( + BidRejectionTracker anotherTracker, + Set additionalImpIds) { + + final BidRejectionTracker newTracker = copyOf(anotherTracker); + newTracker.involvedImpIds.addAll(additionalImpIds); + return newTracker; + } + + public BidRejectionTracker succeed(Collection bids) { bids.stream() .map(BidderBid::getBid) .filter(Objects::nonNull) .forEach(this::succeed); + return this; } - private void succeed(Bid bid) { + private BidRejectionTracker succeed(Bid bid) { final String bidId = bid.getId(); final String impId = bid.getImpid(); if (involvedImpIds.contains(impId)) { @@ -73,21 +97,23 @@ private void succeed(Bid bid) { logSamplingRate); } } + return this; } public void restoreFromRejection(Collection bids) { succeed(bids); } - public void reject(Collection rejections) { + public BidRejectionTracker reject(Collection rejections) { rejections.forEach(this::reject); + return this; } - public void reject(Rejection rejection) { + public BidRejectionTracker reject(Rejection rejection) { if (rejection instanceof ImpRejection && rejection.reason().getValue() >= 300) { logger.warn("The rejected imp {} with the code {} equal to or higher than 300 assumes " + "that there is a rejected bid that shouldn't be lost"); - return; + return this; } final String impId = rejection.impId(); @@ -113,14 +139,18 @@ public void reject(Rejection rejection) { } } } + + return this; } - public void rejectImps(Collection impIds, BidRejectionReason reason) { + public BidRejectionTracker rejectImps(Collection impIds, BidRejectionReason reason) { impIds.forEach(impId -> reject(ImpRejection.of(impId, reason))); + return this; } - public void rejectAll(BidRejectionReason reason) { + public BidRejectionTracker rejectAll(BidRejectionReason reason) { involvedImpIds.forEach(impId -> reject(ImpRejection.of(impId, reason))); + return this; } public Set getRejected() { diff --git a/src/main/java/org/prebid/server/proto/openrtb/ext/request/ExtRequestPrebid.java b/src/main/java/org/prebid/server/proto/openrtb/ext/request/ExtRequestPrebid.java index 1380e543991..2c30e3a516d 100644 --- a/src/main/java/org/prebid/server/proto/openrtb/ext/request/ExtRequestPrebid.java +++ b/src/main/java/org/prebid/server/proto/openrtb/ext/request/ExtRequestPrebid.java @@ -12,6 +12,7 @@ import java.util.List; import java.util.Map; +import java.util.Set; /** * Defines the contract for bidrequest.ext.prebid @@ -200,4 +201,7 @@ public class ExtRequestPrebid { ExtRequestPrebidAlternateBidderCodes alternateBidderCodes; ObjectNode kvps; + + @JsonProperty("secondarybidders") + Set secondaryBidders; } diff --git a/src/main/java/org/prebid/server/settings/model/AccountAuctionConfig.java b/src/main/java/org/prebid/server/settings/model/AccountAuctionConfig.java index c78bd14770c..4ccea419082 100644 --- a/src/main/java/org/prebid/server/settings/model/AccountAuctionConfig.java +++ b/src/main/java/org/prebid/server/settings/model/AccountAuctionConfig.java @@ -9,6 +9,7 @@ import org.prebid.server.spring.config.bidder.model.MediaType; import java.util.Map; +import java.util.Set; @Builder(toBuilder = true) @Value @@ -65,4 +66,7 @@ public class AccountAuctionConfig { Integer impressionLimit; AccountProfilesConfig profiles; + + @JsonAlias("secondary-bidders") + Set secondaryBidders; } diff --git a/src/main/java/org/prebid/server/util/MapUtil.java b/src/main/java/org/prebid/server/util/MapUtil.java index 282d2b40795..fdc4e9ffbf6 100644 --- a/src/main/java/org/prebid/server/util/MapUtil.java +++ b/src/main/java/org/prebid/server/util/MapUtil.java @@ -3,6 +3,9 @@ import java.util.Collections; import java.util.HashMap; import java.util.Map; +import java.util.function.BiFunction; +import java.util.function.Function; +import java.util.stream.Collectors; public class MapUtil { @@ -15,4 +18,23 @@ public static Map merge(Map left, Map right) { return Collections.unmodifiableMap(merged); } + + public static Map mapValues(Map map, Function transform) { + return mapValues(map, (ignored, value) -> transform.apply(value)); + } + + public static Map mapValues(Map map, BiFunction transform) { + return map.entrySet().stream().collect( + Collectors.toMap(Map.Entry::getKey, entry -> transform.apply(entry.getKey(), entry.getValue()))); + } + + public static Function, Map.Entry> mapEntryValueMapper( + BiFunction transform) { + + return mapEntryMapper((key, value) -> Map.entry(key, transform.apply(key, value))); + } + + public static Function, T> mapEntryMapper(BiFunction transform) { + return entry -> transform.apply(entry.getKey(), entry.getValue()); + } } diff --git a/src/test/groovy/org/prebid/server/functional/model/config/AccountAuctionConfig.groovy b/src/test/groovy/org/prebid/server/functional/model/config/AccountAuctionConfig.groovy index 2dc5ff7c77b..a270bfc4545 100644 --- a/src/test/groovy/org/prebid/server/functional/model/config/AccountAuctionConfig.groovy +++ b/src/test/groovy/org/prebid/server/functional/model/config/AccountAuctionConfig.groovy @@ -37,6 +37,7 @@ class AccountAuctionConfig { BidAdjustment bidAdjustments BidRounding bidRounding Integer impressionLimit + List secondaryBidders @JsonProperty("price_granularity") PriceGranularityType priceGranularitySnakeCase @@ -58,5 +59,7 @@ class AccountAuctionConfig { BidRounding bidRoundingSnakeCase @JsonProperty("impression_limit") Integer impressionLimitSnakeCase + @JsonProperty("secondary_bidders") + List secondaryBiddersSnakeCase } diff --git a/src/test/groovy/org/prebid/server/functional/model/request/auction/BidRequest.groovy b/src/test/groovy/org/prebid/server/functional/model/request/auction/BidRequest.groovy index 26e9ddc2057..ccbe970252f 100644 --- a/src/test/groovy/org/prebid/server/functional/model/request/auction/BidRequest.groovy +++ b/src/test/groovy/org/prebid/server/functional/model/request/auction/BidRequest.groovy @@ -170,4 +170,16 @@ class BidRequest { ext.prebid.events = new Events() } } + + void enabledReturnAllBidStatus() { + if (!ext) { + ext = new BidRequestExt() + } + if (!ext.prebid) { + ext.prebid = new Prebid() + } + if (!ext.prebid.returnAllBidStatus) { + ext.prebid.returnAllBidStatus = true + } + } } diff --git a/src/test/groovy/org/prebid/server/functional/model/request/auction/Prebid.groovy b/src/test/groovy/org/prebid/server/functional/model/request/auction/Prebid.groovy index 23b4e7f87a5..b3d414ad3fb 100644 --- a/src/test/groovy/org/prebid/server/functional/model/request/auction/Prebid.groovy +++ b/src/test/groovy/org/prebid/server/functional/model/request/auction/Prebid.groovy @@ -49,6 +49,7 @@ class Prebid { List profileNames @JsonProperty("kvps") Map keyValuePairs + List secondaryBidders static class Channel { diff --git a/src/test/groovy/org/prebid/server/functional/testcontainers/scaffolding/Bidder.groovy b/src/test/groovy/org/prebid/server/functional/testcontainers/scaffolding/Bidder.groovy index 25e0354c5d5..bb7c8bc0d47 100644 --- a/src/test/groovy/org/prebid/server/functional/testcontainers/scaffolding/Bidder.groovy +++ b/src/test/groovy/org/prebid/server/functional/testcontainers/scaffolding/Bidder.groovy @@ -11,6 +11,8 @@ import org.prebid.server.functional.model.request.auction.Imp import org.prebid.server.functional.model.response.auction.BidResponse import org.testcontainers.containers.MockServerContainer +import static java.util.concurrent.TimeUnit.MILLISECONDS +import static java.util.concurrent.TimeUnit.SECONDS import static org.mockserver.model.HttpRequest.request import static org.mockserver.model.HttpResponse.response import static org.mockserver.model.HttpStatusCode.OK_200 @@ -48,6 +50,13 @@ class Bidder extends NetworkScaffolding { } } + void setResponseWithDelay(Long dilayTimeoutMillisecond = 5000) { + mockServerClient.when(request().withPath(endpoint), Times.unlimited(), TimeToLive.unlimited(), -10) + .respond {request -> request.withPath(endpoint) + ? response().withDelay(MILLISECONDS, dilayTimeoutMillisecond).withStatusCode(OK_200.code()).withBody(getBodyByRequest(request)) + : HttpResponse.notFoundResponse()} + } + List getBidderRequests(String bidRequestId) { getRecordedRequestsBody(bidRequestId).collect { decode(it, BidRequest) } } diff --git a/src/test/groovy/org/prebid/server/functional/tests/SecondaryBidderSpec.groovy b/src/test/groovy/org/prebid/server/functional/tests/SecondaryBidderSpec.groovy new file mode 100644 index 00000000000..d032747b0d7 --- /dev/null +++ b/src/test/groovy/org/prebid/server/functional/tests/SecondaryBidderSpec.groovy @@ -0,0 +1,462 @@ +package org.prebid.server.functional.tests + +import org.prebid.server.functional.model.bidder.BidderName +import org.prebid.server.functional.model.bidder.Generic +import org.prebid.server.functional.model.bidder.Openx +import org.prebid.server.functional.model.config.AccountAuctionConfig +import org.prebid.server.functional.model.config.AccountConfig +import org.prebid.server.functional.model.db.Account +import org.prebid.server.functional.model.request.auction.BidRequest +import org.prebid.server.functional.model.response.auction.ErrorType +import org.prebid.server.functional.service.PrebidServerService +import org.prebid.server.functional.testcontainers.scaffolding.Bidder +import org.prebid.server.functional.util.PBSUtils +import spock.lang.Shared + +import static org.prebid.server.functional.model.bidder.BidderName.ALIAS +import static org.prebid.server.functional.model.bidder.BidderName.OPENX +import static org.prebid.server.functional.model.bidder.BidderName.GENERIC +import static org.prebid.server.functional.model.bidder.BidderName.UNKNOWN +import static org.prebid.server.functional.model.response.auction.BidRejectionReason.ERROR_TIMED_OUT +import static org.prebid.server.functional.testcontainers.Dependencies.getNetworkServiceContainer + +class SecondaryBidderSpec extends BaseSpec { + + private static final String OPENX_AUCTION_ENDPOINT = "/openx-auction" + private static final String GENERIC_ALIAS_AUCTION_ENDPOINT = "/generic-alias-auction" + private static final Map OPENX_CONFIG = [ + "adapters.${OPENX.value}.enabled" : "true", + "adapters.${OPENX.value}.endpoint": "$networkServiceContainer.rootUri$OPENX_AUCTION_ENDPOINT".toString()] + private static final Map GENERIC_ALIAS_CONFIG = [ + "adapters.${GENERIC.value}.aliases.${ALIAS}.enabled" : "true", + "adapters.${GENERIC.value}.aliases.${ALIAS}.endpoint": "$networkServiceContainer.rootUri$GENERIC_ALIAS_AUCTION_ENDPOINT".toString()] + private static final String WARNING_TIME_OUT_MESSAGE = "secondary bidder timed out, auction proceeded" + private static final Long RESPONSE_DELAY_MILLISECONDS = 5000 + private static final Bidder openXBidder = new Bidder(networkServiceContainer, OPENX_AUCTION_ENDPOINT) + private static final Bidder genericAliasBidder = new Bidder(networkServiceContainer, GENERIC_ALIAS_AUCTION_ENDPOINT) + + @Shared + PrebidServerService pbsServiceWithOpenXBidder = pbsServiceFactory.getService(OPENX_CONFIG + GENERIC_ALIAS_CONFIG) + + @Override + def cleanupSpec() { + pbsServiceFactory.removeContainer(OPENX_CONFIG + GENERIC_ALIAS_CONFIG) + } + + def cleanup() { + openXBidder.reset() + genericAliasBidder.reset() + } + + def "PBS shouldn't emit warning when secondary bidders account config set to #secondaryBidder"() { + given: "Default basic BidRequest with generic bidder" + def bidRequest = BidRequest.defaultBidRequest.tap { + enabledReturnAllBidStatus() + } + + and: "Account in the DB" + def accountConfig = AccountConfig.defaultAccountConfig.tap { + it.auction = new AccountAuctionConfig(secondaryBidders: [secondaryBidder]) + } + def account = new Account(uuid: bidRequest.accountId, config: accountConfig) + accountDao.save(account) + + when: "PBS processes auction request" + def bidResponse = pbsServiceWithOpenXBidder.sendAuctionRequest(bidRequest) + + then: "PBs should process bidder request" + assert bidder.getBidderRequests(bidRequest.id) + + and: "PBS shouldn't contain errors, warnings and seat non bid" + assert !bidResponse.ext?.warnings + assert !bidResponse.ext?.errors + assert !bidResponse.ext?.seatnonbid + + where: + secondaryBidder << [null, UNKNOWN] + } + + def "PBS should treat all bidders as primary when all requested bidders in secondary bidders account config"() { + given: "Default basic BidRequest with generic and openx bidder" + def bidRequest = getEnrichedBidRequest([GENERIC, OPENX]) + + and: "Account in the DB" + def accountConfig = AccountConfig.defaultAccountConfig.tap { + it.auction = new AccountAuctionConfig(secondaryBidders: [GENERIC, OPENX]) + } + def account = new Account(uuid: bidRequest.accountId, config: accountConfig) + accountDao.save(account) + + and: "Set up openx response" + openXBidder.setResponse() + + when: "PBS processes auction request" + def bidResponse = pbsServiceWithOpenXBidder.sendAuctionRequest(bidRequest) + + then: "PBs should processed generic request" + def genericBidderRequests = bidder.getBidderRequests(bidRequest.id) + assert genericBidderRequests.size() == 1 + + and: "PBs should processed openx request" + def openXBidderRequests = openXBidder.getBidderRequests(bidRequest.id) + assert openXBidderRequests.size() == 1 + + and: "PBS shouldn't contain errors, warnings and seat non bid" + assert !bidResponse.ext?.warnings + assert !bidResponse.ext?.errors + assert !bidResponse.ext?.seatnonbid + } + + def "PBS shouldn't wait on non-prioritized bidder when primary bidder from account configuration responds"() { + given: "Default bid request with generic and openX bidders" + def bidRequest = getEnrichedBidRequest([GENERIC, OPENX]) + + and: "Account in the DB" + def accountConfig = AccountConfig.defaultAccountConfig.tap { + it.auction = secondaryBiddersConfig + } + def account = new Account(uuid: bidRequest.accountId, config: accountConfig) + accountDao.save(account) + + and: "Set up openx bidder response with delay" + openXBidder.setResponseWithDelay(RESPONSE_DELAY_MILLISECONDS) + + when: "PBS processes auction request" + def bidResponse = pbsServiceWithOpenXBidder.sendAuctionRequest(bidRequest) + + then: "PBs should processed bidder call" + assert bidder.getBidderRequests(bidRequest.id) + assert openXBidder.getBidderRequest(bidRequest.id) + + and: "PBs response shouldn't contain response body from openX bidder" + assert !bidResponse?.ext?.debug?.httpcalls[OPENX.value]?.responseBody + + and: "PBS shouldn't contain error for openX due to timeout" + assert !bidResponse.ext?.errors + + and: "PBs should respond with warning for openx" + assert bidResponse.ext?.warnings[ErrorType.OPENX].message == [WARNING_TIME_OUT_MESSAGE] + + and: "PBs should populate seatNonBid for openX bidder" + def seatNonBid = bidResponse.ext.seatnonbid[0] + assert seatNonBid.seat == OPENX + assert seatNonBid.nonBid[0].impId == bidRequest.imp[0].id + assert seatNonBid.nonBid[0].statusCode == ERROR_TIMED_OUT + + where: + secondaryBiddersConfig << [ + new AccountAuctionConfig(secondaryBidders: [OPENX]), + new AccountAuctionConfig(secondaryBiddersSnakeCase: [OPENX]) + ] + } + + def "PBS shouldn't treat alias as secondary when root bidder is secondary in account config"() { + given: "Default bid request with generic and openX bidders" + def bidRequest = getEnrichedBidRequest([OPENX, ALIAS]).tap { + it.ext.prebid.aliases = [(ALIAS.value): OPENX] + } + + and: "Account in the DB" + def accountConfig = AccountConfig.defaultAccountConfig.tap { + it.auction = new AccountAuctionConfig(secondaryBidders: [OPENX]) + } + def account = new Account(uuid: bidRequest.accountId, config: accountConfig) + accountDao.save(account) + + and: "Set up openx bidder response with delay" + openXBidder.setResponseWithDelay(RESPONSE_DELAY_MILLISECONDS) + + and: "Set up openx alias bidder response" + genericAliasBidder.setResponse() + + when: "PBS processes auction request" + def bidResponse = pbsServiceWithOpenXBidder.sendAuctionRequest(bidRequest) + + then: "PBS should process bidder request" + assert bidder.getBidderRequest(bidRequest.id) + assert genericAliasBidder.getBidderRequest(bidRequest.id) + assert openXBidder.getBidderRequest(bidRequest.id) + + and: "PBs response should contain openX alias and generic" + assert bidResponse.seatbid.seat.sort() == [ALIAS, GENERIC].sort() + + and: "PBs response should contain response body from generic and alias bidder" + def httpCalls = bidResponse?.ext?.debug?.httpcalls + assert httpCalls[GENERIC.value]?.responseBody + assert httpCalls[ALIAS.value]?.responseBody + + and: "PBS response shouldn't contain response body from openX bidder" + assert !httpCalls[OPENX.value]?.responseBody + + and: "PBS shouldn't contain error for openX due to timeout" + assert !bidResponse.ext?.errors + + and: "PBs should respond with warning for openx" + assert bidResponse.ext?.warnings[ErrorType.OPENX].message == [WARNING_TIME_OUT_MESSAGE] + + and: "PBs should populate seatNonBid" + def seatNonBid = bidResponse.ext.seatnonbid[0] + assert seatNonBid.seat == OPENX + assert seatNonBid.nonBid[0].impId == bidRequest.imp[0].id + assert seatNonBid.nonBid[0].statusCode == ERROR_TIMED_OUT + } + + def "PBS shouldn't wait on secondary bidder when alias bidder respond with delay"() { + given: "Default bid request with generic and openX bidders" + def bidRequest = getEnrichedBidRequest([OPENX, ALIAS]).tap { + it.ext.prebid.aliases = [(ALIAS.value): OPENX] + } + + and: "Account in the DB" + def accountConfig = AccountConfig.defaultAccountConfig.tap { + it.auction = new AccountAuctionConfig(secondaryBidders: [ALIAS]) + } + def account = new Account(uuid: bidRequest.accountId, config: accountConfig) + accountDao.save(account) + + and: "Set up openx bidder response with delay" + genericAliasBidder.setResponseWithDelay(RESPONSE_DELAY_MILLISECONDS) + + and: "Set up openx alias bidder response" + openXBidder.setResponse() + + when: "PBS processes auction request" + def bidResponse = pbsServiceWithOpenXBidder.sendAuctionRequest(bidRequest) + + then: "PBs should process bidder request" + assert bidder.getBidderRequest(bidRequest.id) + assert genericAliasBidder.getBidderRequest(bidRequest.id) + assert openXBidder.getBidderRequest(bidRequest.id) + + and: "PBs repose shouldn't contain response body from openX bidder" + assert !bidResponse?.ext?.debug?.httpcalls[ALIAS.value]?.responseBody + + and: "PBS should contain error for openX due to timeout" + assert !bidResponse.ext?.errors + + and: "PBs should respond with warning for openx alias" + assert bidResponse.ext?.warnings[ErrorType.ALIAS].message == [WARNING_TIME_OUT_MESSAGE] + + and: "PBs should populate seatNonBid" + def seatNonBid = bidResponse.ext.seatnonbid[0] + assert seatNonBid.seat == ALIAS + assert seatNonBid.nonBid[0].impId == bidRequest.imp[0].id + assert seatNonBid.nonBid[0].statusCode == ERROR_TIMED_OUT + } + + def "PBS should pass auction as usual when primary bidder responds after secondary"() { + given: "Default bid request with generic and openX bidders" + def bidRequest = getEnrichedBidRequest([GENERIC, OPENX]) + + and: "Account in the DB" + def accountConfig = AccountConfig.defaultAccountConfig.tap { + it.auction = new AccountAuctionConfig(secondaryBidders: [GENERIC]) + } + def account = new Account(uuid: bidRequest.accountId, config: accountConfig) + accountDao.save(account) + + and: "Set up openx bidder response with delay" + def openXRandomDelay = bidRequest.tmax - PBSUtils.getRandomNumber(100, 500) + openXBidder.setResponseWithDelay(openXRandomDelay) + + when: "PBS processes auction request" + def bidResponse = pbsServiceWithOpenXBidder.sendAuctionRequest(bidRequest) + + then: "PBs should process bidder request" + assert bidder.getBidderRequest(bidRequest.id) + assert openXBidder.getBidderRequest(bidRequest.id) + + and: "PBs response should contain generic and openX bidders" + assert bidResponse.seatbid.seat.sort() == [GENERIC, OPENX].sort() + + and: "PBS shouldn't contain errors, warnings and seat non bid" + assert !bidResponse.ext?.warnings + assert !bidResponse.ext?.errors + assert !bidResponse.ext?.seatnonbid + } + + def "PBS shouldn't emit warning when secondary bidders request config set to #secondaryBidder"() { + given: "Default basic BidRequest with generic bidder" + def bidRequest = BidRequest.defaultBidRequest.tap { + enabledReturnAllBidStatus() + ext.prebid.secondaryBidders = [secondaryBidder] + } + + when: "PBS processes auction request" + def bidResponse = pbsServiceWithOpenXBidder.sendAuctionRequest(bidRequest) + + then: "PBs should process bidder request" + assert bidder.getBidderRequests(bidRequest.id) + + and: "PBS shouldn't contain errors, warnings and seat non bid" + assert !bidResponse.ext?.warnings + assert !bidResponse.ext?.errors + assert !bidResponse.ext?.seatnonbid + + where: + secondaryBidder << [null, UNKNOWN] + } + + def "PBS should treat all bidders as primary when all requested bidders in secondary bidders request config"() { + given: "Default basic BidRequest with generic and openx bidder" + def bidRequest = getEnrichedBidRequest([GENERIC, OPENX]).tap { + ext.prebid.secondaryBidders = [GENERIC, OPENX] + } + + and: "Set up openx response" + openXBidder.setResponse() + + when: "PBS processes auction request" + def bidResponse = pbsServiceWithOpenXBidder.sendAuctionRequest(bidRequest) + + then: "PBs should processed generic request" + def genericBidderRequests = bidder.getBidderRequests(bidRequest.id) + assert genericBidderRequests.size() == 1 + + and: "PBs should processed openx request" + def openXBidderRequests = openXBidder.getBidderRequests(bidRequest.id) + assert openXBidderRequests.size() == 1 + + and: "PBS shouldn't contain errors, warnings and seat non bid" + assert !bidResponse.ext?.warnings + assert !bidResponse.ext?.errors + assert !bidResponse.ext?.seatnonbid + } + + def "PBS shouldn't wait on non-prioritized bidder when primary bidder from request configuration responds"() { + given: "Default bid request with generic and openX bidders" + def bidRequest = getEnrichedBidRequest([GENERIC, OPENX]).tap { + ext.prebid.secondaryBidders = [OPENX] + } + + and: "Set up openx bidder response with delay" + openXBidder.setResponseWithDelay(RESPONSE_DELAY_MILLISECONDS) + + when: "PBS processes auction request" + def bidResponse = pbsServiceWithOpenXBidder.sendAuctionRequest(bidRequest) + + then: "PBs should processed bidder call" + assert bidder.getBidderRequests(bidRequest.id) + assert openXBidder.getBidderRequest(bidRequest.id) + + and: "PBs response shouldn't contain response body from openX bidder" + assert !bidResponse?.ext?.debug?.httpcalls[OPENX.value]?.responseBody + + and: "PBS shouldn't contain error for openX due to timeout" + assert !bidResponse.ext?.errors + + and: "PBs should respond with warning for openx" + assert bidResponse.ext?.warnings[ErrorType.OPENX].message == [WARNING_TIME_OUT_MESSAGE] + + and: "PBs should populate seatNonBid for openX bidder" + def seatNonBid = bidResponse.ext.seatnonbid[0] + assert seatNonBid.seat == OPENX + assert seatNonBid.nonBid[0].impId == bidRequest.imp[0].id + assert seatNonBid.nonBid[0].statusCode == ERROR_TIMED_OUT + } + + def "PBS shouldn't treat alias as secondary when root bidder is secondary in request config"() { + given: "Default bid request with generic and openX bidders" + def bidRequest = getEnrichedBidRequest([OPENX, ALIAS]).tap { + it.ext.prebid.aliases = [(ALIAS.value): OPENX] + ext.prebid.secondaryBidders = [OPENX] + } + + and: "Set up openx bidder response with delay" + openXBidder.setResponseWithDelay(RESPONSE_DELAY_MILLISECONDS) + + and: "Set up openx alias bidder response" + genericAliasBidder.setResponse() + + when: "PBS processes auction request" + def bidResponse = pbsServiceWithOpenXBidder.sendAuctionRequest(bidRequest) + + then: "PBS should process bidder request" + assert bidder.getBidderRequest(bidRequest.id) + assert genericAliasBidder.getBidderRequest(bidRequest.id) + assert openXBidder.getBidderRequest(bidRequest.id) + + and: "PBs response should contain openX alias and generic" + assert bidResponse.seatbid.seat.sort() == [ALIAS, GENERIC].sort() + + and: "PBs response should contain response body from generic and alias bidder" + def httpCalls = bidResponse?.ext?.debug?.httpcalls + assert httpCalls[GENERIC.value]?.responseBody + assert httpCalls[ALIAS.value]?.responseBody + + and: "PBS response shouldn't contain response body from openX bidder" + assert !httpCalls[OPENX.value]?.responseBody + + and: "PBS shouldn't contain error for openX due to timeout" + assert !bidResponse.ext?.errors + + and: "PBs should respond with warning for openx" + assert bidResponse.ext?.warnings[ErrorType.OPENX].message == [WARNING_TIME_OUT_MESSAGE] + + and: "PBs should populate seatNonBid" + def seatNonBid = bidResponse.ext.seatnonbid[0] + assert seatNonBid.seat == OPENX + assert seatNonBid.nonBid[0].impId == bidRequest.imp[0].id + assert seatNonBid.nonBid[0].statusCode == ERROR_TIMED_OUT + } + + def "PBS should prioritize request secondary bidders config over account config when there conflict"() { + given: "Default bid request with generic and openX bidders" + def bidRequest = getEnrichedBidRequest([GENERIC, OPENX]).tap { + ext.prebid.secondaryBidders = [OPENX] + } + + and: "Set up openx bidder response with delay" + openXBidder.setResponseWithDelay(RESPONSE_DELAY_MILLISECONDS) + + and: "Account in the DB" + def accountConfig = AccountConfig.defaultAccountConfig.tap { + it.auction = secondaryBiddersConfig + } + def account = new Account(uuid: bidRequest.accountId, config: accountConfig) + accountDao.save(account) + + when: "PBS processes auction request" + def bidResponse = pbsServiceWithOpenXBidder.sendAuctionRequest(bidRequest) + + then: "PBs should processed bidder call" + assert bidder.getBidderRequests(bidRequest.id) + assert openXBidder.getBidderRequest(bidRequest.id) + + and: "PBs response shouldn't contain response body from openX bidder" + assert !bidResponse?.ext?.debug?.httpcalls[OPENX.value]?.responseBody + + and: "PBS shouldn't contain error for openX due to timeout" + assert !bidResponse.ext?.errors + + and: "PBs should respond with warning for openx" + assert bidResponse.ext?.warnings[ErrorType.OPENX].message == [WARNING_TIME_OUT_MESSAGE] + + and: "PBs should populate seatNonBid for openX bidder" + def seatNonBid = bidResponse.ext.seatnonbid[0] + assert seatNonBid.seat == OPENX + assert seatNonBid.nonBid[0].impId == bidRequest.imp[0].id + assert seatNonBid.nonBid[0].statusCode == ERROR_TIMED_OUT + + where: + secondaryBiddersConfig << [ + new AccountAuctionConfig(secondaryBidders: [OPENX]), + new AccountAuctionConfig(secondaryBiddersSnakeCase: [OPENX]) + ] + } + + private static BidRequest getEnrichedBidRequest(List bidderNames) { + BidRequest.defaultBidRequest.tap { + if (bidderNames.contains(GENERIC)) { + it.imp[0]?.ext?.prebid?.bidder?.generic = new Generic() + } + if (bidderNames.contains(OPENX)) { + it.imp[0]?.ext?.prebid?.bidder?.openx = Openx.defaultOpenx + } + if (bidderNames.contains(ALIAS)) { + it.imp[0]?.ext?.prebid?.bidder?.alias = new Generic() + } + enabledReturnAllBidStatus() + } + } +} diff --git a/src/test/java/org/prebid/server/auction/ExchangeServiceTest.java b/src/test/java/org/prebid/server/auction/ExchangeServiceTest.java index 84ec69ecddb..23cebedf491 100644 --- a/src/test/java/org/prebid/server/auction/ExchangeServiceTest.java +++ b/src/test/java/org/prebid/server/auction/ExchangeServiceTest.java @@ -29,6 +29,7 @@ import com.iab.openrtb.response.BidResponse; import com.iab.openrtb.response.SeatBid; import io.vertx.core.Future; +import io.vertx.core.Promise; import org.apache.commons.collections4.MapUtils; import org.apache.commons.lang3.tuple.Pair; import org.assertj.core.api.InstanceOfAssertFactories; @@ -39,6 +40,7 @@ import org.mockito.Mock; import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.stubbing.Answer; import org.prebid.server.VertxTest; import org.prebid.server.activity.Activity; import org.prebid.server.activity.ComponentType; @@ -177,12 +179,16 @@ import java.util.UUID; import java.util.function.Function; import java.util.function.UnaryOperator; +import java.util.stream.Collectors; import static java.math.BigDecimal.ONE; import static java.math.BigDecimal.TEN; +import static java.math.BigDecimal.TWO; import static java.util.Arrays.asList; import static java.util.Collections.emptyList; import static java.util.Collections.emptyMap; +import static java.util.Collections.emptySet; +import static java.util.Collections.singleton; import static java.util.Collections.singletonList; import static java.util.Collections.singletonMap; import static java.util.function.UnaryOperator.identity; @@ -209,7 +215,11 @@ import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; +import static org.prebid.server.auction.model.BidRejectionReason.ERROR_GENERAL; +import static org.prebid.server.auction.model.BidRejectionReason.ERROR_TIMED_OUT; import static org.prebid.server.auction.model.BidRejectionReason.NO_BID; +import static org.prebid.server.auction.model.BidRejectionReason.REQUEST_BLOCKED_GENERAL; +import static org.prebid.server.auction.model.BidRejectionReason.REQUEST_BLOCKED_PRIVACY; import static org.prebid.server.auction.model.BidRejectionReason.REQUEST_BLOCKED_UNSUPPORTED_MEDIA_TYPE; import static org.prebid.server.proto.openrtb.ext.response.BidType.banner; import static org.prebid.server.proto.openrtb.ext.response.BidType.video; @@ -4185,6 +4195,408 @@ public void shouldDropBidsWithInvalidPriceAndAddDebugWarningsWhenDebugEnabled() verify(metrics, times(3)).updateAdapterRequestErrorMetric("bidder", MetricName.unknown_error); } + @Test + public void shouldWaitForPrimaryBidders() { + // given + doReturn(Promise.promise().future()).when(httpBidderRequester) + .requestBids(any(), argThat(bidderRequest -> bidderRequest.getBidder().equals("primary")), + any(), any(), any(), any(), anyBoolean()); + + doReturn(Future.succeededFuture(givenSeatBid(emptyList()))).when(httpBidderRequester) + .requestBids(any(), argThat(bidderRequest -> bidderRequest.getBidder().equals("secondary")), + any(), any(), any(), any(), anyBoolean()); + + final BidRequest bidRequest = givenBidRequest(givenSingleImp(Map.of("primary", 1, "secondary", 2))); + final Account account = Account.builder() + .auction(AccountAuctionConfig.builder().secondaryBidders(singleton("secondary")).build()) + .build(); + final AuctionContext auctionContext = givenRequestContext(bidRequest, account); + + // when + final Future result = target.holdAuction(auctionContext); + + // then + assertThat(result.isComplete()).isFalse(); + } + + @Test + public void shouldNotWaitForSecondaryBidders() { + // given + doReturn(Future.succeededFuture(givenSeatBid(emptyList()))).when(httpBidderRequester) + .requestBids(any(), argThat(bidderRequest -> bidderRequest.getBidder().equals("primary")), + any(), any(), any(), any(), anyBoolean()); + + doReturn(Promise.promise().future()).when(httpBidderRequester) + .requestBids(any(), argThat(bidderRequest -> bidderRequest.getBidder().equals("secondary")), + any(), any(), any(), any(), anyBoolean()); + + final BidRequest bidRequest = givenBidRequest(givenSingleImp(Map.of("primary", 1, "secondary", 2))); + final Account account = Account.builder() + .auction(AccountAuctionConfig.builder().secondaryBidders(singleton("secondary")).build()) + .build(); + final AuctionContext auctionContext = givenRequestContext(bidRequest, account); + + // when + final Future result = target.holdAuction(auctionContext); + + // then + assertThat(result.succeeded()).isTrue(); + } + + @Test + public void shouldWaitForSecondaryBiddersWhenThereAreNoPrimaryBidders() { + // given + doReturn(Future.succeededFuture(givenSeatBid(emptyList()))).when(httpBidderRequester) + .requestBids(any(), argThat(bidderRequest -> bidderRequest.getBidder().equals("secondaryCompleted")), + any(), any(), any(), any(), anyBoolean()); + + doReturn(Promise.promise().future()).when(httpBidderRequester) + .requestBids(any(), argThat(bidderRequest -> bidderRequest.getBidder().equals("secondaryUncompleted")), + any(), any(), any(), any(), anyBoolean()); + + final BidRequest bidRequest = givenBidRequest(givenSingleImp( + Map.of("secondaryCompleted", 2, "secondaryUncompleted", 3))); + final Account account = Account.builder() + .auction(AccountAuctionConfig.builder() + .secondaryBidders(Set.of("secondaryCompleted", "secondaryUncompleted")) + .build()) + .build(); + final AuctionContext auctionContext = givenRequestContext(bidRequest, account); + + // when + final Future result = target.holdAuction(auctionContext); + + // then + assertThat(result.isComplete()).isFalse(); + } + + @Test + public void shouldReturnEmptyBidResponseWithTimeoutWarningForUncompletedSecondaryBidders() { + // given + doReturn(Future.succeededFuture(givenSeatBid(emptyList()))).when(httpBidderRequester) + .requestBids(any(), argThat(bidderRequest -> bidderRequest.getBidder().equals("primary")), + any(), any(), any(), any(), anyBoolean()); + + doReturn(Promise.promise().future()).when(httpBidderRequester) + .requestBids(any(), argThat(bidderRequest -> bidderRequest.getBidder().equals("secondary")), + any(), any(), any(), any(), anyBoolean()); + + final BidRequest bidRequest = givenBidRequest(givenSingleImp( + Map.of("primary", 1, "secondary", 2))); + final Account account = Account.builder() + .auction(AccountAuctionConfig.builder() + .secondaryBidders(Set.of("secondary")) + .build()) + .build(); + final AuctionContext auctionContext = givenRequestContext(bidRequest, account); + + // when + target.holdAuction(auctionContext); + + // then + final ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(storedResponseProcessor).updateStoredBidResponse(captor.capture()); + assertThat(captor.getValue()) + .filteredOn(AuctionParticipation::getBidder, "secondary") + .extracting(AuctionParticipation::getBidderResponse) + .extracting(BidderResponse::getSeatBid) + .hasSize(1) + .allSatisfy(seatBid -> { + assertThat(seatBid.getBids()).isEmpty(); + assertThat(seatBid.getWarnings()).containsExactly( + BidderError.of("secondary bidder timed out, auction proceeded", BidderError.Type.timeout)); + }); + } + + @Test + public void shouldReturnBidsFromCompletedPrimaryAndSecondaryBidders() { + // given + final BidderSeatBid primaryBidderSeatBid = givenSingleSeatBid(givenBidderBid(Bid.builder().price(ONE).build())); + doReturn(Future.succeededFuture(primaryBidderSeatBid)).when(httpBidderRequester) + .requestBids(any(), argThat(bidderRequest -> bidderRequest.getBidder().equals("primary")), + any(), any(), any(), any(), anyBoolean()); + + final BidderSeatBid completedSecondaryBidderSeatBid = givenSingleSeatBid( + givenBidderBid(Bid.builder().price(TWO).build())); + doReturn(Future.succeededFuture(completedSecondaryBidderSeatBid)).when(httpBidderRequester) + .requestBids(any(), argThat(bidderRequest -> bidderRequest.getBidder().equals("secondaryCompleted")), + any(), any(), any(), any(), anyBoolean()); + + doReturn(Promise.promise().future()).when(httpBidderRequester) + .requestBids(any(), argThat(bidderRequest -> bidderRequest.getBidder().equals("secondaryUncompleted")), + any(), any(), any(), any(), anyBoolean()); + + final BidRequest bidRequest = givenBidRequest(givenSingleImp( + Map.of("primary", 1, "secondaryCompleted", 2, "secondaryUncompleted", 3))); + final Account account = Account.builder() + .auction(AccountAuctionConfig.builder() + .secondaryBidders(Set.of("secondaryCompleted", "secondaryUncompleted")) + .build()) + .build(); + final AuctionContext auctionContext = givenRequestContext(bidRequest, account); + + // when + target.holdAuction(auctionContext); + + // then + final ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(storedResponseProcessor).updateStoredBidResponse(captor.capture()); + assertThat(captor.getValue()) + .filteredOn(AuctionParticipation::getBidder, "primary") + .extracting(AuctionParticipation::getBidderResponse) + .extracting(BidderResponse::getSeatBid) + .flatExtracting(BidderSeatBid::getBids) + .extracting(BidderBid::getBid) + .extracting(Bid::getPrice) + .containsExactly(ONE); + + assertThat(captor.getValue()) + .filteredOn(AuctionParticipation::getBidder, "secondaryCompleted") + .extracting(AuctionParticipation::getBidderResponse) + .extracting(BidderResponse::getSeatBid) + .flatExtracting(BidderSeatBid::getBids) + .extracting(BidderBid::getBid) + .extracting(Bid::getPrice) + .containsExactly(TWO); + } + + @Test + public void shouldDiscardBidRejectionsFromUncompletedSecondaryBiddersAndReplaceThemWithTimeout() { + // given + doReturn(Future.succeededFuture(givenSeatBid(emptyList()))).when(httpBidderRequester) + .requestBids(any(), argThat(bidderRequest -> bidderRequest.getBidder().equals("primary")), + any(), any(), any(), any(), anyBoolean()); + + doAnswer(givenHttpBidderRequesterAnswerWithRejection(ERROR_GENERAL, Promise.promise().future())) + .when(httpBidderRequester) + .requestBids(any(), argThat(bidderRequest -> bidderRequest.getBidder().equals("secondary")), + any(), any(), any(), any(), anyBoolean()); + + final BidRequest bidRequest = givenBidRequest(givenSingleImp( + Map.of("primary", 1, "secondary", 2))); + final Account account = Account.builder() + .auction(AccountAuctionConfig.builder() + .secondaryBidders(Set.of("secondary")) + .build()) + .build(); + + final AuctionContext auctionContext = givenRequestContext(bidRequest, account); + + // when + final AuctionContext result = target.holdAuction(auctionContext).result(); + + // then + assertThat(result.getBidRejectionTrackers().get("secondary").getRejected()) + .extracting(Rejection::reason) + .containsExactlyInAnyOrder(ERROR_TIMED_OUT); + } + + @Test + public void shouldRetainPreviousBidRejectionsFromUncompletedSecondaryBidders() { + // given + doReturn(Future.succeededFuture(givenSeatBid(emptyList()))).when(httpBidderRequester) + .requestBids(any(), argThat(bidderRequest -> bidderRequest.getBidder().equals("primary")), + any(), any(), any(), any(), anyBoolean()); + + doReturn(Promise.promise().future()).when(httpBidderRequester) + .requestBids(any(), argThat(bidderRequest -> bidderRequest.getBidder().equals("secondary")), + any(), any(), any(), any(), anyBoolean()); + + final BidRequest bidRequest = givenBidRequest(givenSingleImp( + Map.of("primary", 1, "secondary", 2))); + final Account account = Account.builder() + .auction(AccountAuctionConfig.builder() + .secondaryBidders(Set.of("secondary")) + .build()) + .build(); + + final Map bidRejectionTrackers = new HashMap<>(); + bidRejectionTrackers.put("secondary", givenBidRejectionTrackerWithRejection(REQUEST_BLOCKED_GENERAL)); + + final AuctionContext auctionContext = givenRequestContext(bidRequest, account) + .toBuilder() + .bidRejectionTrackers(bidRejectionTrackers) + .build(); + + // when + final AuctionContext result = target.holdAuction(auctionContext).result(); + + // then + assertThat(result.getBidRejectionTrackers().get("secondary").getRejected()) + .extracting(Rejection::reason) + .containsExactlyInAnyOrder(REQUEST_BLOCKED_GENERAL, ERROR_TIMED_OUT); + } + + @Test + public void shouldRetainBidRejectionsFromPrimaryAndCompletedSecondaryBidders() { + // given + doAnswer(givenHttpBidderRequesterAnswerWithRejection( + ERROR_GENERAL, Future.succeededFuture(givenSeatBid(emptyList())))) + .when(httpBidderRequester) + .requestBids(any(), argThat(bidderRequest -> bidderRequest.getBidder().equals("primary")), + any(), any(), any(), any(), anyBoolean()); + + doAnswer(givenHttpBidderRequesterAnswerWithRejection( + ERROR_GENERAL, Future.succeededFuture(givenSeatBid(emptyList())))) + .when(httpBidderRequester) + .requestBids(any(), argThat(bidderRequest -> bidderRequest.getBidder().equals("secondarySucceeded")), + any(), any(), any(), any(), anyBoolean()); + + doAnswer(givenHttpBidderRequesterAnswerWithRejection( + ERROR_GENERAL, Future.failedFuture("failed"))) + .when(httpBidderRequester) + .requestBids(any(), argThat(bidderRequest -> bidderRequest.getBidder().equals("secondaryFailed")), + any(), any(), any(), any(), anyBoolean()); + + doReturn(Promise.promise().future()).when(httpBidderRequester) + .requestBids(any(), argThat(bidderRequest -> bidderRequest.getBidder().equals("secondaryUncompleted")), + any(), any(), any(), any(), anyBoolean()); + + final BidRequest bidRequest = givenBidRequest(givenSingleImp( + Map.of("primary", 1, "secondarySucceeded", 2, "secondaryFailed", 3, "secondaryUncompleted", 4))); + final Account account = Account.builder() + .auction(AccountAuctionConfig.builder() + .secondaryBidders(Set.of("secondarySucceeded", "secondaryFailed", "secondaryUncompleted")) + .build()) + .build(); + + final Map bidRejectionTrackers = new HashMap<>(); + bidRejectionTrackers.put("primary", givenBidRejectionTrackerWithRejection(REQUEST_BLOCKED_GENERAL)); + bidRejectionTrackers.put("secondarySucceeded", givenBidRejectionTrackerWithRejection(REQUEST_BLOCKED_GENERAL)); + bidRejectionTrackers.put("secondaryFailed", givenBidRejectionTrackerWithRejection(REQUEST_BLOCKED_GENERAL)); + + final AuctionContext auctionContext = givenRequestContext(bidRequest, account) + .toBuilder() + .bidRejectionTrackers(bidRejectionTrackers) + .build(); + + // when + final AuctionContext result = target.holdAuction(auctionContext).result(); + + // then + assertThat(result.getBidRejectionTrackers()) + .extractingByKeys("primary", "secondarySucceeded", "secondaryFailed") + .extracting(BidRejectionTracker::getRejected) + .extracting(rejections -> rejections.stream().map(Rejection::reason).collect(Collectors.toSet())) + .hasSize(3) + .containsOnly(Set.of(REQUEST_BLOCKED_GENERAL, ERROR_GENERAL)); + } + + @Test + public void shouldUseRequestLevelSecondaryBiddersOverAccountLevel() { + // given + doReturn(Future.succeededFuture(givenSeatBid(emptyList()))).when(httpBidderRequester) + .requestBids(any(), argThat(bidderRequest -> bidderRequest.getBidder().equals("secondaryAccount")), + any(), any(), any(), any(), anyBoolean()); + + doReturn(Promise.promise().future()).when(httpBidderRequester) + .requestBids(any(), argThat(bidderRequest -> bidderRequest.getBidder().equals("secondaryRequest")), + any(), any(), any(), any(), anyBoolean()); + + final BidRequest bidRequest = givenBidRequest( + givenSingleImp(Map.of("secondaryAccount", 1, "secondaryRequest", 2)), + builder -> builder.ext(ExtRequest.of(ExtRequestPrebid.builder() + .secondaryBidders(Set.of("secondaryRequest")) + .build()))); + + final Account account = Account.builder() + .auction(AccountAuctionConfig.builder() + .secondaryBidders(Set.of("secondaryAccount")) + .build()) + .build(); + final AuctionContext auctionContext = givenRequestContext(bidRequest, account); + + // when + final Future result = target.holdAuction(auctionContext); + + // then + assertThat(result.succeeded()).isTrue(); + } + + @Test + public void shouldTreatEmptyRequestLevelSecondaryBiddersAsOverrideOfAccountLevel() { + // given + doReturn(Promise.promise().future()).when(httpBidderRequester) + .requestBids(any(), argThat(bidderRequest -> bidderRequest.getBidder().equals("bidderA")), + any(), any(), any(), any(), anyBoolean()); + + doReturn(Future.succeededFuture(givenSeatBid(emptyList()))).when(httpBidderRequester) + .requestBids(any(), argThat(bidderRequest -> bidderRequest.getBidder().equals("bidderB")), + any(), any(), any(), any(), anyBoolean()); + + final BidRequest bidRequest = givenBidRequest( + givenSingleImp(Map.of("bidderA", 1, "bidderB", 2)), + builder -> builder.ext(ExtRequest.of(ExtRequestPrebid.builder() + .secondaryBidders(emptySet()) + .build()))); + + final Account account = Account.builder() + .auction(AccountAuctionConfig.builder() + .secondaryBidders(Set.of("bidderA")) + .build()) + .build(); + final AuctionContext auctionContext = givenRequestContext(bidRequest, account); + + // when + final Future result = target.holdAuction(auctionContext); + + // then + assertThat(result.isComplete()).isFalse(); + } + + @Test + public void shouldFallBackToAccountLevelSecondaryBiddersWhenRequestLevelIsAbsent() { + // given + doReturn(Future.succeededFuture(givenSeatBid(emptyList()))).when(httpBidderRequester) + .requestBids(any(), argThat(bidderRequest -> bidderRequest.getBidder().equals("primary")), + any(), any(), any(), any(), anyBoolean()); + + doReturn(Promise.promise().future()).when(httpBidderRequester) + .requestBids(any(), argThat(bidderRequest -> bidderRequest.getBidder().equals("secondary")), + any(), any(), any(), any(), anyBoolean()); + + final BidRequest bidRequest = givenBidRequest( + givenSingleImp(Map.of("primary", 1, "secondary", 2)), + builder -> builder.ext(ExtRequest.of(ExtRequestPrebid.builder().build()))); + + final Account account = Account.builder() + .auction(AccountAuctionConfig.builder() + .secondaryBidders(Set.of("secondary")) + .build()) + .build(); + final AuctionContext auctionContext = givenRequestContext(bidRequest, account); + + // when + final Future result = target.holdAuction(auctionContext); + + // then + assertThat(result.succeeded()).isTrue(); + } + + @Test + public void shouldRetainBidRejectionsForBiddersThatWereRejectedBeforeBidderFutureSplit() { + // given + final BidderPrivacyResult restrictedPrivacy = BidderPrivacyResult.builder() + .requestBidder("testBidder") + .blockedRequestByTcf(true) + .build(); + given(privacyEnforcementService.mask(any(), any(), any())) + .willReturn(Future.succeededFuture(singletonList(restrictedPrivacy))); + + final BidRequest bidRequest = givenBidRequest(givenSingleImp(Map.of("testBidder", 1))); + final AuctionContext auctionContext = givenRequestContext(bidRequest); + + // when + final AuctionContext result = target.holdAuction(auctionContext).result(); + + // then + assertThat(result.getBidRejectionTrackers()) + .extractingByKeys("testBidder") + .extracting(BidRejectionTracker::getRejected) + .extracting(rejections -> rejections.stream().map(Rejection::reason).collect(Collectors.toSet())) + .containsExactly(singleton(REQUEST_BLOCKED_PRIVACY)); + } + private void givenTarget(boolean enabledStrictAppSiteDoohValidation) { target = new ExchangeService( 0, @@ -4462,4 +4874,20 @@ private static EnumMap> stageOutcomes(Applied return new EnumMap<>(stageOutcomes); } + + private static Answer> givenHttpBidderRequesterAnswerWithRejection( + BidRejectionReason bidRejectionReason, + Future answer) { + + return invocation -> { + final BidRejectionTracker bidRejectionTracker = invocation.getArgument(2, BidRejectionTracker.class); + bidRejectionTracker.rejectAll(bidRejectionReason); + return answer; + }; + } + + private static BidRejectionTracker givenBidRejectionTrackerWithRejection(BidRejectionReason bidRejectionReason) { + return new BidRejectionTracker(null, singleton(UUID.randomUUID().toString()), 0.0) + .rejectAll(bidRejectionReason); + } }