diff --git a/src/main/java/org/prebid/server/auction/ExchangeService.java b/src/main/java/org/prebid/server/auction/ExchangeService.java index 428510be069..49fca4e4877 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,8 @@ import java.util.Objects; import java.util.Optional; import java.util.Set; +import java.util.function.Function; +import java.util.function.Predicate; import java.util.stream.Collectors; public class ExchangeService { @@ -257,17 +260,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 +450,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 +1122,100 @@ 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 = + MapUtil.mapValues(auctionContext.getBidRejectionTrackers(), BidRejectionTracker::copyOf); + + 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() + .map(MapUtil.mapEntryValueMapper((bidder, futureResponse) -> + futureResponse.isComplete() ? futureResponse : recoverUncompletedSecondaryBidder(bidder))) + .map(MapUtil.mapEntryMapper((bidder, futureResponse) -> futureResponse.map(bidderResponse -> + bidderToAuctionParticipation.get(bidder).with(bidderResponse)))) + .map(Future::result) + .filter(Objects::nonNull) + .toList()); + }); + } + + private Future recoverUncompletedSecondaryBidder(String bidderName) { + final BidderSeatBid bidderSeatBid = BidderSeatBid.builder() + .warnings(Collections.singletonList( + BidderError.of("secondary bidder timed out, auction proceeded", BidderError.Type.timeout))) + .build(); + + return Future.succeededFuture(BidderResponse.of(bidderName, bidderSeatBid, 0)); + } + + private CompositeFuture buildPrimaryBiddersCompositeFuture( + AuctionContext auctionContext, + Map> bidderToFutureResponse) { + + final Set secondaryBidders = Optional.of(auctionContext) + .map(AuctionContext::getAccount) + .map(Account::getAuction) + .map(AccountAuctionConfig::getSecondaryBidders) + .orElse(Collections.emptySet()); + + 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 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) { + + return futureResponse == null ? oldBidRejectionTracker : futureResponse.isComplete() + ? newBidRejectionTracker + : oldBidRejectionTracker.rejectAll(BidRejectionReason.ERROR_TIMED_OUT); + } + + 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) 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..65e711b78ff 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,49 @@ 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 +98,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 +140,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/settings/model/AccountAuctionConfig.java b/src/main/java/org/prebid/server/settings/model/AccountAuctionConfig.java index c78bd14770c..e47b863c695 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; + + @JsonProperty("secondarybidders") + 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/java/org/prebid/server/auction/ExchangeServiceTest.java b/src/test/java/org/prebid/server/auction/ExchangeServiceTest.java index 84ec69ecddb..1d5ef967190 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,15 @@ 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.singleton; import static java.util.Collections.singletonList; import static java.util.Collections.singletonMap; import static java.util.function.UnaryOperator.identity; @@ -209,7 +214,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 +4194,317 @@ 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 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 +4782,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); + } }