diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml new file mode 100644 index 00000000..012993c7 --- /dev/null +++ b/.github/workflows/integration-test.yml @@ -0,0 +1,265 @@ +name: MEV-Boost Integration Tests + +on: + pull_request: + branches: [develop] + push: + branches: [develop] + +env: + RBUILDER_VERSION: v1.2.6 + PLAYGROUND_TIMEOUT: 180s + TEST_TIMEOUT: 300s + +jobs: + integration-test: + name: "Integration Test (${{ matrix.fork }} fork, ${{ matrix.tx_type }} txs)" + runs-on: ubuntu-latest + timeout-minutes: 40 + strategy: + matrix: + include: + # test legacy transactions on forks + - fork: deneb + tx_type: legacy + contender_cmd: "spam --tps 5 -r http://localhost:8545 -p 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 --min-balance 0.5eth --tx-type legacy transfers" + - fork: electra + tx_type: legacy + contender_cmd: "spam --tps 5 -r http://localhost:8545 -p 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 --min-balance 0.5eth --tx-type legacy transfers" + # test blob transactions on forks + - fork: deneb + tx_type: blobs + contender_cmd: "spam --tps 5 -r http://localhost:8545 -p 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 blobs" + - fork: electra + tx_type: blobs + contender_cmd: "spam --tps 5 -r http://localhost:8545 -p 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 blobs" + fail-fast: false + + steps: + - name: Checkout mev-boost + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.24' + cache: true + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + with: + toolchain: stable + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + with: + version: nightly + + - name: Build MEV-Boost Docker image + run: | + docker build -t my-mev-boost:latest . + - name: Clone builder-playground + run: | + # Clean up any existing builder-playground directory + rm -rf builder-playground + git clone https://github.com/flashbots/builder-playground.git + cd builder-playground + go mod download + - name: Download rbuilder + run: | + cd /tmp + # Clean up any existing rbuilder directory + rm -rf rbuilder + wget https://github.com/flashbots/rbuilder/archive/refs/tags/${{ env.RBUILDER_VERSION }}.tar.gz + tar -xzf ${{ env.RBUILDER_VERSION }}.tar.gz + mv rbuilder-* rbuilder + - name: Cache Rust dependencies + uses: actions/cache@v3 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + /tmp/rbuilder/target/ + /tmp/contender/target/ + key: ${{ runner.os }}-rust-${{ env.RBUILDER_VERSION }}-${{ matrix.fork }}-${{ matrix.tx_type }}-${{ hashFiles('/tmp/rbuilder/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-rust-${{ env.RBUILDER_VERSION }}-${{ matrix.fork }}-${{ matrix.tx_type }}- + ${{ runner.os }}-rust-${{ env.RBUILDER_VERSION }}-${{ matrix.fork }}- + ${{ runner.os }}-rust-${{ env.RBUILDER_VERSION }}- + ${{ runner.os }}-rust- + - name: Start playground environment + run: | + cd builder-playground + + echo "starting playground with ${{ matrix.fork }} fork for ${{ matrix.tx_type }} transaction testing..." + if [ "${{ matrix.fork }}" = "electra" ]; then + FORK_FLAG="--latest-fork" + else + FORK_FLAG="" + fi + + timeout ${{ env.PLAYGROUND_TIMEOUT }} go run main.go cook l1 \ + --genesis-delay 15 \ + --log-level debug \ + --use-separate-mev-boost \ + --override mev-boost=my-mev-boost:latest \ + $FORK_FLAG & + + PLAYGROUND_PID=$! + echo "PLAYGROUND_PID=$PLAYGROUND_PID" >> $GITHUB_ENV + + # wait for beacon node + for i in {1..60}; do + if curl -s http://localhost:3500/eth/v1/beacon/headers/head > /dev/null 2>&1; then + echo "beacon node is ready" + break + fi + echo "waiting for beacon node... ($i/60)" + sleep 5 + done + + # wait for execution layer + for i in {1..60}; do + if curl -s -X POST -H "Content-Type: application/json" \ + --data '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}' \ + http://localhost:8545 > /dev/null 2>&1; then + echo "execution layer is ready" + break + fi + echo "waiting for execution layer... ($i/60)" + sleep 5 + done + + # wait for mev-boost + for i in {1..60}; do + if curl -s http://localhost:18550/eth/v1/builder/status > /dev/null 2>&1; then + echo "mev-boost is ready" + break + fi + echo "waiting for mev-boost... ($i/60)" + sleep 5 + done + + echo "all services are ready!" + - name: Build rbuilder + run: | + cd /tmp/rbuilder + + echo "Building rbuilder (this may take several minutes)..." + cargo build --release --bin rbuilder + + echo "rbuilder build completed" + - name: Start rbuilder + run: | + cd /tmp/rbuilder + + # update config-playground.toml with correct paths + sed -i "s|\$HOME|$HOME|g" config-playground.toml + # Update reth datadir and IPC paths to match playground structure + sed -i "s|reth_datadir = \".*\"|reth_datadir = \"${HOME}/.playground/devnet/volume-el-data\"|g" config-playground.toml + sed -i "s|el_node_ipc_path = \".*\"|el_node_ipc_path = \"${HOME}/.playground/devnet/volume-el-data/reth.ipc\"|g" config-playground.toml + + # start rbuilder + echo "Starting rbuilder with full logging (as root for db access)..." + sudo ./target/release/rbuilder run config-playground.toml 2>&1 | tee rbuilder.log & + RBUILDER_PID=$! + echo "RBUILDER_PID=$RBUILDER_PID" >> $GITHUB_ENV + echo "Started rbuilder with PID: $RBUILDER_PID" + + # wait for rbuilder to start process + echo "waiting for rbuilder." + sleep 15 + + echo "rbuilder started successfully" + + - name: Start contender + run: | + rm -rf /tmp/contender + git clone https://github.com/flashbots/contender.git /tmp/contender + cd /tmp/contender + + cargo build --release --bin contender + + echo "starting contender to spam ${{ matrix.tx_type }} transactions" + ./target/release/contender ${{ matrix.contender_cmd }} & + + CONTENDER_PID=$! + echo "CONTENDER_PID=$CONTENDER_PID" >> $GITHUB_ENV + echo "started contender with PID: $CONTENDER_PID" + + # check if contender started successfully + sleep 3 + if ps -p $CONTENDER_PID > /dev/null 2>&1; then + echo "contender process running" + else + echo "contender process died immediately" + # contender exited with error + fi + + # wait for transactions to start flowing + echo "waiting for transaction activity to begin" + for i in {1..30}; do + TX_COUNT=$(curl -s -X POST -H "Content-Type: application/json" \ + --data '{"jsonrpc":"2.0","method":"txpool_status","params":[],"id":1}' \ + http://localhost:8545 | jq -r '.result.pending // "0"' | sed 's/0x//' | xargs printf "%d") + + if [ "$TX_COUNT" -gt 0 ]; then + echo "transaction activity detected ($TX_COUNT pending transactions)" + break + fi + + echo "waiting for transaction activity... ($i/30)" + sleep 2 + done + + # Give rbuilder time to process transactions and create bids + echo "allowing rbuilder to process transactions and create bids..." + sleep 15 + + - name: Run mev-boost integration tests + run: | + echo "Running MEV-boost integration tests..." + + # set environment for tests + export MEV_BOOST_URL="http://localhost:18550" + export BEACON_NODE_URL="http://localhost:3500" + export RELAY_URL="http://localhost:5555" + export EXECUTION_URL=""http://localhost:8545"" + export TESTING_FORK="${{ matrix.fork }}" + export TESTING_TX_TYPE="${{ matrix.tx_type }}" + + echo "running mev-boost integration tests for ${{ matrix.fork }} fork with ${{ matrix.tx_type }} transactions..." + + # run the integration tests with timeout + timeout ${{ env.TEST_TIMEOUT }} go test -v ./server -run TestMEVBoostIntegration -timeout=10m + TEST_EXIT_CODE=$? + + if [ $TEST_EXIT_CODE -eq 0 ]; then + echo "integration tests passed!" + else + echo "integration tests failed with exit code $TEST_EXIT_CODE" + exit $TEST_EXIT_CODE + fi + - name: Cleanup + if: always() + run: | + echo "cleaning up processes" + + # kill all background processes + [ ! -z "$PLAYGROUND_PID" ] && kill $PLAYGROUND_PID 2>/dev/null || true + [ ! -z "$RBUILDER_PID" ] && kill $RBUILDER_PID 2>/dev/null || true + [ ! -z "$CONTENDER_PID" ] && kill $CONTENDER_PID 2>/dev/null || true + + # stop all docker containers + docker stop $(docker ps -q) 2>/dev/null || true + docker rm $(docker ps -aq) 2>/dev/null || true + + # kill any remaining processes + pkill -f "rbuilder" 2>/dev/null || true + pkill -f "playground" 2>/dev/null || true + pkill -f "contender" 2>/dev/null || true + + echo "cleanup completed" \ No newline at end of file diff --git a/Makefile b/Makefile index 5ddb87cc..51f9fd4b 100644 --- a/Makefile +++ b/Makefile @@ -32,11 +32,11 @@ build-testcli: .PHONY: test test: - CGO_ENABLED=0 go test ./... + CGO_ENABLED=0 go test -short ./... .PHONY: test-race test-race: - CGO_ENABLED=1 go test -race ./... + CGO_ENABLED=1 go test -race -short ./... .PHONY: lint lint: @@ -57,7 +57,7 @@ fmt: .PHONY: test-coverage test-coverage: - CGO_ENABLED=0 go test -v -covermode=atomic -coverprofile=coverage.out ./... + CGO_ENABLED=0 go test -v -short -covermode=atomic -coverprofile=coverage.out ./... go tool cover -func coverage.out .PHONY: cover @@ -84,4 +84,4 @@ docker-image: .PHONY: clean clean: - git clean -fdx + git clean -fdx \ No newline at end of file diff --git a/server/integration_test.go b/server/integration_test.go new file mode 100644 index 00000000..cb86302f --- /dev/null +++ b/server/integration_test.go @@ -0,0 +1,445 @@ +package server + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" +) + +var ( + MEVBoostURL = os.Getenv("MEV_BOOST_URL") + BeaconNodeURL = os.Getenv("BEACON_NODE_URL") + RelayURL = os.Getenv("RELAY_URL") + ExecutionURL = os.Getenv("EXECUTION_URL") + + RelaySecretKey = "0x5eae315483f028b5cdd5d1090ff0c7618b18737ea9bf3c35047189db22835c48" +) + +type ProposerPayloadDelivered struct { + Slot string `json:"slot"` + ParentHash string `json:"parent_hash"` + BlockHash string `json:"block_hash"` + BuilderPubkey string `json:"builder_pubkey"` + ProposerPubkey string `json:"proposer_pubkey"` + ProposerFeeRecipient string `json:"proposer_fee_recipient"` + GasLimit string `json:"gas_limit"` + GasUsed string `json:"gas_used"` + Value string `json:"value"` + BlockNumber string `json:"block_number"` + NumTx string `json:"num_tx"` +} + +type BlockHeaderResponse struct { + Root string `json:"root"` + Canonical bool `json:"canonical"` + Header *phase0.BeaconBlockHeader `json:"header"` +} + +type BeaconNodeClient struct { + baseURL string + client *http.Client +} + +func NewBeaconNodeClient(baseURL string) *BeaconNodeClient { + return &BeaconNodeClient{ + baseURL: baseURL, + client: &http.Client{Timeout: 10 * time.Second}, + } +} + +func (c *BeaconNodeClient) GetCurrentSlot() (phase0.Slot, error) { + resp, err := c.client.Get(c.baseURL + "/eth/v1/beacon/headers/head") + if err != nil { + return 0, err + } + defer resp.Body.Close() + + var result struct { + Data struct { + Header struct { + Message struct { + Slot string `json:"slot"` + } `json:"message"` + } `json:"header"` + } `json:"data"` + } + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return 0, err + } + + var slot phase0.Slot + if _, err := fmt.Sscanf(result.Data.Header.Message.Slot, "%d", &slot); err != nil { + return 0, err + } + + return slot, nil +} + +func (c *BeaconNodeClient) GetBlockHeader(slot phase0.Slot) (*BlockHeaderResponse, error) { + url := fmt.Sprintf("%s/eth/v1/beacon/headers/%d", c.baseURL, slot) + resp, err := c.client.Get(url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var result struct { + Data struct { + Root string `json:"root"` + Canonical bool `json:"canonical"` + Header struct { + Message *phase0.BeaconBlockHeader `json:"message"` + } `json:"header"` + } `json:"data"` + } + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + + return &BlockHeaderResponse{ + Root: result.Data.Root, + Canonical: result.Data.Canonical, + Header: result.Data.Header.Message, + }, nil +} + +func getScheduledValidatorForSlot(client *BeaconNodeClient, slot phase0.Slot) (string, error) { + epoch := slot / 32 + + // retrieve valdiator duties + url := fmt.Sprintf("%s/eth/v1/validator/duties/proposer/%d", client.baseURL, epoch) + resp, err := client.client.Get(url) + if err != nil { + return "", fmt.Errorf("failed to get proposer duties: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", errors.New("failed to request proposer duties") //nolint:err113 + } + + var result struct { + Data []struct { + Pubkey string `json:"pubkey"` + Slot string `json:"slot"` + } `json:"data"` + } + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", errors.New("failed to decode proposer duties") //nolint:err113 + } + + // check for the required slot + targetSlotStr := fmt.Sprintf("%d", slot) + for _, duty := range result.Data { + if duty.Slot == targetSlotStr { + return duty.Pubkey, nil + } + } + + return "", fmt.Errorf("no proposer found for slot %d", slot) //nolint:err113 +} + +type MEVBoostClient struct { + baseURL string + client *http.Client +} + +func NewMEVBoostClient(baseURL string) *MEVBoostClient { + return &MEVBoostClient{ + baseURL: baseURL, + client: &http.Client{Timeout: 10 * time.Second}, + } +} + +func (c *MEVBoostClient) CheckStatus(ctx context.Context) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/eth/v1/builder/status", nil) + if err != nil { + return err + } + + resp, err := c.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("status check failed with status: %d", resp.StatusCode) //nolint:err113 + } + + return nil +} + +// waitForMEVBoost waits for MEV-boost to be available +func waitForMEVBoost(t *testing.T, timeout time.Duration) { + t.Helper() + + client := NewMEVBoostClient(MEVBoostURL) + ctx, cancel := context.WithTimeout(t.Context(), timeout) + defer cancel() + + ticker := time.NewTicker(500 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + t.Fatalf("mev-boost not available at %s after %v", MEVBoostURL, timeout) + case <-ticker.C: + if err := client.CheckStatus(ctx); err == nil { + t.Logf("mev-boost is available at %s", MEVBoostURL) + return + } + } + } +} + +func TestMEVBoostIntegration(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + waitForMEVBoost(t, 10*time.Second) + + beaconClient := NewBeaconNodeClient(BeaconNodeURL) + httpClient := &http.Client{Timeout: 10 * time.Second} + + testingFork := os.Getenv("TESTING_FORK") + if testingFork == "" { + testingFork = "unknown" + } + testingTxType := os.Getenv("TESTING_TX_TYPE") + if testingTxType == "" { + testingTxType = "unknown" + } + + t.Logf("testing Fork: %s", testingFork) + t.Logf("services: Beacon (%s), MEV-boost (%s), Relay (%s)", BeaconNodeURL, MEVBoostURL, RelayURL) + + // check mev-boost status + t.Run("mev-boost status check", func(t *testing.T) { + resp, err := httpClient.Get(MEVBoostURL + "/eth/v1/builder/status") + require.NoError(t, err) + resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + }) + + // validate chain activity + t.Run("validate chain activity", func(t *testing.T) { + // should be delivering payloads since its via mev-boost we can directly check the relay api + resp, err := httpClient.Get(RelayURL + "/relay/v1/data/bidtraces/proposer_payload_delivered") + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + + var payloads []map[string]any + require.NoError(t, json.NewDecoder(resp.Body).Decode(&payloads)) + // since payloads are being requested via mev-boost their length being greater shows its working + require.NotEmpty(t, payloads) + + // for blob transaction testing, check if recent blocks contain blob transactions + if testingTxType == "blobs" { //nolint:nestif + blobTxFound := false + totalBlobGasUsed := uint64(0) + + latestBlockResp, err := http.Post(ExecutionURL, "application/json", + strings.NewReader(`{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}`)) + require.NoError(t, err) + defer latestBlockResp.Body.Close() + + var latestBlockResult struct { + Result string `json:"result"` + } + require.NoError(t, json.NewDecoder(latestBlockResp.Body).Decode(&latestBlockResult)) + + latestBlockNum, err := strconv.ParseUint(strings.TrimPrefix(latestBlockResult.Result, "0x"), 16, 64) + require.NoError(t, err) + + // check the last 15 blocks for blob transactions + for i := uint64(0); i < 15; i++ { + if latestBlockNum < i { + continue + } + + blockNumber := fmt.Sprintf("0x%x", latestBlockNum-i) + blockResp, err := http.Post(ExecutionURL, "application/json", + strings.NewReader(fmt.Sprintf(`{"jsonrpc":"2.0","method":"eth_getBlockByNumber","params":["%s",true],"id":1}`, blockNumber))) + if err != nil { + continue + } + defer blockResp.Body.Close() + + var blockResult struct { + Result struct { + Number string `json:"number"` + BlobGasUsed string `json:"blobGasUsed"` + ExcessBlobGas string `json:"excessBlobGas"` + Transactions []map[string]interface{} `json:"transactions"` + } `json:"result"` + } + + if err := json.NewDecoder(blockResp.Body).Decode(&blockResult); err != nil { + continue + } + + // we could also have check for txtypes but that would have been traversering through + // alot of them so for simplicity we can make sure of BlobGasUsed to check for block txs + if blockResult.Result.BlobGasUsed != "" && blockResult.Result.BlobGasUsed != "0x0" { + blobGasUsed, _ := strconv.ParseUint(strings.TrimPrefix(blockResult.Result.BlobGasUsed, "0x"), 16, 64) + if blobGasUsed > 0 { + blobTxFound = true + totalBlobGasUsed += blobGasUsed + t.Logf("found blob transactions in block %s: %d blob gas used", blockResult.Result.Number, blobGasUsed) + + // Count blob transactions in this block + blobTxCount := 0 + for _, tx := range blockResult.Result.Transactions { + if txType, exists := tx["type"]; exists && txType == "0x3" { + blobTxCount++ + } + } + require.Positive(t, blobTxCount) + } + } + } + + require.True(t, blobTxFound) + + if blobTxFound { + t.Logf("successfully detected blob transactions, total blob gas: %d", totalBlobGasUsed) + } else { + t.Logf("no blob transactions found") + } + } + }) + + // validate mev-boost is consistently building all blocks + t.Run("mev-boost consistent block building", func(t *testing.T) { + currentSlot, err := beaconClient.GetCurrentSlot() + require.NoError(t, err, "Should be able to get current slot") + + mevBoostBlocks := 0 + totalBlocks := 0 + + for i := 1; i <= 5; i++ { + slotToCheck := currentSlot - phase0.Slot(i) + if slotToCheck <= 0 { + continue + } + + totalBlocks++ + + // payload for this block must have been delivered by the relay + resp, err := httpClient.Get(fmt.Sprintf("%s/relay/v1/data/bidtraces/proposer_payload_delivered?slot=%d", RelayURL, slotToCheck)) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusOK, resp.StatusCode) + var deliveries []map[string]any + require.NoError(t, json.NewDecoder(resp.Body).Decode(&deliveries)) + t.Logf("deliveries %d: length", len(deliveries)) + require.NotEmpty(t, deliveries) + + mevBoostBlocks++ + } + + require.Positive(t, totalBlocks) + require.Equal(t, mevBoostBlocks, totalBlocks) + }) + + t.Run("request header on invalid parent hash and pubkey", func(t *testing.T) { + // invalid pubkey + resp, err := httpClient.Get(MEVBoostURL + "/eth/v1/builder/header/1/0x0000000000000000000000000000000000000000000000000000000000000000/0x000000") + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + + // invalid parent hash + resp, err = httpClient.Get(MEVBoostURL + "/eth/v1/builder/header/1/0x0000/0x000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000") + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("bid retrieval", func(t *testing.T) { + // payload for this block must have been delivered by the relay + resp, err := httpClient.Get(fmt.Sprintf("%s/relay/v1/data/bidtraces/proposer_payload_delivered", RelayURL)) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusOK, resp.StatusCode) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + var payloads []ProposerPayloadDelivered + err = json.Unmarshal(body, &payloads) + require.NoError(t, err) + + require.NotEmpty(t, payloads) + + currentSlot, _ := beaconClient.GetCurrentSlot() + nextSlot := currentSlot + 1 + nextValidator, err := getScheduledValidatorForSlot(beaconClient, nextSlot) + require.NoError(t, err) + currentBlockData, err := beaconClient.GetBlockHeader(currentSlot) + require.NoError(t, err) + + nextParentHash := currentBlockData.Root + url := fmt.Sprintf("%s/eth/v1/builder/header/%d/%s/%s", + MEVBoostURL, nextSlot, nextParentHash, nextValidator) + + resp, err = httpClient.Get(url) + require.NoError(t, err) + + defer resp.Body.Close() + require.True(t, http.StatusOK == resp.StatusCode || http.StatusNoContent == resp.StatusCode) + }) + + // testing concurrent calls + t.Run("mev-boost performance", func(t *testing.T) { + concurrentRequests := 5 + var wg sync.WaitGroup + errors := make(chan error, concurrentRequests) + + for i := 0; i < concurrentRequests; i++ { + wg.Add(1) + go func() { + defer wg.Done() + resp, err := httpClient.Get(MEVBoostURL + "/eth/v1/builder/status") + if err != nil { + errors <- err + return + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + errors <- fmt.Errorf("unexpected status code: %d", resp.StatusCode) //nolint:err113 + } + }() + } + + wg.Wait() + close(errors) + + errorCount := 0 + for err := range errors { + errorCount++ + t.Logf("concurrent request error: %v", err) + } + + require.Equal(t, 0, errorCount) + }) +}