Repository: prebid/prebid-server
Pull request #2498 (2022-12-20 15:29:47)

Modularity: Bidder request stage implementation

legendko: Not sure how to solve the issue `Validate Merge / validate-merge (pull_request)`. The branches `raw-auction-stage` and `master` are already merged into current branch without existing conflicts.
SyntaxNode: I'll try the age old IT of advice of "turning it off, an on again". Let's see if this works.
SyntaxNode: Closing and reopening this PR caused the tests to rerun. There are legit errors:

```
Error: exchange/bidder_test.go:2851:6: TestExtraBidWithMultiCurrencies redeclared in this block
Error: 	exchange/bidder_test.go:2[72](https://github.com/prebid/prebid-server/actions/runs/3742519679/jobs/6353563751#step:4:73)7:6: other declaration of TestExtraBidWithMultiCurrencies
Error: exchange/bidder_test.go:2957:3: not enough arguments in call to bidder.requestBid
```

Looks like a merge conflict or an error with a merge.

Patch:
diff --git a/endpoints/openrtb2/sample-requests/valid-whole/hooks/auction_bidder_reject.json b/endpoints/openrtb2/sample-requests/valid-whole/hooks/auction_bidder_reject.json
new file mode 100644
index 00000000..29d333ca
--- /dev/null
+++ b/endpoints/openrtb2/sample-requests/valid-whole/hooks/auction_bidder_reject.json
@@ -0,0 +1,78 @@
+{
+  "description": "Simple request",
+  "config": {
+    "mockBidders": [
+      {"bidderName": "appnexus", "currency": "USD", "price": 0.00},
+      {"bidderName": "applogy", "currency": "USD", "price": 0.00}
+    ]
+  },
+  "mockBidRequest": {
+    "id": "some-request-id",
+    "site": {
+      "page": "prebid.org"
+    },
+    "imp": [
+      {
+        "id": "some-impression-id",
+        "banner": {
+          "format": [
+            {
+              "w": 300,
+              "h": 250
+            },
+            {
+              "w": 300,
+              "h": 600
+            }
+          ]
+        },
+        "ext": {
+          "appnexus": {
+            "placementId": 12883451
+          },
+          "applogy": {
+            "placementId": 12883451
+          }
+        }
+      }
+    ],
+    "tmax": 50,
+    "ext": {}
+  },
+  "expectedBidResponse": {
+    "id":"some-request-id",
+    "bidid":"test bid id",
+    "cur":"USD",
+    "nbr":null,
+    "seatbid": [
+      {
+        "bid": [
+          {
+            "id": "applogy-bid",
+            "impid": "some-impression-id",
+            "price": 0,
+            "ext": {"prebid":{"type":"banner"}}
+          }
+        ],
+        "seat": "applogy"
+      }
+    ],
+    "ext": {
+      "warnings": {
+        "appnexus": [
+          {
+            "code": 11,
+            "message": "Module foobar (hook: foo) rejected request with code 123 at bidder_request stage"
+          }
+        ],
+        "general": [
+          {
+            "code": 10002,
+            "message": "debug turned off for account"
+          }
+        ]
+      }
+    }
+  },
+  "expectedReturnCode": 200
+}
diff --git a/exchange/bidder.go b/exchange/bidder.go
index a565606b..20184482 100644
--- a/exchange/bidder.go
+++ b/exchange/bidder.go
@@ -19,6 +19,7 @@ import (
 	"github.com/prebid/prebid-server/config/util"
 	"github.com/prebid/prebid-server/currency"
 	"github.com/prebid/prebid-server/experiment/adscert"
+	"github.com/prebid/prebid-server/hooks/hookexecution"
 	"github.com/prebid/prebid-server/version"
 
 	nativeRequests "github.com/prebid/openrtb/v17/native1/request"
@@ -54,7 +55,7 @@ type AdaptedBidder interface {
 	//
 	// Any errors will be user-facing in the API.
 	// Error messages should help publishers understand what might account for "bad" bids.
-	requestBid(ctx context.Context, bidderRequest BidderRequest, conversions currency.Conversions, reqInfo *adapters.ExtraRequestInfo, adsCertSigner adscert.Signer, bidRequestOptions bidRequestOptions, alternateBidderCodes openrtb_ext.ExtAlternateBidderCodes) ([]*pbsOrtbSeatBid, []error)
+	requestBid(ctx context.Context, bidderRequest BidderRequest, conversions currency.Conversions, reqInfo *adapters.ExtraRequestInfo, adsCertSigner adscert.Signer, bidRequestOptions bidRequestOptions, alternateBidderCodes openrtb_ext.ExtAlternateBidderCodes, hookExecutor hookexecution.StageExecutor) ([]*pbsOrtbSeatBid, []error)
 }
 
 // bidRequestOptions holds additional options for bid request execution to maintain clean code and reasonable number of parameters
@@ -154,7 +155,11 @@ type bidderAdapterConfig struct {
 	EndpointCompression string
 }
 
-func (bidder *bidderAdapter) requestBid(ctx context.Context, bidderRequest BidderRequest, conversions currency.Conversions, reqInfo *adapters.ExtraRequestInfo, adsCertSigner adscert.Signer, bidRequestOptions bidRequestOptions, alternateBidderCodes openrtb_ext.ExtAlternateBidderCodes) ([]*pbsOrtbSeatBid, []error) {
+func (bidder *bidderAdapter) requestBid(ctx context.Context, bidderRequest BidderRequest, conversions currency.Conversions, reqInfo *adapters.ExtraRequestInfo, adsCertSigner adscert.Signer, bidRequestOptions bidRequestOptions, alternateBidderCodes openrtb_ext.ExtAlternateBidderCodes, hookExecutor hookexecution.StageExecutor) ([]*pbsOrtbSeatBid, []error) {
+	reject := hookExecutor.ExecuteBidderRequestStage(bidderRequest.BidRequest, string(bidderRequest.BidderName))
+	if reject != nil {
+		return nil, []error{reject}
+	}
 
 	var reqData []*adapters.RequestData
 	var errs []error
diff --git a/exchange/bidder_validate_bids.go b/exchange/bidder_validate_bids.go
index 30395491..12dc21de 100644
--- a/exchange/bidder_validate_bids.go
+++ b/exchange/bidder_validate_bids.go
@@ -10,6 +10,7 @@ import (
 	"github.com/prebid/prebid-server/adapters"
 	"github.com/prebid/prebid-server/currency"
 	"github.com/prebid/prebid-server/experiment/adscert"
+	"github.com/prebid/prebid-server/hooks/hookexecution"
 	"github.com/prebid/prebid-server/openrtb_ext"
 	goCurrency "golang.org/x/text/currency"
 )
@@ -29,8 +30,8 @@ type validatedBidder struct {
 	bidder AdaptedBidder
 }
 
-func (v *validatedBidder) requestBid(ctx context.Context, bidderRequest BidderRequest, conversions currency.Conversions, reqInfo *adapters.ExtraRequestInfo, adsCertSigner adscert.Signer, bidRequestOptions bidRequestOptions, alternateBidderCodes openrtb_ext.ExtAlternateBidderCodes) ([]*pbsOrtbSeatBid, []error) {
-	seatBids, errs := v.bidder.requestBid(ctx, bidderRequest, conversions, reqInfo, adsCertSigner, bidRequestOptions, alternateBidderCodes)
+func (v *validatedBidder) requestBid(ctx context.Context, bidderRequest BidderRequest, conversions currency.Conversions, reqInfo *adapters.ExtraRequestInfo, adsCertSigner adscert.Signer, bidRequestOptions bidRequestOptions, alternateBidderCodes openrtb_ext.ExtAlternateBidderCodes, hookExecutor hookexecution.StageExecutor) ([]*pbsOrtbSeatBid, []error) {
+	seatBids, errs := v.bidder.requestBid(ctx, bidderRequest, conversions, reqInfo, adsCertSigner, bidRequestOptions, alternateBidderCodes, hookExecutor)
 	for _, seatBid := range seatBids {
 		if validationErrors := removeInvalidBids(bidderRequest.BidRequest, seatBid); len(validationErrors) > 0 {
 			errs = append(errs, validationErrors...)
diff --git a/exchange/exchange.go b/exchange/exchange.go
index 3e5fb1f5..611f7c06 100644
--- a/exchange/exchange.go
+++ b/exchange/exchange.go
@@ -307,7 +307,7 @@ func (e *exchange) HoldAuction(ctx context.Context, r AuctionRequest, debugLog *
 			alternateBidderCodes = *r.Account.AlternateBidderCodes
 		}
 
-		adapterBids, adapterExtra, anyBidsReturned = e.getAllBids(auctionCtx, bidderRequests, bidAdjustmentFactors, conversions, accountDebugAllow, r.GlobalPrivacyControlHeader, debugLog.DebugOverride, alternateBidderCodes, requestExt.Prebid.Experiment)
+		adapterBids, adapterExtra, anyBidsReturned = e.getAllBids(auctionCtx, bidderRequests, bidAdjustmentFactors, conversions, accountDebugAllow, r.GlobalPrivacyControlHeader, debugLog.DebugOverride, alternateBidderCodes, requestExt.Prebid.Experiment, r.HookExecutor)
 	}
 
 	var auc *auction
@@ -516,7 +516,8 @@ func (e *exchange) getAllBids(
 	globalPrivacyControlHeader string,
 	headerDebugAllowed bool,
 	alternateBidderCodes openrtb_ext.ExtAlternateBidderCodes,
-	experiment *openrtb_ext.Experiment) (
+	experiment *openrtb_ext.Experiment,
+	hookExecutor hookexecution.StageExecutor) (
 	map[openrtb_ext.BidderName]*pbsOrtbSeatBid,
 	map[openrtb_ext.BidderName]*seatResponseExtra, bool) {
 	// Set up pointers to the bid results
@@ -551,7 +552,7 @@ func (e *exchange) getAllBids(
 				addCallSignHeader:   isAdsCertEnabled(experiment, e.bidderInfo[string(bidderRequest.BidderName)]),
 				bidAdjustments:      bidAdjustments,
 			}
-			seatBids, err := e.adapterMap[bidderRequest.BidderCoreName].requestBid(ctx, bidderRequest, conversions, &reqInfo, e.adsCertSigner, bidReqOptions, alternateBidderCodes)
+			seatBids, err := e.adapterMap[bidderRequest.BidderCoreName].requestBid(ctx, bidderRequest, conversions, &reqInfo, e.adsCertSigner, bidReqOptions, alternateBidderCodes, hookExecutor)
 
 			// Add in time reporting
 			elapsed := time.Since(start)
diff --git a/hooks/hookexecution/executor.go b/hooks/hookexecution/executor.go
index bdc99c3d..477564c7 100644
--- a/hooks/hookexecution/executor.go
+++ b/hooks/hookexecution/executor.go
@@ -31,6 +31,7 @@ type StageExecutor interface {
 	ExecuteEntrypointStage(req *http.Request, body []byte) ([]byte, *RejectError)
 	ExecuteRawAuctionStage(body []byte) ([]byte, *RejectError)
 	ExecuteProcessedAuctionStage(req *openrtb2.BidRequest) *RejectError
+	ExecuteBidderRequestStage(req *openrtb2.BidRequest, bidder string) *RejectError
 }
 
 type HookStageExecutor interface {
@@ -161,6 +162,34 @@ func (e *hookExecutor) ExecuteProcessedAuctionStage(request *openrtb2.BidRequest
 	return reject
 }
 
+func (e *hookExecutor) ExecuteBidderRequestStage(req *openrtb2.BidRequest, bidder string) *RejectError {
+	plan := e.planBuilder.PlanForBidderRequestStage(e.endpoint, e.account)
+	if len(plan) == 0 {
+		return nil
+	}
+
+	handler := func(
+		ctx context.Context,
+		moduleCtx hookstage.ModuleInvocationContext,
+		hook hookstage.BidderRequest,
+		payload hookstage.BidderRequestPayload,
+	) (hookstage.HookResult[hookstage.BidderRequestPayload], error) {
+		return hook.HandleBidderRequestHook(ctx, moduleCtx, payload)
+	}
+
+	stageName := hooks.StageBidderRequest.String()
+	executionCtx := e.newContext(stageName)
+	payload := hookstage.BidderRequestPayload{BidRequest: req, Bidder: bidder}
+	outcome, payload, contexts, reject := executeStage(executionCtx, plan, payload, handler, e.metricEngine)
+	outcome.Entity = entity(bidder)
+	outcome.Stage = stageName
+
+	e.saveModuleContexts(contexts)
+	e.pushStageOutcome(outcome)
+
+	return reject
+}
+
 func (e *hookExecutor) newContext(stage string) executionContext {
 	return executionContext{
 		account:        e.account,
@@ -204,3 +233,7 @@ func (executor *EmptyHookExecutor) ExecuteRawAuctionStage(body []byte) ([]byte,
 func (executor *EmptyHookExecutor) ExecuteProcessedAuctionStage(_ *openrtb2.BidRequest) *RejectError {
 	return nil
 }
+
+func (executor *EmptyHookExecutor) ExecuteBidderRequestStage(_ *openrtb2.BidRequest, bidder string) *RejectError {
+	return nil
+}
diff --git a/hooks/hookstage/bidderrequest.go b/hooks/hookstage/bidderrequest.go
index 0da4640d..07a21fd4 100644
--- a/hooks/hookstage/bidderrequest.go
+++ b/hooks/hookstage/bidderrequest.go
@@ -26,4 +26,5 @@ type BidderRequest interface {
 // Hooks are allowed to modify openrtb2.BidRequest using mutations.
 type BidderRequestPayload struct {
 	BidRequest *openrtb2.BidRequest
+	Bidder     string
 }


Test patch:
diff --git a/endpoints/openrtb2/amp_auction_test.go b/endpoints/openrtb2/amp_auction_test.go
index ad604df8..8d21e3fc 100644
--- a/endpoints/openrtb2/amp_auction_test.go
+++ b/endpoints/openrtb2/amp_auction_test.go
@@ -1969,6 +1969,26 @@ func TestValidAmpResponseWhenRequestStagesRejected(t *testing.T) {
 			planBuilder:         mockPlanBuilder{processedAuctionPlan: makeRejectPlan[hookstage.ProcessedAuctionRequest](mockRejectionHook{nbr})},
 			expectedAmpResponse: AmpResponse{Targeting: map[string]string{}},
 		},
+		{
+			// bidder-request stage rejects only bidder, so we expect bidder rejection warning added
+			description: "Assert correct BidResponse when request rejected at bidder-request stage",
+			file:        file,
+			planBuilder: mockPlanBuilder{bidderRequestPlan: makeRejectPlan[hookstage.BidderRequest](mockRejectionHook{nbr})},
+			expectedAmpResponse: AmpResponse{Targeting: map[string]string{}, Warnings: map[openrtb_ext.BidderName][]openrtb_ext.ExtBidderMessage{
+				"appnexus": {
+					{
+						Code:    11,
+						Message: "Module foobar (hook: foo) rejected request with code 123 at bidder_request stage",
+					},
+				},
+				"general": {
+					{
+						Code:    10002,
+						Message: "debug turned off for account",
+					},
+				},
+			}},
+		},
 	}
 
 	for _, tc := range testCases {
diff --git a/endpoints/openrtb2/auction_test.go b/endpoints/openrtb2/auction_test.go
index a531d09c..3ce31c00 100644
--- a/endpoints/openrtb2/auction_test.go
+++ b/endpoints/openrtb2/auction_test.go
@@ -4680,6 +4680,12 @@ func TestValidResponseWhenRequestRejected(t *testing.T) {
 			file:        file,
 			planBuilder: mockPlanBuilder{processedAuctionPlan: makeRejectPlan[hookstage.ProcessedAuctionRequest](mockRejectionHook{nbr})},
 		},
+		{
+			// bidder-request stage doesn't reject whole request, so we do not expect NBR code in response
+			description: "Assert correct BidResponse when request rejected at bidder-request stage",
+			file:        "sample-requests/valid-whole/hooks/auction_bidder_reject.json",
+			planBuilder: mockPlanBuilder{bidderRequestPlan: makeRejectPlan[hookstage.BidderRequest](mockRejectionHook{nbr})},
+		},
 	}
 
 	for _, tc := range testCases {
diff --git a/endpoints/openrtb2/test_utils.go b/endpoints/openrtb2/test_utils.go
index f5b156e1..09186f6f 100644
--- a/endpoints/openrtb2/test_utils.go
+++ b/endpoints/openrtb2/test_utils.go
@@ -1516,3 +1516,17 @@ func (m mockRejectionHook) HandleProcessedAuctionHook(
 ) (hookstage.HookResult[hookstage.ProcessedAuctionRequestPayload], error) {
 	return hookstage.HookResult[hookstage.ProcessedAuctionRequestPayload]{Reject: true, NbrCode: m.nbr}, nil
 }
+
+func (m mockRejectionHook) HandleBidderRequestHook(
+	_ context.Context,
+	_ hookstage.ModuleInvocationContext,
+	payload hookstage.BidderRequestPayload,
+) (hookstage.HookResult[hookstage.BidderRequestPayload], error) {
+	result := hookstage.HookResult[hookstage.BidderRequestPayload]{}
+	if payload.Bidder == "appnexus" {
+		result.Reject = true
+		result.NbrCode = m.nbr
+	}
+
+	return result, nil
+}
diff --git a/exchange/bidder_test.go b/exchange/bidder_test.go
index 24b0b76e..b963fc77 100644
--- a/exchange/bidder_test.go
+++ b/exchange/bidder_test.go
@@ -25,6 +25,7 @@ import (
 	"github.com/prebid/prebid-server/currency"
 	"github.com/prebid/prebid-server/errortypes"
 	"github.com/prebid/prebid-server/experiment/adscert"
+	"github.com/prebid/prebid-server/hooks/hookexecution"
 	"github.com/prebid/prebid-server/metrics"
 	metricsConfig "github.com/prebid/prebid-server/metrics/config"
 	"github.com/prebid/prebid-server/openrtb_ext"
@@ -106,7 +107,7 @@ func TestSingleBidder(t *testing.T) {
 			addCallSignHeader:   false,
 			bidAdjustments:      bidAdjustments,
 		}
-		seatBids, errs := bidder.requestBid(ctx, bidderReq, currencyConverter.Rates(), &adapters.ExtraRequestInfo{}, &adscert.NilSigner{}, bidReqOptions, openrtb_ext.ExtAlternateBidderCodes{})
+		seatBids, errs := bidder.requestBid(ctx, bidderReq, currencyConverter.Rates(), &adapters.ExtraRequestInfo{}, &adscert.NilSigner{}, bidReqOptions, openrtb_ext.ExtAlternateBidderCodes{}, &hookexecution.EmptyHookExecutor{})
 		assert.Len(t, seatBids, 1)
 		seatBid := seatBids[0]
 
@@ -228,7 +229,7 @@ func TestSingleBidderGzip(t *testing.T) {
 			addCallSignHeader:   false,
 			bidAdjustments:      bidAdjustments,
 		}
-		seatBids, errs := bidder.requestBid(ctx, bidderReq, currencyConverter.Rates(), &adapters.ExtraRequestInfo{}, &adscert.NilSigner{}, bidReqOptions, openrtb_ext.ExtAlternateBidderCodes{})
+		seatBids, errs := bidder.requestBid(ctx, bidderReq, currencyConverter.Rates(), &adapters.ExtraRequestInfo{}, &adscert.NilSigner{}, bidReqOptions, openrtb_ext.ExtAlternateBidderCodes{}, &hookexecution.EmptyHookExecutor{})
 		assert.Len(t, seatBids, 1)
 		seatBid := seatBids[0]
 
@@ -328,7 +329,7 @@ func TestRequestBidRemovesSensitiveHeaders(t *testing.T) {
 		addCallSignHeader:   false,
 		bidAdjustments:      bidAdjustments,
 	}
-	seatBids, errs := bidder.requestBid(ctx, bidderReq, currencyConverter.Rates(), &adapters.ExtraRequestInfo{}, &adscert.NilSigner{}, bidReqOptions, openrtb_ext.ExtAlternateBidderCodes{})
+	seatBids, errs := bidder.requestBid(ctx, bidderReq, currencyConverter.Rates(), &adapters.ExtraRequestInfo{}, &adscert.NilSigner{}, bidReqOptions, openrtb_ext.ExtAlternateBidderCodes{}, &hookexecution.EmptyHookExecutor{})
 
 	expectedHttpCalls := []*openrtb_ext.ExtHttpCall{
 		{
@@ -380,7 +381,7 @@ func TestSetGPCHeader(t *testing.T) {
 		addCallSignHeader:   false,
 		bidAdjustments:      bidAdjustments,
 	}
-	seatBids, errs := bidder.requestBid(ctx, bidderReq, currencyConverter.Rates(), &adapters.ExtraRequestInfo{GlobalPrivacyControlHeader: "1"}, &adscert.NilSigner{}, bidReqOptions, openrtb_ext.ExtAlternateBidderCodes{})
+	seatBids, errs := bidder.requestBid(ctx, bidderReq, currencyConverter.Rates(), &adapters.ExtraRequestInfo{GlobalPrivacyControlHeader: "1"}, &adscert.NilSigner{}, bidReqOptions, openrtb_ext.ExtAlternateBidderCodes{}, &hookexecution.EmptyHookExecutor{})
 
 	expectedHttpCall := []*openrtb_ext.ExtHttpCall{
 		{
@@ -430,7 +431,7 @@ func TestSetGPCHeaderNil(t *testing.T) {
 		addCallSignHeader:   false,
 		bidAdjustments:      bidAdjustments,
 	}
-	seatBids, errs := bidder.requestBid(ctx, bidderReq, currencyConverter.Rates(), &adapters.ExtraRequestInfo{GlobalPrivacyControlHeader: "1"}, &adscert.NilSigner{}, bidReqOptions, openrtb_ext.ExtAlternateBidderCodes{})
+	seatBids, errs := bidder.requestBid(ctx, bidderReq, currencyConverter.Rates(), &adapters.ExtraRequestInfo{GlobalPrivacyControlHeader: "1"}, &adscert.NilSigner{}, bidReqOptions, openrtb_ext.ExtAlternateBidderCodes{}, &hookexecution.EmptyHookExecutor{})
 
 	expectedHttpCall := []*openrtb_ext.ExtHttpCall{
 		{
@@ -500,7 +501,7 @@ func TestMultiBidder(t *testing.T) {
 		addCallSignHeader:   false,
 		bidAdjustments:      bidAdjustments,
 	}
-	seatBids, errs := bidder.requestBid(context.Background(), bidderReq, currencyConverter.Rates(), &adapters.ExtraRequestInfo{}, &adscert.NilSigner{}, bidReqOptions, openrtb_ext.ExtAlternateBidderCodes{})
+	seatBids, errs := bidder.requestBid(context.Background(), bidderReq, currencyConverter.Rates(), &adapters.ExtraRequestInfo{}, &adscert.NilSigner{}, bidReqOptions, openrtb_ext.ExtAlternateBidderCodes{}, &hookexecution.EmptyHookExecutor{})
 
 	if len(seatBids) != 1 {
 		t.Fatalf("SeatBid should exist, because bids exist.")
@@ -880,6 +881,7 @@ func TestMultiCurrencies(t *testing.T) {
 				bidAdjustments:      bidAdjustments,
 			},
 			openrtb_ext.ExtAlternateBidderCodes{},
+			&hookexecution.EmptyHookExecutor{},
 		)
 		assert.Len(t, seatBids, 1)
 		seatBid := seatBids[0]
@@ -1038,6 +1040,7 @@ func TestMultiCurrencies_RateConverterNotSet(t *testing.T) {
 				bidAdjustments:      bidAdjustments,
 			},
 			openrtb_ext.ExtAlternateBidderCodes{},
+			&hookexecution.EmptyHookExecutor{},
 		)
 		assert.Len(t, seatBids, 1)
 		seatBid := seatBids[0]
@@ -1215,6 +1218,7 @@ func TestMultiCurrencies_RequestCurrencyPick(t *testing.T) {
 				bidAdjustments:      bidAdjustments,
 			},
 			openrtb_ext.ExtAlternateBidderCodes{},
+			&hookexecution.EmptyHookExecutor{},
 		)
 		assert.Len(t, seatBids, 1)
 		seatBid := seatBids[0]
@@ -1531,6 +1535,7 @@ func TestMobileNativeTypes(t *testing.T) {
 				bidAdjustments:      bidAdjustments,
 			},
 			openrtb_ext.ExtAlternateBidderCodes{},
+			&hookexecution.EmptyHookExecutor{},
 		)
 		assert.Len(t, seatBids, 1)
 
@@ -1649,6 +1654,7 @@ func TestRequestBidsStoredBidResponses(t *testing.T) {
 				bidAdjustments:      bidAdjustments,
 			},
 			openrtb_ext.ExtAlternateBidderCodes{},
+			&hookexecution.EmptyHookExecutor{},
 		)
 		assert.Len(t, seatBids, 1)
 
@@ -1675,7 +1681,7 @@ func TestErrorReporting(t *testing.T) {
 		addCallSignHeader:   false,
 		bidAdjustments:      bidAdjustments,
 	}
-	bids, errs := bidder.requestBid(context.Background(), bidderReq, currencyConverter.Rates(), &adapters.ExtraRequestInfo{}, &adscert.NilSigner{}, bidReqOptions, openrtb_ext.ExtAlternateBidderCodes{})
+	bids, errs := bidder.requestBid(context.Background(), bidderReq, currencyConverter.Rates(), &adapters.ExtraRequestInfo{}, &adscert.NilSigner{}, bidReqOptions, openrtb_ext.ExtAlternateBidderCodes{}, &hookexecution.EmptyHookExecutor{})
 	if bids != nil {
 		t.Errorf("There should be no seatbid if no http requests are returned.")
 	}
@@ -1907,7 +1913,7 @@ func TestCallRecordAdapterConnections(t *testing.T) {
 		addCallSignHeader:   false,
 		bidAdjustments:      bidAdjustments,
 	}
-	_, errs := bidder.requestBid(context.Background(), bidderReq, currencyConverter.Rates(), &adapters.ExtraRequestInfo{}, &adscert.NilSigner{}, bidReqOptions, openrtb_ext.ExtAlternateBidderCodes{})
+	_, errs := bidder.requestBid(context.Background(), bidderReq, currencyConverter.Rates(), &adapters.ExtraRequestInfo{}, &adscert.NilSigner{}, bidReqOptions, openrtb_ext.ExtAlternateBidderCodes{}, &hookexecution.EmptyHookExecutor{})
 
 	// Assert no errors
 	assert.Equal(t, 0, len(errs), "bidder.requestBid returned errors %v \n", errs)
@@ -2150,7 +2156,7 @@ func TestRequestBidsWithAdsCertsSigner(t *testing.T) {
 		addCallSignHeader:   true,
 		bidAdjustments:      bidAdjustments,
 	}
-	_, errs := bidder.requestBid(ctx, bidderReq, currencyConverter.Rates(), &adapters.ExtraRequestInfo{}, &MockSigner{}, bidReqOptions, openrtb_ext.ExtAlternateBidderCodes{})
+	_, errs := bidder.requestBid(ctx, bidderReq, currencyConverter.Rates(), &adapters.ExtraRequestInfo{}, &MockSigner{}, bidReqOptions, openrtb_ext.ExtAlternateBidderCodes{}, &hookexecution.EmptyHookExecutor{})
 
 	assert.Empty(t, errs, "no errors should be returned")
 }
@@ -2373,7 +2379,8 @@ func TestExtraBid(t *testing.T) {
 					AllowedBidderCodes: []string{"groupm"},
 				},
 			},
-		})
+		},
+		&hookexecution.EmptyHookExecutor{})
 	assert.Nil(t, errs)
 	assert.Len(t, seatBids, 2)
 	sort.Slice(seatBids, func(i, j int) bool {
@@ -2485,7 +2492,8 @@ func TestExtraBidWithAlternateBidderCodeDisabled(t *testing.T) {
 					AllowedBidderCodes: []string{"groupm-allowed"},
 				},
 			},
-		})
+		},
+		&hookexecution.EmptyHookExecutor{})
 	assert.Equal(t, wantErrs, errs)
 	assert.Len(t, seatBids, 2)
 	assert.ElementsMatch(t, wantSeatBids, seatBids)
@@ -2594,7 +2602,8 @@ func TestExtraBidWithBidAdjustments(t *testing.T) {
 					AllowedBidderCodes: []string{"groupm"},
 				},
 			},
-		})
+		},
+		&hookexecution.EmptyHookExecutor{})
 	assert.Nil(t, errs)
 	assert.Len(t, seatBids, 2)
 	sort.Slice(seatBids, func(i, j int) bool {
@@ -2705,7 +2714,8 @@ func TestExtraBidWithBidAdjustmentsUsingAdapterCode(t *testing.T) {
 					AllowedBidderCodes: []string{"groupm"},
 				},
 			},
-		})
+		},
+		&hookexecution.EmptyHookExecutor{})
 	assert.Nil(t, errs)
 	assert.Len(t, seatBids, 2)
 	sort.Slice(seatBids, func(i, j int) bool {
@@ -2828,7 +2838,8 @@ func TestExtraBidWithMultiCurrencies(t *testing.T) {
 					AllowedBidderCodes: []string{"groupm"},
 				},
 			},
-		})
+		},
+		&hookexecution.EmptyHookExecutor{})
 	assert.Nil(t, errs)
 	assert.Len(t, seatBids, 2)
 	sort.Slice(seatBids, func(i, j int) bool {
diff --git a/exchange/bidder_validate_bids_test.go b/exchange/bidder_validate_bids_test.go
index 2a8f5658..38cce3ee 100644
--- a/exchange/bidder_validate_bids_test.go
+++ b/exchange/bidder_validate_bids_test.go
@@ -8,6 +8,7 @@ import (
 	"github.com/prebid/prebid-server/adapters"
 	"github.com/prebid/prebid-server/currency"
 	"github.com/prebid/prebid-server/experiment/adscert"
+	"github.com/prebid/prebid-server/hooks/hookexecution"
 	"github.com/prebid/prebid-server/openrtb_ext"
 	"github.com/stretchr/testify/assert"
 )
@@ -63,7 +64,7 @@ func TestAllValidBids(t *testing.T) {
 		addCallSignHeader:   false,
 		bidAdjustments:      bidAdjustments,
 	}
-	seatBids, errs := bidder.requestBid(context.Background(), bidderReq, currency.NewConstantRates(), &adapters.ExtraRequestInfo{}, &adscert.NilSigner{}, bidReqOptions, openrtb_ext.ExtAlternateBidderCodes{})
+	seatBids, errs := bidder.requestBid(context.Background(), bidderReq, currency.NewConstantRates(), &adapters.ExtraRequestInfo{}, &adscert.NilSigner{}, bidReqOptions, openrtb_ext.ExtAlternateBidderCodes{}, &hookexecution.EmptyHookExecutor{})
 	assert.Len(t, seatBids, 1)
 	assert.Len(t, seatBids[0].bids, 4)
 	assert.Len(t, errs, 0)
@@ -133,7 +134,7 @@ func TestAllBadBids(t *testing.T) {
 		addCallSignHeader:   false,
 		bidAdjustments:      bidAdjustments,
 	}
-	seatBids, errs := bidder.requestBid(context.Background(), bidderReq, currency.NewConstantRates(), &adapters.ExtraRequestInfo{}, &adscert.NilSigner{}, bidReqOptions, openrtb_ext.ExtAlternateBidderCodes{})
+	seatBids, errs := bidder.requestBid(context.Background(), bidderReq, currency.NewConstantRates(), &adapters.ExtraRequestInfo{}, &adscert.NilSigner{}, bidReqOptions, openrtb_ext.ExtAlternateBidderCodes{}, &hookexecution.EmptyHookExecutor{})
 	assert.Len(t, seatBids, 1)
 	assert.Len(t, seatBids[0].bids, 0)
 	assert.Len(t, errs, 7)
@@ -214,7 +215,7 @@ func TestMixedBids(t *testing.T) {
 		addCallSignHeader:   false,
 		bidAdjustments:      bidAdjustments,
 	}
-	seatBids, errs := bidder.requestBid(context.Background(), bidderReq, currency.NewConstantRates(), &adapters.ExtraRequestInfo{}, &adscert.NilSigner{}, bidReqOptions, openrtb_ext.ExtAlternateBidderCodes{})
+	seatBids, errs := bidder.requestBid(context.Background(), bidderReq, currency.NewConstantRates(), &adapters.ExtraRequestInfo{}, &adscert.NilSigner{}, bidReqOptions, openrtb_ext.ExtAlternateBidderCodes{}, &hookexecution.EmptyHookExecutor{})
 	assert.Len(t, seatBids, 1)
 	assert.Len(t, seatBids[0].bids, 3)
 	assert.Len(t, errs, 5)
@@ -343,7 +344,7 @@ func TestCurrencyBids(t *testing.T) {
 			addCallSignHeader:   false,
 			bidAdjustments:      bidAdjustments,
 		}
-		seatBids, errs := bidder.requestBid(context.Background(), bidderRequest, currency.NewConstantRates(), &adapters.ExtraRequestInfo{}, &adscert.NilSigner{}, bidReqOptions, openrtb_ext.ExtAlternateBidderCodes{})
+		seatBids, errs := bidder.requestBid(context.Background(), bidderRequest, currency.NewConstantRates(), &adapters.ExtraRequestInfo{}, &adscert.NilSigner{}, bidReqOptions, openrtb_ext.ExtAlternateBidderCodes{}, &hookexecution.EmptyHookExecutor{})
 		assert.Len(t, seatBids, 1)
 		assert.Len(t, seatBids[0].bids, expectedValidBids)
 		assert.Len(t, errs, expectedErrs)
@@ -355,6 +356,6 @@ type mockAdaptedBidder struct {
 	errorResponse []error
 }
 
-func (b *mockAdaptedBidder) requestBid(ctx context.Context, bidderRequest BidderRequest, conversions currency.Conversions, reqInfo *adapters.ExtraRequestInfo, adsCertSigner adscert.Signer, bidRequestMetadata bidRequestOptions, alternateBidderCodes openrtb_ext.ExtAlternateBidderCodes) ([]*pbsOrtbSeatBid, []error) {
+func (b *mockAdaptedBidder) requestBid(ctx context.Context, bidderRequest BidderRequest, conversions currency.Conversions, reqInfo *adapters.ExtraRequestInfo, adsCertSigner adscert.Signer, bidRequestMetadata bidRequestOptions, alternateBidderCodes openrtb_ext.ExtAlternateBidderCodes, executor hookexecution.StageExecutor) ([]*pbsOrtbSeatBid, []error) {
 	return b.bidResponse, b.errorResponse
 }
diff --git a/exchange/exchange_test.go b/exchange/exchange_test.go
index a1c70ee3..5abc7f4d 100644
--- a/exchange/exchange_test.go
+++ b/exchange/exchange_test.go
@@ -20,7 +20,9 @@ import (
 
 	"github.com/buger/jsonparser"
 	"github.com/prebid/openrtb/v17/openrtb2"
+	"github.com/prebid/prebid-server/hooks"
 	"github.com/prebid/prebid-server/hooks/hookexecution"
+	"github.com/prebid/prebid-server/hooks/hookstage"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/mock"
 	jsonpatch "gopkg.in/evanphx/json-patch.v4"
@@ -4480,7 +4482,7 @@ type validatingBidder struct {
 	mockResponses map[string]bidderResponse
 }
 
-func (b *validatingBidder) requestBid(ctx context.Context, bidderRequest BidderRequest, conversions currency.Conversions, reqInfo *adapters.ExtraRequestInfo, adsCertSigner adscert.Signer, bidRequestOptions bidRequestOptions, alternateBidderCodes openrtb_ext.ExtAlternateBidderCodes) (seatBids []*pbsOrtbSeatBid, errs []error) {
+func (b *validatingBidder) requestBid(ctx context.Context, bidderRequest BidderRequest, conversions currency.Conversions, reqInfo *adapters.ExtraRequestInfo, adsCertSigner adscert.Signer, bidRequestOptions bidRequestOptions, alternateBidderCodes openrtb_ext.ExtAlternateBidderCodes, executor hookexecution.StageExecutor) (seatBids []*pbsOrtbSeatBid, errs []error) {
 	if expectedRequest, ok := b.expectations[string(bidderRequest.BidderName)]; ok {
 		if expectedRequest != nil {
 			if !reflect.DeepEqual(expectedRequest.BidAdjustments, bidRequestOptions.bidAdjustments) {
@@ -4643,7 +4645,7 @@ func (e *emptyUsersync) HasAnyLiveSyncs() bool {
 
 type panicingAdapter struct{}
 
-func (panicingAdapter) requestBid(ctx context.Context, bidderRequest BidderRequest, conversions currency.Conversions, reqInfo *adapters.ExtraRequestInfo, adsCertSigner adscert.Signer, bidRequestMetadata bidRequestOptions, alternateBidderCodes openrtb_ext.ExtAlternateBidderCodes) (posb []*pbsOrtbSeatBid, errs []error) {
+func (panicingAdapter) requestBid(ctx context.Context, bidderRequest BidderRequest, conversions currency.Conversions, reqInfo *adapters.ExtraRequestInfo, adsCertSigner adscert.Signer, bidRequestMetadata bidRequestOptions, alternateBidderCodes openrtb_ext.ExtAlternateBidderCodes, executor hookexecution.StageExecutor) (posb []*pbsOrtbSeatBid, errs []error) {
 	panic("Panic! Panic! The world is ending!")
 }
 
@@ -4713,3 +4715,128 @@ func getInfoFromImp(req *openrtb_ext.RequestWrapper) (json.RawMessage, string, e
 	}
 	return extPrebid.Passthrough, impID, nil
 }
+
+func TestModulesCanBeExecutedForMultipleBiddersSimultaneously(t *testing.T) {
+	noBidServer := func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(204)
+	}
+	server := httptest.NewServer(http.HandlerFunc(noBidServer))
+	defer server.Close()
+
+	bidderImpl := &goodSingleBidder{
+		httpRequest: &adapters.RequestData{
+			Method:  "POST",
+			Uri:     server.URL,
+			Body:    []byte(`{"key":"val"}`),
+			Headers: http.Header{},
+		},
+		bidResponse: &adapters.BidderResponse{},
+	}
+
+	e := new(exchange)
+	e.me = &metricsConf.NilMetricsEngine{}
+	e.tcf2ConfigBuilder = fakeTCF2ConfigBuilder{
+		cfg: gdpr.NewTCF2Config(config.TCF2{}, config.AccountGDPR{}),
+	}.Builder
+	e.currencyConverter = currency.NewRateConverter(&http.Client{}, "", time.Duration(0))
+
+	bidRequest := &openrtb2.BidRequest{
+		ID: "some-request-id",
+		Imp: []openrtb2.Imp{{
+			ID:     "some-impression-id",
+			Banner: &openrtb2.Banner{Format: []openrtb2.Format{{W: 300, H: 250}, {W: 300, H: 600}}},
+			Ext: json.RawMessage(
+				`{"prebid":{"bidder":{"telaria": {"placementId": 1}, "appnexus": {"placementid": 2}, "33across": {"placementId": 3}, "aax": {"placementid": 4}}}}`,
+			),
+		}},
+		Site:   &openrtb2.Site{Page: "prebid.org", Ext: json.RawMessage(`{"amp":0}`)},
+		Device: &openrtb2.Device{UA: "curl/7.54.0", IP: "::1"},
+		AT:     1,
+		TMax:   500,
+	}
+
+	exec := hookexecution.NewHookExecutor(TestApplyHookMutationsBuilder{}, "/openrtb2/auction", &metricsConfig.NilMetricsEngine{})
+
+	auctionRequest := AuctionRequest{
+		BidRequestWrapper: &openrtb_ext.RequestWrapper{BidRequest: bidRequest},
+		Account:           config.Account{DebugAllow: true},
+		UserSyncs:         &emptyUsersync{},
+		StartTime:         time.Now(),
+		HookExecutor:      exec,
+	}
+
+	e.adapterMap = map[openrtb_ext.BidderName]AdaptedBidder{
+		openrtb_ext.BidderAppnexus: AdaptBidder(bidderImpl, server.Client(), &config.Configuration{}, &metricsConfig.NilMetricsEngine{}, openrtb_ext.BidderAppnexus, &config.DebugInfo{}, ""),
+		openrtb_ext.BidderTelaria:  AdaptBidder(bidderImpl, server.Client(), &config.Configuration{}, &metricsConfig.NilMetricsEngine{}, openrtb_ext.BidderAppnexus, &config.DebugInfo{}, ""),
+		openrtb_ext.Bidder33Across: AdaptBidder(bidderImpl, server.Client(), &config.Configuration{}, &metricsConfig.NilMetricsEngine{}, openrtb_ext.Bidder33Across, &config.DebugInfo{}, ""),
+		openrtb_ext.BidderAax:      AdaptBidder(bidderImpl, server.Client(), &config.Configuration{}, &metricsConfig.NilMetricsEngine{}, openrtb_ext.BidderAax, &config.DebugInfo{}, ""),
+	}
+	// Run test
+	_, err := e.HoldAuction(context.Background(), auctionRequest, &DebugLog{})
+	// Assert no HoldAuction err
+	assert.NoErrorf(t, err, "ex.HoldAuction returned an err")
+
+	// check stage outcomes
+	assert.Equal(t, len(exec.GetOutcomes()), len(e.adapterMap), "stage outcomes append operation failed")
+	//check that all modules were applied and logged
+	for _, sto := range exec.GetOutcomes() {
+		assert.Equal(t, 2, len(sto.Groups), "not all groups were executed")
+		for _, group := range sto.Groups {
+			assert.Equal(t, 5, len(group.InvocationResults), "not all module hooks were applied")
+			for _, r := range group.InvocationResults {
+				assert.Equal(t, "success", string(r.Status), fmt.Sprintf("Module %s hook %s completed unsuccessfully", r.HookID.ModuleCode, r.HookID.HookImplCode))
+			}
+		}
+	}
+}
+
+type TestApplyHookMutationsBuilder struct {
+	hooks.EmptyPlanBuilder
+}
+
+func (e TestApplyHookMutationsBuilder) PlanForBidderRequestStage(_ string, _ *config.Account) hooks.Plan[hookstage.BidderRequest] {
+	return hooks.Plan[hookstage.BidderRequest]{
+		hooks.Group[hookstage.BidderRequest]{
+			Timeout: 100 * time.Millisecond,
+			Hooks: []hooks.HookWrapper[hookstage.BidderRequest]{
+				{Module: "foobar1", Code: "foo1", Hook: mockUpdateBidRequestHook{}},
+				{Module: "foobar2", Code: "foo2", Hook: mockUpdateBidRequestHook{}},
+				{Module: "foobar3", Code: "foo3", Hook: mockUpdateBidRequestHook{}},
+				{Module: "foobar4", Code: "foo4", Hook: mockUpdateBidRequestHook{}},
+				{Module: "foobar5", Code: "foo5", Hook: mockUpdateBidRequestHook{}},
+			},
+		},
+		hooks.Group[hookstage.BidderRequest]{
+			Timeout: 100 * time.Millisecond,
+			Hooks: []hooks.HookWrapper[hookstage.BidderRequest]{
+				{Module: "foobar6", Code: "foo6", Hook: mockUpdateBidRequestHook{}},
+				{Module: "foobar7", Code: "foo7", Hook: mockUpdateBidRequestHook{}},
+				{Module: "foobar8", Code: "foo8", Hook: mockUpdateBidRequestHook{}},
+				{Module: "foobar9", Code: "foo9", Hook: mockUpdateBidRequestHook{}},
+				{Module: "foobar10", Code: "foo10", Hook: mockUpdateBidRequestHook{}},
+			},
+		},
+	}
+}
+
+type mockUpdateBidRequestHook struct{}
+
+func (e mockUpdateBidRequestHook) HandleBidderRequestHook(_ context.Context, mctx hookstage.ModuleInvocationContext, _ hookstage.BidderRequestPayload) (hookstage.HookResult[hookstage.BidderRequestPayload], error) {
+	time.Sleep(50 * time.Millisecond)
+	c := &hookstage.ChangeSet[hookstage.BidderRequestPayload]{}
+	c.AddMutation(
+		func(payload hookstage.BidderRequestPayload) (hookstage.BidderRequestPayload, error) {
+			payload.BidRequest.Site.Name = "test"
+			return payload, nil
+		}, hookstage.MutationUpdate, "bidRequest", "site.name",
+	).AddMutation(
+		func(payload hookstage.BidderRequestPayload) (hookstage.BidderRequestPayload, error) {
+			payload.BidRequest.Site.Domain = "test.com"
+			return payload, nil
+		}, hookstage.MutationUpdate, "bidRequest", "site.domain",
+	)
+
+	mctx.ModuleContext = map[string]interface{}{"some-ctx": "some-ctx"}
+
+	return hookstage.HookResult[hookstage.BidderRequestPayload]{ChangeSet: c, ModuleContext: mctx.ModuleContext}, nil
+}
diff --git a/hooks/hookexecution/executor_test.go b/hooks/hookexecution/executor_test.go
index a60e320f..551dadd0 100644
--- a/hooks/hookexecution/executor_test.go
+++ b/hooks/hookexecution/executor_test.go
@@ -28,9 +28,13 @@ func TestEmptyHookExecutor(t *testing.T) {
 	req, err := http.NewRequest(http.MethodPost, "https://prebid.com/openrtb2/auction", reader)
 	assert.NoError(t, err, "Failed to create http request.")
 
+	bidderRequest := &openrtb2.BidRequest{ID: "some-id"}
+	expectedBidderRequest := &openrtb2.BidRequest{ID: "some-id"}
+
 	entrypointBody, entrypointRejectErr := executor.ExecuteEntrypointStage(req, body)
 	rawAuctionBody, rawAuctionRejectErr := executor.ExecuteRawAuctionStage(body)
 	processedAuctionRejectErr := executor.ExecuteProcessedAuctionStage(&openrtb2.BidRequest{})
+	bidderRequestRejectErr := executor.ExecuteBidderRequestStage(bidderRequest, "bidder-name")
 
 	outcomes := executor.GetOutcomes()
 	assert.Equal(t, EmptyHookExecutor{}, executor, "EmptyHookExecutor shouldn't be changed.")
@@ -43,6 +47,8 @@ func TestEmptyHookExecutor(t *testing.T) {
 	assert.Equal(t, body, rawAuctionBody, "EmptyHookExecutor shouldn't change body at raw-auction stage.")
 
 	assert.Nil(t, processedAuctionRejectErr, "EmptyHookExecutor shouldn't return reject error at processed-auction stage.")
+	assert.Nil(t, bidderRequestRejectErr, "EmptyHookExecutor shouldn't return reject error at bidder-request stage.")
+	assert.Equal(t, expectedBidderRequest, bidderRequest, "EmptyHookExecutor shouldn't change payload at bidder-request stage.")
 }
 
 func TestExecuteEntrypointStage(t *testing.T) {
@@ -920,6 +926,279 @@ func TestExecuteProcessedAuctionStage(t *testing.T) {
 	}
 }
 
+func TestExecuteBidderRequestStage(t *testing.T) {
+	bidderName := "the-bidder"
+	foobarModuleCtx := &moduleContexts{ctxs: map[string]hookstage.ModuleContext{"foobar": nil}}
+	account := &config.Account{}
+
+	expectedBidderRequest := &openrtb2.BidRequest{ID: "some-id", User: &openrtb2.User{ID: "user-id"}}
+	expectedUpdatedBidderRequest := &openrtb2.BidRequest{
+		ID: "some-id",
+		User: &openrtb2.User{
+			ID:      "user-id",
+			Yob:     2000,
+			Consent: "true",
+		},
+	}
+
+	testCases := []struct {
+		description            string
+		givenBidderRequest     *openrtb2.BidRequest
+		givenPlanBuilder       hooks.ExecutionPlanBuilder
+		givenAccount           *config.Account
+		expectedBidderRequest  *openrtb2.BidRequest
+		expectedReject         *RejectError
+		expectedModuleContexts *moduleContexts
+		expectedStageOutcomes  []StageOutcome
+	}{
+		{
+			description:            "Payload not changed if hook execution plan empty",
+			givenBidderRequest:     &openrtb2.BidRequest{ID: "some-id", User: &openrtb2.User{ID: "user-id"}},
+			givenPlanBuilder:       hooks.EmptyPlanBuilder{},
+			givenAccount:           account,
+			expectedBidderRequest:  expectedBidderRequest,
+			expectedReject:         nil,
+			expectedModuleContexts: &moduleContexts{ctxs: map[string]hookstage.ModuleContext{}},
+			expectedStageOutcomes:  []StageOutcome{},
+		},
+		{
+			description:            "Payload changed if hooks return mutations",
+			givenBidderRequest:     &openrtb2.BidRequest{ID: "some-id", User: &openrtb2.User{ID: "user-id"}},
+			givenPlanBuilder:       TestApplyHookMutationsBuilder{},
+			givenAccount:           account,
+			expectedBidderRequest:  expectedUpdatedBidderRequest,
+			expectedReject:         nil,
+			expectedModuleContexts: foobarModuleCtx,
+			expectedStageOutcomes: []StageOutcome{
+				{
+					Entity: entity(bidderName),
+					Stage:  hooks.StageBidderRequest.String(),
+					Groups: []GroupOutcome{
+						{
+							InvocationResults: []HookOutcome{
+								{
+									AnalyticsTags: hookanalytics.Analytics{},
+									HookID:        HookID{ModuleCode: "foobar", HookImplCode: "foo"},
+									Status:        StatusSuccess,
+									Action:        ActionUpdate,
+									Message:       "",
+									DebugMessages: []string{
+										fmt.Sprintf("Hook mutation successfully applied, affected key: bidRequest.user.yob, mutation type: %s", hookstage.MutationUpdate),
+										fmt.Sprintf("Hook mutation successfully applied, affected key: bidRequest.user.consent, mutation type: %s", hookstage.MutationUpdate),
+									},
+									Errors:   nil,
+									Warnings: nil,
+								},
+								{
+									AnalyticsTags: hookanalytics.Analytics{},
+									HookID:        HookID{ModuleCode: "foobar", HookImplCode: "bar"},
+									Status:        StatusExecutionFailure,
+									Action:        ActionUpdate,
+									Message:       "",
+									DebugMessages: nil,
+									Errors:        nil,
+									Warnings:      []string{"failed to apply hook mutation: key not found"},
+								},
+							},
+						},
+						{
+							InvocationResults: []HookOutcome{
+								{
+									AnalyticsTags: hookanalytics.Analytics{},
+									HookID:        HookID{ModuleCode: "foobar", HookImplCode: "baz"},
+									Status:        StatusFailure,
+									Action:        "",
+									Message:       "",
+									DebugMessages: nil,
+									Errors:        []string{"hook execution failed: attribute not found"},
+									Warnings:      nil,
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			description:            "Stage execution can be rejected - and later hooks rejected",
+			givenBidderRequest:     &openrtb2.BidRequest{ID: "some-id", User: &openrtb2.User{ID: "user-id"}},
+			givenPlanBuilder:       TestRejectPlanBuilder{},
+			givenAccount:           nil,
+			expectedBidderRequest:  expectedBidderRequest,
+			expectedReject:         &RejectError{0, HookID{ModuleCode: "foobar", HookImplCode: "foo"}, hooks.StageBidderRequest.String()},
+			expectedModuleContexts: foobarModuleCtx,
+			expectedStageOutcomes: []StageOutcome{
+				{
+					ExecutionTime: ExecutionTime{},
+					Entity:        entity(bidderName),
+					Stage:         hooks.StageBidderRequest.String(),
+					Groups: []GroupOutcome{
+						{
+							ExecutionTime: ExecutionTime{},
+							InvocationResults: []HookOutcome{
+								{
+									ExecutionTime: ExecutionTime{},
+									AnalyticsTags: hookanalytics.Analytics{},
+									HookID:        HookID{ModuleCode: "foobar", HookImplCode: "baz"},
+									Status:        StatusExecutionFailure,
+									Action:        "",
+									Message:       "",
+									DebugMessages: nil,
+									Errors:        []string{"unexpected error"},
+									Warnings:      nil,
+								},
+							},
+						},
+						{
+							ExecutionTime: ExecutionTime{},
+							InvocationResults: []HookOutcome{
+								{
+									ExecutionTime: ExecutionTime{},
+									AnalyticsTags: hookanalytics.Analytics{},
+									HookID:        HookID{ModuleCode: "foobar", HookImplCode: "foo"},
+									Status:        StatusSuccess,
+									Action:        ActionReject,
+									Message:       "",
+									DebugMessages: nil,
+									Errors: []string{
+										`Module foobar (hook: foo) rejected request with code 0 at bidder_request stage`,
+									},
+									Warnings: nil,
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			description:            "Stage execution can be timed out",
+			givenBidderRequest:     &openrtb2.BidRequest{ID: "some-id", User: &openrtb2.User{ID: "user-id"}},
+			givenPlanBuilder:       TestWithTimeoutPlanBuilder{},
+			givenAccount:           account,
+			expectedBidderRequest:  expectedUpdatedBidderRequest,
+			expectedReject:         nil,
+			expectedModuleContexts: foobarModuleCtx,
+			expectedStageOutcomes: []StageOutcome{
+				{
+					ExecutionTime: ExecutionTime{},
+					Entity:        entity(bidderName),
+					Stage:         hooks.StageBidderRequest.String(),
+					Groups: []GroupOutcome{
+						{
+							ExecutionTime: ExecutionTime{},
+							InvocationResults: []HookOutcome{
+								{
+									ExecutionTime: ExecutionTime{},
+									AnalyticsTags: hookanalytics.Analytics{},
+									HookID:        HookID{ModuleCode: "foobar", HookImplCode: "foo"},
+									Status:        StatusTimeout,
+									Action:        "",
+									Message:       "",
+									DebugMessages: nil,
+									Errors:        []string{"Hook execution timeout"},
+									Warnings:      nil,
+								},
+							},
+						},
+						{
+							ExecutionTime: ExecutionTime{},
+							InvocationResults: []HookOutcome{
+								{
+									AnalyticsTags: hookanalytics.Analytics{},
+									HookID:        HookID{ModuleCode: "foobar", HookImplCode: "bar"},
+									Status:        StatusSuccess,
+									Action:        ActionUpdate,
+									Message:       "",
+									DebugMessages: []string{
+										fmt.Sprintf("Hook mutation successfully applied, affected key: bidRequest.user.yob, mutation type: %s", hookstage.MutationUpdate),
+										fmt.Sprintf("Hook mutation successfully applied, affected key: bidRequest.user.consent, mutation type: %s", hookstage.MutationUpdate),
+									},
+									Errors:   nil,
+									Warnings: nil,
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			description:           "Modules contexts are preserved and correct",
+			givenBidderRequest:    &openrtb2.BidRequest{ID: "some-id", User: &openrtb2.User{ID: "user-id"}},
+			givenPlanBuilder:      TestWithModuleContextsPlanBuilder{},
+			givenAccount:          account,
+			expectedBidderRequest: expectedBidderRequest,
+			expectedReject:        nil,
+			expectedModuleContexts: &moduleContexts{ctxs: map[string]hookstage.ModuleContext{
+				"module-1": {"bidder-request-ctx-1": "some-ctx-1"},
+				"module-2": {"bidder-request-ctx-2": "some-ctx-2"},
+			}},
+			expectedStageOutcomes: []StageOutcome{
+				{
+					ExecutionTime: ExecutionTime{},
+					Entity:        entity(bidderName),
+					Stage:         hooks.StageBidderRequest.String(),
+					Groups: []GroupOutcome{
+						{
+							ExecutionTime: ExecutionTime{},
+							InvocationResults: []HookOutcome{
+								{
+									ExecutionTime: ExecutionTime{},
+									AnalyticsTags: hookanalytics.Analytics{},
+									HookID:        HookID{ModuleCode: "module-1", HookImplCode: "foo"},
+									Status:        StatusSuccess,
+									Action:        ActionNone,
+									Message:       "",
+									DebugMessages: nil,
+									Errors:        nil,
+									Warnings:      nil,
+								},
+							},
+						},
+						{
+							ExecutionTime: ExecutionTime{},
+							InvocationResults: []HookOutcome{
+								{
+									ExecutionTime: ExecutionTime{},
+									AnalyticsTags: hookanalytics.Analytics{},
+									HookID:        HookID{ModuleCode: "module-2", HookImplCode: "bar"},
+									Status:        StatusSuccess,
+									Action:        ActionNone,
+									Message:       "",
+									DebugMessages: nil,
+									Errors:        nil,
+									Warnings:      nil,
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+	}
+
+	for _, test := range testCases {
+		t.Run(test.description, func(t *testing.T) {
+			exec := NewHookExecutor(test.givenPlanBuilder, EndpointAuction, &metricsConfig.NilMetricsEngine{})
+			exec.SetAccount(test.givenAccount)
+
+			reject := exec.ExecuteBidderRequestStage(test.givenBidderRequest, bidderName)
+
+			assert.Equal(t, test.expectedReject, reject, "Unexpected stage reject.")
+			assert.Equal(t, test.expectedBidderRequest, test.givenBidderRequest, "Incorrect bidder request.")
+			assert.Equal(t, test.expectedModuleContexts, exec.moduleContexts, "Incorrect module contexts")
+
+			stageOutcomes := exec.GetOutcomes()
+			if len(test.expectedStageOutcomes) == 0 {
+				assert.Empty(t, stageOutcomes, "Incorrect stage outcomes.")
+			} else {
+				assertEqualStageOutcomes(t, test.expectedStageOutcomes[0], stageOutcomes[0])
+			}
+		})
+	}
+}
+
 func TestInterStageContextCommunication(t *testing.T) {
 	body := []byte(`{"foo": "bar"}`)
 	reader := bytes.NewReader(body)
@@ -1032,6 +1311,24 @@ func (e TestApplyHookMutationsBuilder) PlanForProcessedAuctionStage(_ string, _
 	}
 }
 
+func (e TestApplyHookMutationsBuilder) PlanForBidderRequestStage(_ string, _ *config.Account) hooks.Plan[hookstage.BidderRequest] {
+	return hooks.Plan[hookstage.BidderRequest]{
+		hooks.Group[hookstage.BidderRequest]{
+			Timeout: 1 * time.Millisecond,
+			Hooks: []hooks.HookWrapper[hookstage.BidderRequest]{
+				{Module: "foobar", Code: "foo", Hook: mockUpdateBidRequestHook{}},
+				{Module: "foobar", Code: "bar", Hook: mockFailedMutationHook{}},
+			},
+		},
+		hooks.Group[hookstage.BidderRequest]{
+			Timeout: 1 * time.Millisecond,
+			Hooks: []hooks.HookWrapper[hookstage.BidderRequest]{
+				{Module: "foobar", Code: "baz", Hook: mockFailureHook{}},
+			},
+		},
+	}
+}
+
 type TestRejectPlanBuilder struct {
 	hooks.EmptyPlanBuilder
 }
@@ -1112,6 +1409,29 @@ func (e TestRejectPlanBuilder) PlanForProcessedAuctionStage(_ string, _ *config.
 	}
 }
 
+func (e TestRejectPlanBuilder) PlanForBidderRequestStage(_ string, _ *config.Account) hooks.Plan[hookstage.BidderRequest] {
+	return hooks.Plan[hookstage.BidderRequest]{
+		hooks.Group[hookstage.BidderRequest]{
+			Timeout: 1 * time.Millisecond,
+			Hooks: []hooks.HookWrapper[hookstage.BidderRequest]{
+				{Module: "foobar", Code: "baz", Hook: mockErrorHook{}},
+			},
+		},
+		hooks.Group[hookstage.BidderRequest]{
+			Timeout: 1 * time.Millisecond,
+			Hooks: []hooks.HookWrapper[hookstage.BidderRequest]{
+				{Module: "foobar", Code: "foo", Hook: mockRejectHook{}},
+			},
+		},
+		hooks.Group[hookstage.BidderRequest]{
+			Timeout: 1 * time.Millisecond,
+			Hooks: []hooks.HookWrapper[hookstage.BidderRequest]{
+				{Module: "foobar", Code: "bar", Hook: mockUpdateBidRequestHook{}},
+			},
+		},
+	}
+}
+
 type TestWithTimeoutPlanBuilder struct {
 	hooks.EmptyPlanBuilder
 }
@@ -1168,6 +1488,23 @@ func (e TestWithTimeoutPlanBuilder) PlanForProcessedAuctionStage(_ string, _ *co
 	}
 }
 
+func (e TestWithTimeoutPlanBuilder) PlanForBidderRequestStage(_ string, _ *config.Account) hooks.Plan[hookstage.BidderRequest] {
+	return hooks.Plan[hookstage.BidderRequest]{
+		hooks.Group[hookstage.BidderRequest]{
+			Timeout: 1 * time.Millisecond,
+			Hooks: []hooks.HookWrapper[hookstage.BidderRequest]{
+				{Module: "foobar", Code: "foo", Hook: mockTimeoutHook{}},
+			},
+		},
+		hooks.Group[hookstage.BidderRequest]{
+			Timeout: 1 * time.Millisecond,
+			Hooks: []hooks.HookWrapper[hookstage.BidderRequest]{
+				{Module: "foobar", Code: "bar", Hook: mockUpdateBidRequestHook{}},
+			},
+		},
+	}
+}
+
 type TestWithModuleContextsPlanBuilder struct {
 	hooks.EmptyPlanBuilder
 }
@@ -1226,6 +1563,23 @@ func (e TestWithModuleContextsPlanBuilder) PlanForProcessedAuctionStage(_ string
 	}
 }
 
+func (e TestWithModuleContextsPlanBuilder) PlanForBidderRequestStage(_ string, _ *config.Account) hooks.Plan[hookstage.BidderRequest] {
+	return hooks.Plan[hookstage.BidderRequest]{
+		hooks.Group[hookstage.BidderRequest]{
+			Timeout: 1 * time.Millisecond,
+			Hooks: []hooks.HookWrapper[hookstage.BidderRequest]{
+				{Module: "module-1", Code: "foo", Hook: mockModuleContextHook{key: "bidder-request-ctx-1", val: "some-ctx-1"}},
+			},
+		},
+		hooks.Group[hookstage.BidderRequest]{
+			Timeout: 1 * time.Millisecond,
+			Hooks: []hooks.HookWrapper[hookstage.BidderRequest]{
+				{Module: "module-2", Code: "bar", Hook: mockModuleContextHook{key: "bidder-request-ctx-2", val: "some-ctx-2"}},
+			},
+		},
+	}
+}
+
 type TestAllHookResultsBuilder struct {
 	hooks.EmptyPlanBuilder
 }
diff --git a/hooks/hookexecution/mocks_test.go b/hooks/hookexecution/mocks_test.go
index a11063ef..7195f3ee 100644
--- a/hooks/hookexecution/mocks_test.go
+++ b/hooks/hookexecution/mocks_test.go
@@ -84,6 +84,10 @@ func (e mockRejectHook) HandleProcessedAuctionHook(_ context.Context, _ hookstag
 	return hookstage.HookResult[hookstage.ProcessedAuctionRequestPayload]{Reject: true}, nil
 }
 
+func (e mockRejectHook) HandleBidderRequestHook(_ context.Context, _ hookstage.ModuleInvocationContext, _ hookstage.BidderRequestPayload) (hookstage.HookResult[hookstage.BidderRequestPayload], error) {
+	return hookstage.HookResult[hookstage.BidderRequestPayload]{Reject: true}, nil
+}
+
 type mockTimeoutHook struct{}
 
 func (e mockTimeoutHook) HandleEntrypointHook(_ context.Context, _ hookstage.ModuleInvocationContext, _ hookstage.EntrypointPayload) (hookstage.HookResult[hookstage.EntrypointPayload], error) {
@@ -121,6 +125,17 @@ func (e mockTimeoutHook) HandleProcessedAuctionHook(_ context.Context, _ hooksta
 	return hookstage.HookResult[hookstage.ProcessedAuctionRequestPayload]{ChangeSet: c}, nil
 }
 
+func (e mockTimeoutHook) HandleBidderRequestHook(_ context.Context, _ hookstage.ModuleInvocationContext, _ hookstage.BidderRequestPayload) (hookstage.HookResult[hookstage.BidderRequestPayload], error) {
+	time.Sleep(2 * time.Millisecond)
+	c := &hookstage.ChangeSet[hookstage.BidderRequestPayload]{}
+	c.AddMutation(func(payload hookstage.BidderRequestPayload) (hookstage.BidderRequestPayload, error) {
+		payload.BidRequest.User.CustomData = "some-custom-data"
+		return payload, nil
+	}, hookstage.MutationUpdate, "bidRequest", "user.customData")
+
+	return hookstage.HookResult[hookstage.BidderRequestPayload]{ChangeSet: c}, nil
+}
+
 type mockModuleContextHook struct {
 	key, val string
 }
@@ -140,6 +155,11 @@ func (e mockModuleContextHook) HandleProcessedAuctionHook(_ context.Context, miC
 	return hookstage.HookResult[hookstage.ProcessedAuctionRequestPayload]{ModuleContext: miCtx.ModuleContext}, nil
 }
 
+func (e mockModuleContextHook) HandleBidderRequestHook(_ context.Context, miCtx hookstage.ModuleInvocationContext, _ hookstage.BidderRequestPayload) (hookstage.HookResult[hookstage.BidderRequestPayload], error) {
+	miCtx.ModuleContext = map[string]interface{}{e.key: e.val}
+	return hookstage.HookResult[hookstage.BidderRequestPayload]{ModuleContext: miCtx.ModuleContext}, nil
+}
+
 type mockFailureHook struct{}
 
 func (h mockFailureHook) HandleEntrypointHook(_ context.Context, _ hookstage.ModuleInvocationContext, _ hookstage.EntrypointPayload) (hookstage.HookResult[hookstage.EntrypointPayload], error) {
@@ -150,16 +170,24 @@ func (h mockFailureHook) HandleRawAuctionHook(_ context.Context, miCtx hookstage
 	return hookstage.HookResult[hookstage.RawAuctionRequestPayload]{}, FailureError{Message: "attribute not found"}
 }
 
+func (h mockFailureHook) HandleBidderRequestHook(_ context.Context, _ hookstage.ModuleInvocationContext, _ hookstage.BidderRequestPayload) (hookstage.HookResult[hookstage.BidderRequestPayload], error) {
+	return hookstage.HookResult[hookstage.BidderRequestPayload]{}, FailureError{Message: "attribute not found"}
+}
+
 type mockErrorHook struct{}
 
 func (h mockErrorHook) HandleEntrypointHook(_ context.Context, _ hookstage.ModuleInvocationContext, _ hookstage.EntrypointPayload) (hookstage.HookResult[hookstage.EntrypointPayload], error) {
 	return hookstage.HookResult[hookstage.EntrypointPayload]{}, errors.New("unexpected error")
 }
 
-func (h mockErrorHook) HandleRawAuctionHook(_ context.Context, miCtx hookstage.ModuleInvocationContext, _ hookstage.RawAuctionRequestPayload) (hookstage.HookResult[hookstage.RawAuctionRequestPayload], error) {
+func (h mockErrorHook) HandleRawAuctionHook(_ context.Context, _ hookstage.ModuleInvocationContext, _ hookstage.RawAuctionRequestPayload) (hookstage.HookResult[hookstage.RawAuctionRequestPayload], error) {
 	return hookstage.HookResult[hookstage.RawAuctionRequestPayload]{}, errors.New("unexpected error")
 }
 
+func (h mockErrorHook) HandleBidderRequestHook(_ context.Context, _ hookstage.ModuleInvocationContext, _ hookstage.BidderRequestPayload) (hookstage.HookResult[hookstage.BidderRequestPayload], error) {
+	return hookstage.HookResult[hookstage.BidderRequestPayload]{}, errors.New("unexpected error")
+}
+
 type mockFailedMutationHook struct{}
 
 func (h mockFailedMutationHook) HandleEntrypointHook(_ context.Context, _ hookstage.ModuleInvocationContext, _ hookstage.EntrypointPayload) (hookstage.HookResult[hookstage.EntrypointPayload], error) {
@@ -180,6 +208,15 @@ func (h mockFailedMutationHook) HandleRawAuctionHook(_ context.Context, miCtx ho
 	return hookstage.HookResult[hookstage.RawAuctionRequestPayload]{ChangeSet: changeSet}, nil
 }
 
+func (h mockFailedMutationHook) HandleBidderRequestHook(_ context.Context, _ hookstage.ModuleInvocationContext, _ hookstage.BidderRequestPayload) (hookstage.HookResult[hookstage.BidderRequestPayload], error) {
+	changeSet := &hookstage.ChangeSet[hookstage.BidderRequestPayload]{}
+	changeSet.AddMutation(func(payload hookstage.BidderRequestPayload) (hookstage.BidderRequestPayload, error) {
+		return payload, errors.New("key not found")
+	}, hookstage.MutationUpdate, "header", "foo")
+
+	return hookstage.HookResult[hookstage.BidderRequestPayload]{ChangeSet: changeSet}, nil
+}
+
 type mockUpdateBidRequestHook struct{}
 
 func (e mockUpdateBidRequestHook) HandleProcessedAuctionHook(_ context.Context, _ hookstage.ModuleInvocationContext, _ hookstage.ProcessedAuctionRequestPayload) (hookstage.HookResult[hookstage.ProcessedAuctionRequestPayload], error) {
@@ -198,3 +235,20 @@ func (e mockUpdateBidRequestHook) HandleProcessedAuctionHook(_ context.Context,
 
 	return hookstage.HookResult[hookstage.ProcessedAuctionRequestPayload]{ChangeSet: c}, nil
 }
+
+func (e mockUpdateBidRequestHook) HandleBidderRequestHook(_ context.Context, _ hookstage.ModuleInvocationContext, _ hookstage.BidderRequestPayload) (hookstage.HookResult[hookstage.BidderRequestPayload], error) {
+	c := &hookstage.ChangeSet[hookstage.BidderRequestPayload]{}
+	c.AddMutation(
+		func(payload hookstage.BidderRequestPayload) (hookstage.BidderRequestPayload, error) {
+			payload.BidRequest.User.Yob = 2000
+			return payload, nil
+		}, hookstage.MutationUpdate, "bidRequest", "user.yob",
+	).AddMutation(
+		func(payload hookstage.BidderRequestPayload) (hookstage.BidderRequestPayload, error) {
+			payload.BidRequest.User.Consent = "true"
+			return payload, nil
+		}, hookstage.MutationUpdate, "bidRequest", "user.consent",
+	)
+
+	return hookstage.HookResult[hookstage.BidderRequestPayload]{ChangeSet: c}, nil
+}