You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@solr.apache.org by ge...@apache.org on 2024/02/26 15:45:55 UTC

(solr) branch main updated: SOLR-17066: Switch "LB" clients away from core URLs (#2283)

This is an automated email from the ASF dual-hosted git repository.

gerlowskija pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/solr.git


The following commit(s) were added to refs/heads/main by this push:
     new c5709bd4135 SOLR-17066: Switch "LB" clients away from core URLs (#2283)
c5709bd4135 is described below

commit c5709bd4135538fffba3b2c4947dd91ff28d5750
Author: Jason Gerlowski <ge...@apache.org>
AuthorDate: Mon Feb 26 10:45:49 2024 -0500

    SOLR-17066: Switch "LB" clients away from core URLs (#2283)
    
    Providing a core URL String as a SolrClient's "base URL" prevents
    it from communicating with other cores or making core-agnostic API
    requests (e.g. node healthcheck, list cores, etc.)
    
    This commit migrates usage of both "LB" clients away from using
    raw core-URL Strings, in favor of using a new structured 'Endpoint'
    class, which allows access to both the "root URL" and "collection"
    separately.
    
    This commit also updates various ref-guides and Javadoc snippets to
    reflect the fact that clients now only accept "root URLs" (with a small
    exception for LB clients which may use the Endpoint class, as mentioned
    above).
---
 solr/CHANGES.txt                                   |   5 +
 .../handler/component/HttpShardHandlerFactory.java |   6 +-
 .../org/apache/solr/cloud/SSLMigrationTest.java    |   2 +-
 .../component/DistributedDebugComponentTest.java   |   2 +-
 .../component/TestHttpShardHandlerFactory.java     |   2 +-
 .../solr/update/DeleteByIdWithRouterFieldTest.java |  17 +-
 .../modules/deployment-guide/pages/solrj.adoc      |  63 ++---
 .../pages/major-changes-in-solr-10.adoc            |   7 +-
 .../solr/client/solrj/impl/CloudSolrClient.java    |  48 ++--
 .../impl/ConcurrentUpdateHttp2SolrClient.java      |  38 +++
 .../solrj/impl/ConcurrentUpdateSolrClient.java     |  24 +-
 .../solr/client/solrj/impl/Http2SolrClient.java    |  15 +
 .../solr/client/solrj/impl/HttpSolrClient.java     |  51 +---
 .../solr/client/solrj/impl/LBHttp2SolrClient.java  |  74 +++--
 .../solr/client/solrj/impl/LBHttpSolrClient.java   | 201 +++++++------
 .../solr/client/solrj/impl/LBSolrClient.java       | 312 ++++++++++++++-------
 .../solr/client/solrj/request/UpdateRequest.java   |  15 +-
 .../solr/client/solrj/TestLBHttp2SolrClient.java   |  45 ++-
 .../solr/client/solrj/TestLBHttpSolrClient.java    |  21 +-
 .../solrj/impl/CloudHttp2SolrClientTest.java       |   4 +-
 .../solrj/impl/CloudSolrClientCacheTest.java       |   2 +-
 .../client/solrj/impl/CloudSolrClientTest.java     |   4 +-
 .../solrj/impl/HttpSolrClientConPoolTest.java      |   7 +-
 .../client/solrj/impl/LBHttp2SolrClientTest.java   |   3 +-
 .../solrj/impl/LBHttpSolrClientBadInputTest.java   |   5 +-
 .../solrj/impl/LBHttpSolrClientBuilderTest.java    |  10 +-
 .../client/solrj/impl/LBHttpSolrClientTest.java    |   6 +-
 .../solr/client/solrj/impl/LBSolrClientTest.java   | 134 ++++++---
 28 files changed, 684 insertions(+), 439 deletions(-)

diff --git a/solr/CHANGES.txt b/solr/CHANGES.txt
index 24299620c5a..80864bb77e7 100644
--- a/solr/CHANGES.txt
+++ b/solr/CHANGES.txt
@@ -52,6 +52,11 @@ Deprecation Removals
 
 * SOLR-17159: Remove deprecated bin/post and bin/postlogs scripts in favour of bin/solr equivalents. (Eric Pugh)
 
+* SOLR-17066: `SolrClient` implementations that rely on "base URL" strings now only accept "root" URL paths (i.e. URLs that
+  end in "/solr").  Users may still specify a default collection through use of the `withDefaultCollection` method available
+  on most SolrClient builders. (Jason Gerlowski, David Smiley, Eric Pugh)
+
+
 Dependency Upgrades
 ---------------------
 (No changes)
diff --git a/solr/core/src/java/org/apache/solr/handler/component/HttpShardHandlerFactory.java b/solr/core/src/java/org/apache/solr/handler/component/HttpShardHandlerFactory.java
index 5b227359701..c07b7bcac90 100644
--- a/solr/core/src/java/org/apache/solr/handler/component/HttpShardHandlerFactory.java
+++ b/solr/core/src/java/org/apache/solr/handler/component/HttpShardHandlerFactory.java
@@ -28,6 +28,7 @@ import java.util.concurrent.BlockingQueue;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.SynchronousQueue;
 import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
 import org.apache.solr.client.solrj.SolrClient;
 import org.apache.solr.client.solrj.impl.Http2SolrClient;
 import org.apache.solr.client.solrj.impl.HttpClientUtil;
@@ -347,7 +348,10 @@ public class HttpShardHandlerFactory extends ShardHandlerFactory
     if (numServersToTry < this.permittedLoadBalancerRequestsMinimumAbsolute) {
       numServersToTry = this.permittedLoadBalancerRequestsMinimumAbsolute;
     }
-    return new LBSolrClient.Req(req, urls, numServersToTry);
+
+    final var endpoints =
+        urls.stream().map(url -> LBSolrClient.Endpoint.from(url)).collect(Collectors.toList());
+    return new LBSolrClient.Req(req, endpoints, numServersToTry);
   }
 
   /**
diff --git a/solr/core/src/test/org/apache/solr/cloud/SSLMigrationTest.java b/solr/core/src/test/org/apache/solr/cloud/SSLMigrationTest.java
index 327561b14ff..d510f024c6a 100644
--- a/solr/core/src/test/org/apache/solr/cloud/SSLMigrationTest.java
+++ b/solr/core/src/test/org/apache/solr/cloud/SSLMigrationTest.java
@@ -127,7 +127,7 @@ public class SSLMigrationTest extends AbstractFullDistribZkTestBase {
             .map(r -> r.getStr(ZkStateReader.BASE_URL_PROP))
             .toArray(String[]::new);
     // Create new SolrServer to configure new HttpClient w/ SSL config
-    try (SolrClient client = new LBHttpSolrClient.Builder().withBaseSolrUrls(urls).build()) {
+    try (SolrClient client = new LBHttpSolrClient.Builder().withBaseEndpoints(urls).build()) {
       client.request(request);
     }
   }
diff --git a/solr/core/src/test/org/apache/solr/handler/component/DistributedDebugComponentTest.java b/solr/core/src/test/org/apache/solr/handler/component/DistributedDebugComponentTest.java
index 5027f975c33..bbbc5ad81e5 100644
--- a/solr/core/src/test/org/apache/solr/handler/component/DistributedDebugComponentTest.java
+++ b/solr/core/src/test/org/apache/solr/handler/component/DistributedDebugComponentTest.java
@@ -403,7 +403,7 @@ public class DistributedDebugComponentTest extends SolrJettyTestBase {
   }
 
   public void testTolerantSearch() throws SolrServerException, IOException {
-    String badShard = DEAD_HOST_1;
+    String badShard = DEAD_HOST_1 + "/solr/collection1";
     SolrQuery query = new SolrQuery();
     query.setQuery("*:*");
     query.set("debug", "true");
diff --git a/solr/core/src/test/org/apache/solr/handler/component/TestHttpShardHandlerFactory.java b/solr/core/src/test/org/apache/solr/handler/component/TestHttpShardHandlerFactory.java
index 6c20bdcdf08..5b162b33ef7 100644
--- a/solr/core/src/test/org/apache/solr/handler/component/TestHttpShardHandlerFactory.java
+++ b/solr/core/src/test/org/apache/solr/handler/component/TestHttpShardHandlerFactory.java
@@ -93,7 +93,7 @@ public class TestHttpShardHandlerFactory extends SolrTestCaseJ4 {
       final QueryRequest queryRequest = null;
       final List<String> urls = new ArrayList<>();
       for (int ii = 0; ii < 10; ++ii) {
-        urls.add(null);
+        urls.add("http://localhost" + ii + ":8983/solr");
       }
 
       // create LBHttpSolrClient request
diff --git a/solr/core/src/test/org/apache/solr/update/DeleteByIdWithRouterFieldTest.java b/solr/core/src/test/org/apache/solr/update/DeleteByIdWithRouterFieldTest.java
index 5e818dd045a..6910aed10ce 100644
--- a/solr/core/src/test/org/apache/solr/update/DeleteByIdWithRouterFieldTest.java
+++ b/solr/core/src/test/org/apache/solr/update/DeleteByIdWithRouterFieldTest.java
@@ -303,7 +303,9 @@ public class DeleteByIdWithRouterFieldTest extends SolrCloudTestCase {
     final Map<String, List<String>> urlMap =
         docCol.getActiveSlices().stream()
             .collect(
-                Collectors.toMap(s -> s.getName(), s -> Collections.singletonList(s.getName())));
+                Collectors.toMap(
+                    s -> s.getName(),
+                    s -> Collections.singletonList(fakeSolrUrlForShard(s.getName()))));
 
     // simplified rote info we'll build up with the shards for each delete (after sanity checking
     // they have routing info at all)...
@@ -314,7 +316,7 @@ public class DeleteByIdWithRouterFieldTest extends SolrCloudTestCase {
             .getRoutesToCollection(docCol.getRouter(), docCol, urlMap, params(), ROUTE_FIELD);
     for (LBSolrClient.Req lbreq : rawDelRoutes.values()) {
       assertTrue(lbreq.getRequest() instanceof UpdateRequest);
-      final String shard = lbreq.getServers().get(0);
+      final LBSolrClient.Endpoint shard = lbreq.getEndpoints().get(0);
       final UpdateRequest req = (UpdateRequest) lbreq.getRequest();
       for (Map.Entry<String, Map<String, Object>> entry : req.getDeleteByIdMap().entrySet()) {
         final String id = entry.getKey();
@@ -327,7 +329,7 @@ public class DeleteByIdWithRouterFieldTest extends SolrCloudTestCase {
             RVAL_PRE + id.substring(id.length() - 1),
             route.toString());
 
-        actualDelRoutes.put(id, shard);
+        actualDelRoutes.put(id, shard.toString());
       }
     }
 
@@ -342,11 +344,18 @@ public class DeleteByIdWithRouterFieldTest extends SolrCloudTestCase {
               .getRouter()
               .getTargetSlice(id, doc, doc.getFieldValue(ROUTE_FIELD).toString(), params(), docCol);
       assertNotNull(id + " add route is null?", expectedShard);
-      assertEquals("Wrong shard for delete of id: " + id, expectedShard.getName(), actualShard);
+      assertEquals(
+          "Wrong shard for delete of id: " + id,
+          fakeSolrUrlForShard(expectedShard.getName()),
+          actualShard.toString());
     }
 
     // sanity check no one broke our test and made it a waste of time
     assertEquals(100, add100Docs().getDocuments().size());
     assertEquals(100, actualDelRoutes.entrySet().size());
   }
+
+  private static String fakeSolrUrlForShard(String shardName) {
+    return "http://localhost:8983/solr/" + shardName;
+  }
 }
diff --git a/solr/solr-ref-guide/modules/deployment-guide/pages/solrj.adoc b/solr/solr-ref-guide/modules/deployment-guide/pages/solrj.adoc
index 64156f54af8..bda99df4304 100644
--- a/solr/solr-ref-guide/modules/deployment-guide/pages/solrj.adoc
+++ b/solr/solr-ref-guide/modules/deployment-guide/pages/solrj.adoc
@@ -117,51 +117,48 @@ The most common/important of these are discussed below.
 For comprehensive information on how to tweak your `SolrClient`, see the Javadocs for the involved client, and its corresponding builder object.
 
 ==== Base URLs
-Most `SolrClient` implementations (except for `CloudSolrClient` and `Http2SolrClient`) require users to specify one or more Solr base URLs, which the client then uses to send HTTP requests to Solr.
-The path users include on the base URL they provide has an effect on the behavior of the created client from that point on.
-
-. A URL with a path pointing to a specific core or collection (e.g., `\http://hostname:8983/solr/core1`).
-When a core or collection is specified in the base URL, subsequent requests made with that client are not required to re-specify the affected collection.
-However, the client is limited to sending requests to  that core/collection, and can not send requests to any others.
-. A URL pointing to the root Solr path (e.g., `\http://hostname:8983/solr`).
-When no core or collection is specified in the base URL, requests can be made to any core/collection, but the affected core/collection must be specified on all requests.
-
-Generally speaking, if your `SolrClient` will only be used on a single core/collection, including that entity in the path is the most convenient.
-Where more flexibility is required, the collection/core should be excluded.
-
-==== Base URLs of Http2SolrClient
-The `Http2SolrClient` manages connections to different nodes efficiently.
-`Http2SolrClient` does not require a `baseUrl`.
-In case a `baseUrl` is not provided, then `SolrRequest.basePath` must be set, so
-`Http2SolrClient` knows which nodes to send requests to.
-If not an `IllegalArgumentException` will be thrown.
-
-==== Base URLs of CloudSolrClient
-
-It is also possible to specify base URLs for `CloudSolrClient`, but URLs are expected to point to the root Solr path (e.g., `\http://hostname:8983/solr`).
-They should not include any collections, cores, or other path components.
-
+Many `SolrClient` implementations require users to specify one or more Solr URLs, which the client then uses to send HTTP requests to Solr.
+Unless otherwise specified, SolrJ expects these URLs to point to the root Solr path (i.e. "/solr").
+
+A few notable exceptions to this are described below:
+
+- *Http2SolrClient* - Users of `Http2SolrClient` may choose to skip providing a root URL to their client, in favor of specifing the URL on a request-by-request basis using `SolrRequest.setBasePath`.
+`Http2SolrClient` will throw an `IllegalArgumentException` if neither the client nor the request specify a URL.
+- *LBHttpSolrClient* and *LBHttp2SolrClient* - Solr's "load balancing" clients are frequently used to round-robin requests across a set of replicas or cores.
+URLs are still expected to point to the Solr root (i.e. "/solr"), but to support this use-case the URLs are often supplemented by an additional parameter to specify the targeted core.
+Alternatively, some "load balancing" methods make use of an `Endpoint` abstraction to provide this URL and core information in a more structured way.
+- *CloudSolrClient* - Like many clients, CloudSolrClient accepts a series of URLs pointing to the Solr root path (i.e. "/solr").
++
 [source,java,indent=0]
 ----
 include::example$UsingSolrJRefGuideExamplesTest.java[tag=solrj-cloudsolrclient-baseurl]
 ----
-
-In case a `baseUrl` is not provided, then a list of ZooKeeper hosts (with ports) and ZooKeeper root must be provided.
-If no ZooKeeper root is used then `java.util.Optional.empty()` has to be provided as part of the method.
-
++
+However, unlike other clients, these URLs aren't used to send user-provided requests, but instead serve to fetch information about the layout and health of the Solr cluster.
+If Solr URLs are not known, users may instead specify a list of ZooKeeper hosts (with ports) and ZooKeeper root path, for the `CloudSolrClient` to fetch this information from ZooKeeper directly.
++
 [source,java,indent=0]
 ----
-include::example$UsingSolrJRefGuideExamplesTest.java[tag=solrj-cloudsolrclient-zookeepernoroot]
+include::example$UsingSolrJRefGuideExamplesTest.java[tag=solrj-cloudsolrclient-zookeeperroot]
 ----
-
++
+If no ZooKeeper root path is used then `java.util.Optional.empty()` should be provided.
++
 [source,java,indent=0]
 ----
-include::example$UsingSolrJRefGuideExamplesTest.java[tag=solrj-cloudsolrclient-zookeeperroot]
+include::example$UsingSolrJRefGuideExamplesTest.java[tag=solrj-cloudsolrclient-zookeepernoroot]
 ----
++
+`CloudSolrClient` users who wish to provide ZooKeeper connection information, must depend on the `solr-solrj-zookeeper` artifact to have all of the necessary classes available.
+The ZooKeeper based connection is the most reliable and performant means for CloudSolrClient to work.  On the other hand, it means exposing ZooKeeper more broadly than to Solr nodes, which is a security risk.  It also adds more JAR dependencies.
 
-Additionally, you will need to depend on the `solr-solrj-zookeeper` artifact or else you will get a `ClassNotFoundException`.
+==== Default Collections
 
-The ZooKeeper based connection is the most reliable and performant means for CloudSolrClient to work.  On the other hand, it means exposing ZooKeeper more broadly than to Solr nodes, which is a security risk.  It also adds more JAR dependencies.
+Most `SolrClient` methods allow users to specify the core or collection they wish to query, etc. as a `String` parameter.
+However continually specifying this parameter can become tedious, especially for users who always work with the same collection.
+
+Users can avoid this pattern by specifying a "default" collection when creating their client, using the `withDefaultCollection(String)` method available on the relevant `SolrClient` Builder object.
+If specified on a Builder, the created `SolrClient` will use this default for making requests whenever a collection or core is needed (and no overriding value is specified).
 
 ==== Timeouts
 All `SolrClient` implementations allow users to specify the connection and read timeouts for communicating with Solr.
diff --git a/solr/solr-ref-guide/modules/upgrade-notes/pages/major-changes-in-solr-10.adoc b/solr/solr-ref-guide/modules/upgrade-notes/pages/major-changes-in-solr-10.adoc
index e407ab04f64..a48f1e72892 100644
--- a/solr/solr-ref-guide/modules/upgrade-notes/pages/major-changes-in-solr-10.adoc
+++ b/solr/solr-ref-guide/modules/upgrade-notes/pages/major-changes-in-solr-10.adoc
@@ -28,7 +28,10 @@ Before starting an upgrade to this version of Solr, please take the time to revi
 
 === SolrJ
 
-Starting in 10, the Maven POM for SolrJ does not refer to SolrJ modules like ZooKeeper.  If you require such functionality, you need to add additional dependencies.
+* Starting in 10, the Maven POM for SolrJ does not refer to SolrJ modules like ZooKeeper.  If you require such functionality, you need to add additional dependencies.
+
+* `SolrClient` implementations that rely on "base URL" strings now only accept "root" URL paths (i.e. URLs that end in "/solr").
+Users who previously relied on collection-specific URLs to avoid including the collection name with each request can instead achieve this by specifying a "default collection" using the `withDefaultCollection` method available on most `SolrClient` Builders.
 
 === Deprecation removals
 
@@ -47,4 +50,4 @@ has been removed. Please use `-Dsolr.hiddenSysProps` or the envVar `SOLR_HIDDEN_
 
 * The node configuration file `/solr.xml` can no longer be loaded from Zookeeper. Solr startup will fail if it is present.
 
-* The legacy Circuit Breaker named `CircuitBreakerManager`, is removed. Please use individual Circuit Breaker plugins instead.
\ No newline at end of file
+* The legacy Circuit Breaker named `CircuitBreakerManager`, is removed. Please use individual Circuit Breaker plugins instead.
diff --git a/solr/solrj/src/java/org/apache/solr/client/solrj/impl/CloudSolrClient.java b/solr/solrj/src/java/org/apache/solr/client/solrj/impl/CloudSolrClient.java
index 2d08fac6a35..223fef6ab23 100644
--- a/solr/solrj/src/java/org/apache/solr/client/solrj/impl/CloudSolrClient.java
+++ b/solr/solrj/src/java/org/apache/solr/client/solrj/impl/CloudSolrClient.java
@@ -492,14 +492,18 @@ public abstract class CloudSolrClient extends SolrClient {
       nonRoutableRequest.setParams(nonRoutableParams);
       nonRoutableRequest.setBasicAuthCredentials(
           request.getBasicAuthUser(), request.getBasicAuthPassword());
-      List<String> urlList = new ArrayList<>(routes.keySet());
-      Collections.shuffle(urlList, rand);
-      LBSolrClient.Req req = new LBSolrClient.Req(nonRoutableRequest, urlList);
+      final var endpoints =
+          routes.keySet().stream()
+              .map(url -> LBSolrClient.Endpoint.from(url))
+              .collect(Collectors.toList());
+      Collections.shuffle(endpoints, rand);
+      LBSolrClient.Req req = new LBSolrClient.Req(nonRoutableRequest, endpoints);
       try {
         LBSolrClient.Rsp rsp = getLbClient().request(req);
-        shardResponses.add(urlList.get(0), rsp.getResponse());
+        shardResponses.add(endpoints.get(0).toString(), rsp.getResponse());
       } catch (Exception e) {
-        throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, urlList.get(0), e);
+        throw new SolrException(
+            SolrException.ErrorCode.SERVER_ERROR, endpoints.get(0).toString(), e);
       }
     }
 
@@ -1018,18 +1022,21 @@ public abstract class CloudSolrClient extends SolrClient {
     final String urlScheme = provider.getClusterProperty(ClusterState.URL_SCHEME, "http");
     final Set<String> liveNodes = provider.getLiveNodes();
 
-    final List<String> theUrlList = new ArrayList<>(); // we populate this as follows...
+    final List<LBSolrClient.Endpoint> requestEndpoints =
+        new ArrayList<>(); // we populate this as follows...
 
     if (request instanceof V2Request) {
       if (!liveNodes.isEmpty()) {
         List<String> liveNodesList = new ArrayList<>(liveNodes);
         Collections.shuffle(liveNodesList, rand);
-        theUrlList.add(Utils.getBaseUrlForNodeName(liveNodesList.get(0), urlScheme));
+        final var chosenNodeUrl = Utils.getBaseUrlForNodeName(liveNodesList.get(0), urlScheme);
+        requestEndpoints.add(new LBSolrClient.Endpoint(chosenNodeUrl));
       }
 
     } else if (ADMIN_PATHS.contains(request.getPath())) {
       for (String liveNode : liveNodes) {
-        theUrlList.add(Utils.getBaseUrlForNodeName(liveNode, urlScheme));
+        final var nodeBaseUrl = Utils.getBaseUrlForNodeName(liveNode, urlScheme);
+        requestEndpoints.add(new LBSolrClient.Endpoint(nodeBaseUrl));
       }
 
     } else { // Typical...
@@ -1044,13 +1051,13 @@ public abstract class CloudSolrClient extends SolrClient {
       List<String> preferredNodes = request.getPreferredNodes();
       if (preferredNodes != null && !preferredNodes.isEmpty()) {
         String joinedInputCollections = StrUtils.join(inputCollections, ',');
-        List<String> urlList = new ArrayList<>(preferredNodes.size());
-        for (String nodeName : preferredNodes) {
-          urlList.add(
-              Utils.getBaseUrlForNodeName(nodeName, urlScheme) + "/" + joinedInputCollections);
-        }
-        if (!urlList.isEmpty()) {
-          LBSolrClient.Req req = new LBSolrClient.Req(request, urlList);
+        final var endpoints =
+            preferredNodes.stream()
+                .map(nodeName -> Utils.getBaseUrlForNodeName(nodeName, urlScheme))
+                .map(nodeUrl -> new LBSolrClient.Endpoint(nodeUrl, joinedInputCollections))
+                .collect(Collectors.toList());
+        if (!endpoints.isEmpty()) {
+          LBSolrClient.Req req = new LBSolrClient.Req(request, endpoints);
           LBSolrClient.Rsp rsp = getLbClient().request(req);
           return rsp.getResponse();
         }
@@ -1110,15 +1117,16 @@ public abstract class CloudSolrClient extends SolrClient {
               if (inputCollections.size() == 1 && collectionNames.size() == 1) {
                 // If we have a single collection name (and not a alias to multiple collection),
                 // send the query directly to a replica of this collection.
-                theUrlList.add(replica.getCoreUrl());
+                requestEndpoints.add(
+                    new LBSolrClient.Endpoint(replica.getBaseUrl(), replica.getCoreName()));
               } else {
-                theUrlList.add(
-                    ZkCoreNodeProps.getCoreUrl(replica.getBaseUrl(), joinedInputCollections));
+                requestEndpoints.add(
+                    new LBSolrClient.Endpoint(replica.getBaseUrl(), joinedInputCollections));
               }
             }
           });
 
-      if (theUrlList.isEmpty()) {
+      if (requestEndpoints.isEmpty()) {
         collectionStateCache.keySet().removeAll(collectionNames);
         throw new SolrException(
             SolrException.ErrorCode.INVALID_STATE,
@@ -1126,7 +1134,7 @@ public abstract class CloudSolrClient extends SolrClient {
       }
     }
 
-    LBSolrClient.Req req = new LBSolrClient.Req(request, theUrlList);
+    LBSolrClient.Req req = new LBSolrClient.Req(request, requestEndpoints);
     LBSolrClient.Rsp rsp = getLbClient().request(req);
     return rsp.getResponse();
   }
diff --git a/solr/solrj/src/java/org/apache/solr/client/solrj/impl/ConcurrentUpdateHttp2SolrClient.java b/solr/solrj/src/java/org/apache/solr/client/solrj/impl/ConcurrentUpdateHttp2SolrClient.java
index 89d5f77f202..9deb0364543 100644
--- a/solr/solrj/src/java/org/apache/solr/client/solrj/impl/ConcurrentUpdateHttp2SolrClient.java
+++ b/solr/solrj/src/java/org/apache/solr/client/solrj/impl/ConcurrentUpdateHttp2SolrClient.java
@@ -709,10 +709,48 @@ public class ConcurrentUpdateHttp2SolrClient extends SolrClient {
     protected boolean closeHttp2Client;
     private long pollQueueTimeMillis;
 
+    /**
+     * Initialize a Builder object, based on the provided URL and client.
+     *
+     * <p>The provided URL must point to the root Solr path (i.e. "/solr"), for example:
+     *
+     * <pre>
+     *   SolrClient client = new ConcurrentUpdateHttp2SolrClient.Builder("http://my-solr-server:8983/solr", http2Client)
+     *       .withDefaultCollection("core1")
+     *       .build();
+     *   QueryResponse resp = client.query(new SolrQuery("*:*"));
+     * </pre>
+     *
+     * @param baseSolrUrl a URL pointing to the root Solr path, typically of the form
+     *     "http[s]://host:port/solr"
+     * @param client a client for this ConcurrentUpdateHttp2SolrClient to use for all requests
+     *     internally. Callers are responsible for closing the provided client (after closing any
+     *     clients created by this builder)
+     */
     public Builder(String baseSolrUrl, Http2SolrClient client) {
       this(baseSolrUrl, client, false);
     }
 
+    /**
+     * Initialize a Builder object, based on the provided arguments.
+     *
+     * <p>The provided URL must point to the root Solr path (i.e. "/solr"), for example:
+     *
+     * <pre>
+     *   SolrClient client = new ConcurrentUpdateHttp2SolrClient.Builder("http://my-solr-server:8983/solr", http2Client)
+     *       .withDefaultCollection("core1")
+     *       .build();
+     *   QueryResponse resp = client.query(new SolrQuery("*:*"));
+     * </pre>
+     *
+     * @param baseSolrUrl a URL pointing to the root Solr path, typically of the form
+     *     "http[s]://host:port/solr"
+     * @param client a client for this ConcurrentUpdateHttp2SolrClient to use for all requests
+     *     internally.
+     * @param closeHttp2Client a boolean flag indicating whether the created
+     *     ConcurrentUpdateHttp2SolrClient should assume responsibility for closing the provided
+     *     'client'
+     */
     public Builder(String baseSolrUrl, Http2SolrClient client, boolean closeHttp2Client) {
       this.baseSolrUrl = baseSolrUrl;
       this.client = client;
diff --git a/solr/solrj/src/java/org/apache/solr/client/solrj/impl/ConcurrentUpdateSolrClient.java b/solr/solrj/src/java/org/apache/solr/client/solrj/impl/ConcurrentUpdateSolrClient.java
index 5a4a129769c..6800f435b07 100644
--- a/solr/solrj/src/java/org/apache/solr/client/solrj/impl/ConcurrentUpdateSolrClient.java
+++ b/solr/solrj/src/java/org/apache/solr/client/solrj/impl/ConcurrentUpdateSolrClient.java
@@ -844,30 +844,16 @@ public class ConcurrentUpdateSolrClient extends SolrClient {
     protected boolean streamDeletes;
 
     /**
-     * Create a Builder object, based on the provided Solr URL.
+     * Initialize a Builder object, based on the provided Solr URL.
      *
-     * <p>Two different paths can be specified as a part of this URL:
-     *
-     * <p>1) A path pointing directly at a particular core
+     * <p>The provided URL must point to the root Solr path ("/solr"), for example:
      *
      * <pre>
-     *   SolrClient client = new ConcurrentUpdateSolrClient.Builder("http://my-solr-server:8983/solr/core1").build();
+     *   SolrClient client = new ConcurrentUpdateSolrClient.Builder("http://my-solr-server:8983/solr")
+     *       .withDefaultCollection("core1")
+     *       .build();
      *   QueryResponse resp = client.query(new SolrQuery("*:*"));
      * </pre>
-     *
-     * Note that when a core is provided in the base URL, queries and other requests can be made
-     * without mentioning the core explicitly. However, the client can only send requests to that
-     * core.
-     *
-     * <p>2) The path of the root Solr path ("/solr")
-     *
-     * <pre>
-     *   SolrClient client = new ConcurrentUpdateSolrClient.Builder("http://my-solr-server:8983/solr").build();
-     *   QueryResponse resp = client.query("core1", new SolrQuery("*:*"));
-     * </pre>
-     *
-     * In this case the client is more flexible and can be used to send requests to any cores. This
-     * flexibility though requires that the core be specified on all requests.
      */
     public Builder(String baseSolrUrl) {
       this.baseSolrUrl = baseSolrUrl;
diff --git a/solr/solrj/src/java/org/apache/solr/client/solrj/impl/Http2SolrClient.java b/solr/solrj/src/java/org/apache/solr/client/solrj/impl/Http2SolrClient.java
index e2b847a2954..79de13f4a26 100644
--- a/solr/solrj/src/java/org/apache/solr/client/solrj/impl/Http2SolrClient.java
+++ b/solr/solrj/src/java/org/apache/solr/client/solrj/impl/Http2SolrClient.java
@@ -1083,6 +1083,21 @@ public class Http2SolrClient extends SolrClient {
 
     public Builder() {}
 
+    /**
+     * Initialize a Builder object, based on the provided Solr URL.
+     *
+     * <p>The provided URL must point to the root Solr path ("/solr"), for example:
+     *
+     * <pre>
+     *   SolrClient client = new Http2SolrClient.Builder("http://my-solr-server:8983/solr")
+     *       .withDefaultCollection("core1")
+     *       .build();
+     *   QueryResponse resp = client.query(new SolrQuery("*:*"));
+     * </pre>
+     *
+     * @param baseSolrUrl a URL to the root Solr path (i.e. "/solr") that will be targeted by any
+     *     created clients.
+     */
     public Builder(String baseSolrUrl) {
       this.baseSolrUrl = baseSolrUrl;
     }
diff --git a/solr/solrj/src/java/org/apache/solr/client/solrj/impl/HttpSolrClient.java b/solr/solrj/src/java/org/apache/solr/client/solrj/impl/HttpSolrClient.java
index 611e9145ca9..180135d8273 100644
--- a/solr/solrj/src/java/org/apache/solr/client/solrj/impl/HttpSolrClient.java
+++ b/solr/solrj/src/java/org/apache/solr/client/solrj/impl/HttpSolrClient.java
@@ -844,28 +844,15 @@ public class HttpSolrClient extends BaseHttpSolrClient {
     /**
      * Specify the base-url for the created client to use when sending requests to Solr.
      *
-     * <p>Two different paths can be specified as a part of this URL:
-     *
-     * <p>1) A path pointing directly at a particular core
+     * <p>The provided URL must point to the root Solr path ("/solr"), for example:
      *
      * <pre>
-     *   SolrClient client = builder.withBaseSolrUrl("http://my-solr-server:8983/solr/core1").build();
+     *   SolrClient client = new HttpSolrClient.Builder()
+     *       .withBaseSolrUrl("http://my-solr-server:8983/solr")
+     *       .withDefaultCollection("core1")
+     *       .build();
      *   QueryResponse resp = client.query(new SolrQuery("*:*"));
      * </pre>
-     *
-     * Note that when a core is provided in the base URL, queries and other requests can be made
-     * without mentioning the core explicitly. However, the client can only send requests to that
-     * core.
-     *
-     * <p>2) The path of the root Solr path ("/solr")
-     *
-     * <pre>
-     *   SolrClient client = builder.withBaseSolrUrl("http://my-solr-server:8983/solr").build();
-     *   QueryResponse resp = client.query("core1", new SolrQuery("*:*"));
-     * </pre>
-     *
-     * In this case the client is more flexible and can be used to send requests to any cores. This
-     * flexibility though requires that the core is specified on all requests.
      */
     public Builder withBaseSolrUrl(String baseSolrUrl) {
       this.baseSolrUrl = baseSolrUrl;
@@ -873,38 +860,24 @@ public class HttpSolrClient extends BaseHttpSolrClient {
     }
 
     /**
-     * Create a Builder object, based on the provided Solr URL.
+     * Initialize a Builder object, based on the provided Solr URL.
      *
-     * <p>Two different paths can be specified as a part of this URL:
-     *
-     * <p>1) A path pointing directly at a particular core
+     * <p>The provided URL must point to the root Solr path ("/solr"), for example:
      *
      * <pre>
-     *   SolrClient client = new HttpSolrClient.Builder("http://my-solr-server:8983/solr/core1").build();
+     *   SolrClient client = new HttpSolrClient.Builder("http://my-solr-server:8983/solr")
+     *       .withDefaultCollection("core1")
+     *       .build();
      *   QueryResponse resp = client.query(new SolrQuery("*:*"));
      * </pre>
      *
-     * Note that when a core is provided in the base URL, queries and other requests can be made
-     * without mentioning the core explicitly. However, the client can only send requests to that
-     * core.
-     *
-     * <p>2) The path of the root Solr path ("/solr")
-     *
-     * <pre>
-     *   SolrClient client = new HttpSolrClient.Builder("http://my-solr-server:8983/solr").build();
-     *   QueryResponse resp = client.query("core1", new SolrQuery("*:*"));
-     * </pre>
-     *
-     * In this case the client is more flexible and can be used to send requests to any cores. This
-     * flexibility though requires that the core be specified on all requests.
-     *
      * <p>By default, compression is not enabled on created HttpSolrClient objects. By default,
      * redirects are not followed in created HttpSolrClient objects. By default, {@link
      * BinaryRequestWriter} is used for composing requests. By default, {@link BinaryResponseParser}
      * is used for parsing responses.
      *
-     * @param baseSolrUrl the base URL of the Solr server that will be targeted by any created
-     *     clients.
+     * @param baseSolrUrl a URL to the root Solr path (i.e. "/solr") that will be targeted by any
+     *     created clients.
      */
     public Builder(String baseSolrUrl) {
       this.baseSolrUrl = baseSolrUrl;
diff --git a/solr/solrj/src/java/org/apache/solr/client/solrj/impl/LBHttp2SolrClient.java b/solr/solrj/src/java/org/apache/solr/client/solrj/impl/LBHttp2SolrClient.java
index 6a55583f3ff..67fce05183d 100644
--- a/solr/solrj/src/java/org/apache/solr/client/solrj/impl/LBHttp2SolrClient.java
+++ b/solr/solrj/src/java/org/apache/solr/client/solrj/impl/LBHttp2SolrClient.java
@@ -40,7 +40,7 @@ import org.slf4j.MDC;
 
 /**
  * LBHttp2SolrClient or "LoadBalanced LBHttp2SolrClient" is a load balancing wrapper around {@link
- * Http2SolrClient}. This is useful when you have multiple Solr servers and the requests need to be
+ * Http2SolrClient}. This is useful when you have multiple Solr endpoints and requests need to be
  * Load Balanced among them.
  *
  * <p>Do <b>NOT</b> use this class for indexing in leader/follower scenarios since documents must be
@@ -53,24 +53,40 @@ import org.slf4j.MDC;
  * <p>It offers automatic failover when a server goes down, and it detects when the server comes
  * back up.
  *
- * <p>Load balancing is done using a simple round-robin on the list of servers.
+ * <p>Load balancing is done using a simple round-robin on the list of endpoints. Endpoint URLs are
+ * expected to point to the Solr "root" path (i.e. "/solr").
  *
- * <p>If a request to a server fails by an IOException due to a connection timeout or read timeout
- * then the host is taken off the list of live servers and moved to a 'dead server list' and the
- * request is resent to the next live server. This process is continued till it tries all the live
- * servers. If at least one server is alive, the request succeeds, and if not it fails.
+ * <blockquote>
+ *
+ * <pre>
+ * SolrClient client = new LBHttp2SolrClient.Builder(http2SolrClient,
+ *         new LBSolrClient.Endpoint("http://host1:8080/solr"), new LBSolrClient.Endpoint("http://host2:8080/solr"))
+ *     .build();
+ * </pre>
+ *
+ * </blockquote>
+ *
+ * Users who wish to balance traffic across a specific set of replicas or cores may specify each
+ * endpoint as a root-URL and core-name pair. For example:
  *
  * <blockquote>
  *
  * <pre>
- * SolrClient lbHttp2SolrClient = new LBHttp2SolrClient(http2SolrClient, "http://host1:8080/solr/", "http://host2:8080/solr", "http://host2:8080/solr");
+ * SolrClient client = new LBHttp2SolrClient.Builder(http2SolrClient,
+ *         new LBSolrClient.Endpoint("http://host1:8080/solr", "coreA"),
+ *         new LBSolrClient.Endpoint("http://host2:8080/solr", "coreB"))
+ *     .build();
  * </pre>
  *
  * </blockquote>
  *
- * This detects if a dead server comes alive automatically. The check is done in fixed intervals in
- * a dedicated thread. This interval can be set using {@link
- * LBHttp2SolrClient.Builder#setAliveCheckInterval(int, TimeUnit)} , the default is set to one
+ * <p>If a request to an endpoint fails by an IOException due to a connection timeout or read
+ * timeout then the host is taken off the list of live endpoints and moved to a 'dead endpoint list'
+ * and the request is resent to the next live endpoint. This process is continued till it tries all
+ * the live endpoints. If at least one endpoint is alive, the request succeeds, and if not it fails.
+ *
+ * <p>Dead endpoints are periodically healthchecked on a fixed interval controlled by {@link
+ * LBHttp2SolrClient.Builder#setAliveCheckInterval(int, TimeUnit)}. The default is set to one
  * minute.
  *
  * <p><b>When to use this?</b><br>
@@ -86,14 +102,14 @@ public class LBHttp2SolrClient extends LBSolrClient {
   private final Http2SolrClient solrClient;
 
   private LBHttp2SolrClient(Builder builder) {
-    super(Arrays.asList(builder.baseSolrUrls));
+    super(Arrays.asList(builder.solrEndpoints));
     this.solrClient = builder.http2SolrClient;
     this.aliveCheckIntervalMillis = builder.aliveCheckIntervalMillis;
     this.defaultCollection = builder.defaultCollection;
   }
 
   @Override
-  protected SolrClient getClient(String baseUrl) {
+  protected SolrClient getClient(Endpoint endpoint) {
     return solrClient;
   }
 
@@ -115,7 +131,7 @@ public class LBHttp2SolrClient extends LBSolrClient {
     Rsp rsp = new Rsp();
     boolean isNonRetryable =
         req.request instanceof IsUpdateRequest || ADMIN_PATHS.contains(req.request.getPath());
-    ServerIterator it = new ServerIterator(req, zombieServers);
+    EndpointIterator it = new EndpointIterator(req, zombieServers);
     asyncListener.onStart();
     final AtomicBoolean cancelled = new AtomicBoolean(false);
     AtomicReference<Cancellable> currentCancellable = new AtomicReference<>();
@@ -130,7 +146,7 @@ public class LBHttp2SolrClient extends LBSolrClient {
           @Override
           public void onFailure(Exception e, boolean retryReq) {
             if (retryReq) {
-              String url;
+              Endpoint url;
               try {
                 url = it.nextOrError(e);
               } catch (SolrServerException ex) {
@@ -138,7 +154,7 @@ public class LBHttp2SolrClient extends LBSolrClient {
                 return;
               }
               try {
-                MDC.put("LBSolrClient.url", url);
+                MDC.put("LBSolrClient.url", url.toString());
                 synchronized (cancelled) {
                   if (cancelled.get()) {
                     return;
@@ -185,15 +201,15 @@ public class LBHttp2SolrClient extends LBSolrClient {
   }
 
   private Cancellable doRequest(
-      String baseUrl,
+      Endpoint endpoint,
       Req req,
       Rsp rsp,
       boolean isNonRetryable,
       boolean isZombie,
       RetryListener listener) {
-    rsp.server = baseUrl;
-    req.getRequest().setBasePath(baseUrl);
-    return ((Http2SolrClient) getClient(baseUrl))
+    rsp.server = endpoint.toString();
+    req.getRequest().setBasePath(endpoint.toString());
+    return ((Http2SolrClient) getClient(endpoint))
         .asyncRequest(
             req.getRequest(),
             null,
@@ -202,7 +218,7 @@ public class LBHttp2SolrClient extends LBSolrClient {
               public void onSuccess(NamedList<Object> result) {
                 rsp.rsp = result;
                 if (isZombie) {
-                  zombieServers.remove(baseUrl);
+                  zombieServers.remove(endpoint);
                 }
                 listener.onSuccess(rsp);
               }
@@ -217,32 +233,32 @@ public class LBHttp2SolrClient extends LBSolrClient {
                   // we retry on 404 or 403 or 503 or 500
                   // unless it's an update - then we only retry on connect exception
                   if (!isNonRetryable && RETRY_CODES.contains(e.code())) {
-                    listener.onFailure((!isZombie) ? addZombie(baseUrl, e) : e, true);
+                    listener.onFailure((!isZombie) ? addZombie(endpoint, e) : e, true);
                   } else {
                     // Server is alive but the request was likely malformed or invalid
                     if (isZombie) {
-                      zombieServers.remove(baseUrl);
+                      zombieServers.remove(endpoint);
                     }
                     listener.onFailure(e, false);
                   }
                 } catch (SocketException e) {
                   if (!isNonRetryable || e instanceof ConnectException) {
-                    listener.onFailure((!isZombie) ? addZombie(baseUrl, e) : e, true);
+                    listener.onFailure((!isZombie) ? addZombie(endpoint, e) : e, true);
                   } else {
                     listener.onFailure(e, false);
                   }
                 } catch (SocketTimeoutException e) {
                   if (!isNonRetryable) {
-                    listener.onFailure((!isZombie) ? addZombie(baseUrl, e) : e, true);
+                    listener.onFailure((!isZombie) ? addZombie(endpoint, e) : e, true);
                   } else {
                     listener.onFailure(e, false);
                   }
                 } catch (SolrServerException e) {
                   Throwable rootCause = e.getRootCause();
                   if (!isNonRetryable && rootCause instanceof IOException) {
-                    listener.onFailure((!isZombie) ? addZombie(baseUrl, e) : e, true);
+                    listener.onFailure((!isZombie) ? addZombie(endpoint, e) : e, true);
                   } else if (isNonRetryable && rootCause instanceof ConnectException) {
-                    listener.onFailure((!isZombie) ? addZombie(baseUrl, e) : e, true);
+                    listener.onFailure((!isZombie) ? addZombie(endpoint, e) : e, true);
                   } else {
                     listener.onFailure(e, false);
                   }
@@ -256,14 +272,14 @@ public class LBHttp2SolrClient extends LBSolrClient {
   public static class Builder {
 
     private final Http2SolrClient http2SolrClient;
-    private final String[] baseSolrUrls;
+    private final Endpoint[] solrEndpoints;
     private long aliveCheckIntervalMillis =
         TimeUnit.MILLISECONDS.convert(60, TimeUnit.SECONDS); // 1 minute between checks
     protected String defaultCollection;
 
-    public Builder(Http2SolrClient http2Client, String... baseSolrUrls) {
+    public Builder(Http2SolrClient http2Client, Endpoint... endpoints) {
       this.http2SolrClient = http2Client;
-      this.baseSolrUrls = baseSolrUrls;
+      this.solrEndpoints = endpoints;
     }
 
     /**
diff --git a/solr/solrj/src/java/org/apache/solr/client/solrj/impl/LBHttpSolrClient.java b/solr/solrj/src/java/org/apache/solr/client/solrj/impl/LBHttpSolrClient.java
index b60a5b77b0f..8b0971a53ac 100644
--- a/solr/solrj/src/java/org/apache/solr/client/solrj/impl/LBHttpSolrClient.java
+++ b/solr/solrj/src/java/org/apache/solr/client/solrj/impl/LBHttpSolrClient.java
@@ -25,12 +25,11 @@ import java.util.concurrent.TimeUnit;
 import org.apache.http.client.HttpClient;
 import org.apache.solr.client.solrj.SolrClient;
 import org.apache.solr.common.params.ModifiableSolrParams;
-import org.apache.solr.common.util.URLUtil;
 
 /**
  * LBHttpSolrClient or "LoadBalanced HttpSolrClient" is a load balancing wrapper around {@link
- * HttpSolrClient}. This is useful when you have multiple Solr servers and the requests need to be
- * Load Balanced among them.
+ * HttpSolrClient}. This is useful when you have multiple Solr servers (also called endpoints) and
+ * requests need to be Load Balanced among them.
  *
  * <p>Do <b>NOT</b> use this class for indexing in leader/follower scenarios since documents must be
  * sent to the correct leader; no inter-node routing is done.
@@ -42,27 +41,48 @@ import org.apache.solr.common.util.URLUtil;
  * <p>It offers automatic failover when a server goes down and it detects when the server comes back
  * up.
  *
- * <p>Load balancing is done using a simple round-robin on the list of servers.
+ * <p>Load balancing is done using a simple round-robin on the list of endpoints. Endpoint URLs are
+ * expected to point to the Solr "root" path (i.e. "/solr").
  *
- * <p>If a request to a server fails by an IOException due to a connection timeout or read timeout
- * then the host is taken off the list of live servers and moved to a 'dead server list' and the
- * request is resent to the next live server. This process is continued till it tries all the live
- * servers. If at least one server is alive, the request succeeds, and if not it fails.
+ * <blockquote>
+ *
+ * <pre>
+ * SolrClient lbHttpSolrClient = new LBHttpSolrClient.Builder()
+ *     .withBaseEndpoints("http://host1:8080/solr", "http://host2:8080/solr", "http://host3:8080/solr")
+ *     .build();
+ * </pre>
+ *
+ * </blockquote>
+ *
+ * Users who wish to balance traffic across a specific set of replicas or cores may specify each
+ * endpoint as a root-URL and core-name pair. For example:
  *
  * <blockquote>
  *
  * <pre>
- * SolrClient lbHttpSolrClient = new LBHttpSolrClient("http://host1:8080/solr/", "http://host2:8080/solr", "http://host2:8080/solr");
- * //or if you wish to pass the HttpClient do as follows
- * httpClient httpClient = new HttpClient();
- * SolrClient lbHttpSolrClient = new LBHttpSolrClient(httpClient, "http://host1:8080/solr/", "http://host2:8080/solr", "http://host2:8080/solr");
+ * SolrClient lbHttpSolrClient = new LBHttpSolrClient.Builder()
+ *     .withCollectionEndpoint("http://host1:8080/solr", "coreA")
+ *     .withCollectionEndpoint("http://host2:8080/solr", "coreB")
+ *     .withCollectionEndpoint("http://host3:8080/solr", "coreC")
+ *     .build();
+ * // Or, if you wish to provide all endpoints together:
+ * lbHttpSolrClient = new LBHttpSolrClient.Builder()
+ *     .withCollectionEndpoints(
+ *         new LBSolrClient.Endpoint("http://host1:8080/solr", "coreA"),
+ *         new LBSolrClient.Endpoint("http://host2:8080/solr", "coreB"),
+ *         new LBSolrClient.Endpoint("http://host3:8080/solr", "coreC"))
+ *     .build();
  * </pre>
  *
  * </blockquote>
  *
- * This detects if a dead server comes alive automatically. The check is done in fixed intervals in
- * a dedicated thread. This interval can be set using {@link
- * LBHttpSolrClient.Builder#setAliveCheckInterval(int)} , the default is set to one minute.
+ * <p>If a request to an endpoint fails by an IOException due to a connection timeout or read
+ * timeout then the host is taken off the list of live endpoints and moved to a 'dead endpoint list'
+ * and the request is resent to the next live endpoint. This process is continued till it tries all
+ * the live endpoints. If at least one endpoint is alive, the request succeeds, and if not it fails.
+ *
+ * <p>Dead endpoints are periodically healthchecked on a fixed interval controlled by {@link
+ * LBHttpSolrClient.Builder#setAliveCheckInterval(int)}. The default is set to one minute.
  *
  * <p><b>When to use this?</b><br>
  * This can be used as a software load balancer when you do not wish to setup an external load
@@ -87,13 +107,11 @@ public class LBHttpSolrClient extends LBSolrClient {
 
   /** The provided httpClient should use a multi-threaded connection manager */
   protected LBHttpSolrClient(Builder builder) {
-    super(builder.baseSolrUrls);
+    super(builder.solrEndpoints);
     this.clientIsInternal = builder.httpClient == null;
     this.httpSolrClientBuilder = builder.httpSolrClientBuilder;
     this.httpClient =
-        builder.httpClient == null
-            ? constructClient(builder.baseSolrUrls.toArray(new String[0]))
-            : builder.httpClient;
+        builder.httpClient == null ? constructClient(builder.solrEndpoints) : builder.httpClient;
     this.defaultCollection = builder.defaultCollection;
     if (httpSolrClientBuilder != null && this.defaultCollection != null) {
       httpSolrClientBuilder.defaultCollection = this.defaultCollection;
@@ -102,14 +120,14 @@ public class LBHttpSolrClient extends LBSolrClient {
     this.soTimeoutMillis = builder.socketTimeoutMillis;
     this.parser = builder.responseParser;
     this.aliveCheckIntervalMillis = builder.aliveCheckInterval;
-    for (String baseUrl : builder.baseSolrUrls) {
-      urlToClient.put(baseUrl, makeSolrClient(baseUrl));
+    for (Endpoint endpoint : builder.solrEndpoints) {
+      urlToClient.put(endpoint.toString(), makeSolrClient(endpoint));
     }
   }
 
-  private HttpClient constructClient(String[] solrServerUrl) {
+  private HttpClient constructClient(List<Endpoint> solrEndpoints) {
     ModifiableSolrParams params = new ModifiableSolrParams();
-    if (solrServerUrl != null && solrServerUrl.length > 1) {
+    if (solrEndpoints != null && solrEndpoints.size() > 1) {
       // we prefer retrying another server
       params.set(HttpClientUtil.PROP_USE_RETRY, false);
     } else {
@@ -118,12 +136,13 @@ public class LBHttpSolrClient extends LBSolrClient {
     return HttpClientUtil.createClient(params);
   }
 
-  protected HttpSolrClient makeSolrClient(String server) {
+  protected HttpSolrClient makeSolrClient(Endpoint server) {
     HttpSolrClient client;
     if (httpSolrClientBuilder != null) {
       synchronized (this) {
         httpSolrClientBuilder
-            .withBaseSolrUrl(server)
+            .withBaseSolrUrl(server.getBaseUrl())
+            .withDefaultCollection(server.getCore())
             .withHttpClient(httpClient)
             .withConnectionTimeout(connectionTimeoutMillis, TimeUnit.MILLISECONDS)
             .withSocketTimeout(soTimeoutMillis, TimeUnit.MILLISECONDS);
@@ -140,15 +159,12 @@ public class LBHttpSolrClient extends LBSolrClient {
       }
     } else {
       final var clientBuilder =
-          (URLUtil.isBaseUrl(server))
-              ? new HttpSolrClient.Builder(server)
-              : new HttpSolrClient.Builder(URLUtil.extractBaseUrl(server))
-                  .withDefaultCollection(URLUtil.extractCoreFromCoreUrl(server));
-      clientBuilder
-          .withHttpClient(httpClient)
-          .withResponseParser(parser)
-          .withConnectionTimeout(connectionTimeoutMillis, TimeUnit.MILLISECONDS)
-          .withSocketTimeout(soTimeoutMillis, TimeUnit.MILLISECONDS);
+          new HttpSolrClient.Builder(server.getBaseUrl())
+              .withDefaultCollection(server.getCore())
+              .withHttpClient(httpClient)
+              .withResponseParser(parser)
+              .withConnectionTimeout(connectionTimeoutMillis, TimeUnit.MILLISECONDS)
+              .withSocketTimeout(soTimeoutMillis, TimeUnit.MILLISECONDS);
       if (defaultCollection != null) {
         clientBuilder.withDefaultCollection(defaultCollection);
       }
@@ -165,18 +181,18 @@ public class LBHttpSolrClient extends LBSolrClient {
   }
 
   @Override
-  protected SolrClient getClient(String baseUrl) {
-    SolrClient client = urlToClient.get(baseUrl);
+  protected SolrClient getClient(Endpoint endpoint) {
+    SolrClient client = urlToClient.get(endpoint.toString());
     if (client == null) {
-      return makeSolrClient(baseUrl);
+      return makeSolrClient(endpoint);
     } else {
       return client;
     }
   }
 
   @Override
-  public String removeSolrServer(String server) {
-    urlToClient.remove(server);
+  public String removeSolrServer(Endpoint server) {
+    urlToClient.remove(server.toString());
     return super.removeSolrServer(server);
   }
 
@@ -197,12 +213,12 @@ public class LBHttpSolrClient extends LBSolrClient {
   public static class Builder extends SolrClientBuilder<Builder> {
 
     public static final int CHECK_INTERVAL = 60 * 1000; // 1 minute between checks
-    protected final List<String> baseSolrUrls;
+    protected final List<Endpoint> solrEndpoints;
     protected HttpSolrClient.Builder httpSolrClientBuilder;
     private int aliveCheckInterval = CHECK_INTERVAL;
 
     public Builder() {
-      this.baseSolrUrls = new ArrayList<>();
+      this.solrEndpoints = new ArrayList<>();
       this.responseParser = new BinaryResponseParser();
     }
 
@@ -211,70 +227,77 @@ public class LBHttpSolrClient extends LBSolrClient {
     }
 
     /**
-     * Provide a Solr endpoint to be used when configuring {@link LBHttpSolrClient} instances.
-     *
-     * <p>Method may be called multiple times. All provided values will be used.
-     *
-     * <p>Two different paths can be specified as a part of the URL:
-     *
-     * <p>1) A path pointing directly at a particular core
+     * Provide a "base" Solr URL to be used when configuring {@link LBHttpSolrClient} instances.
      *
-     * <pre>
-     *   SolrClient client = builder.withBaseSolrUrl("http://my-solr-server:8983/solr/core1").build();
-     *   QueryResponse resp = client.query(new SolrQuery("*:*"));
-     * </pre>
+     * <p>Method may be called multiple times. All provided values will be used. However, all
+     * endpoints must be of the same type: providing a mix of"base" endpoints via this method and
+     * core/collection endpoints via {@link #withCollectionEndpoint(String, String)} is prohibited.
      *
-     * Note that when a core is provided in the base URL, queries and other requests can be made
-     * without mentioning the core explicitly. However, the client can only send requests to that
-     * core.
+     * <p>Users who use this method to provide base Solr URLs may specify a "default collection" for
+     * their requests using {@link #withDefaultCollection(String)} if they wish to avoid needing to
+     * specify a collection or core on relevant requests.
      *
-     * <p>2) The path of the root Solr path ("/solr")
-     *
-     * <pre>
-     *   SolrClient client = builder.withBaseSolrUrl("http://my-solr-server:8983/solr").build();
-     *   QueryResponse resp = client.query("core1", new SolrQuery("*:*"));
-     * </pre>
-     *
-     * In this case the client is more flexible and can be used to send requests to any cores. This
-     * flexibility though requires that the core is specified on all requests.
+     * @param baseSolrUrl the base URL for a Solr node, in the form "http[s]://hostname:port/solr"
      */
-    public Builder withBaseSolrUrl(String baseSolrUrl) {
-      this.baseSolrUrls.add(baseSolrUrl);
+    public Builder withBaseEndpoint(String baseSolrUrl) {
+      solrEndpoints.add(new Endpoint(baseSolrUrl));
       return this;
     }
 
     /**
-     * Provide Solr endpoints to be used when configuring {@link LBHttpSolrClient} instances.
-     *
-     * <p>Method may be called multiple times. All provided values will be used.
+     * Provide multiple "base" Solr URLs to be used when configuring {@link LBHttpSolrClient}
+     * instances.
      *
-     * <p>Two different paths can be specified as a part of each URL:
+     * <p>Method may be called multiple times. All provided values will be used. However, all
+     * endpoints must be of the same type: providing a mix of"base" endpoints via this method and
+     * core/collection endpoints via {@link #withCollectionEndpoint(String, String)} is prohibited.
      *
-     * <p>1) A path pointing directly at a particular core
+     * <p>Users who use this method to provide base Solr URLs may specify a "default collection" for
+     * their requests using {@link #withDefaultCollection(String)} if they wish to avoid needing to
+     * specify a collection or core on relevant requests.
      *
-     * <pre>
-     *   SolrClient client = builder.withBaseSolrUrls("http://my-solr-server:8983/solr/core1").build();
-     *   QueryResponse resp = client.query(new SolrQuery("*:*"));
-     * </pre>
+     * @param baseSolrUrls Solr base URLs, in the form "http[s]://hostname:port/solr"
+     */
+    public Builder withBaseEndpoints(String... baseSolrUrls) {
+      for (String baseSolrUrl : baseSolrUrls) {
+        solrEndpoints.add(new Endpoint(baseSolrUrl));
+      }
+      return this;
+    }
+
+    /**
+     * Provide a core/collection Solr endpoint to be used when configuring {@link LBHttpSolrClient}
+     * instances.
      *
-     * Note that when a core is provided in the base URL, queries and other requests can be made
-     * without mentioning the core explicitly. However, the client can only send requests to that
-     * core.
+     * <p>Method may be called multiple times. All provided values will be used. However, all
+     * endpoints must be of the same type: providing a mix of "core" endpoints via this method and
+     * base endpoints via {@link #withBaseEndpoint(String)} is prohibited.
      *
-     * <p>2) The path of the root Solr path ("/solr")
+     * @param baseSolrUrl the base URL for a Solr node, in the form "http[s]://hostname:port/solr"
+     * @param core the Solr core or collection to target
+     */
+    public Builder withCollectionEndpoint(String baseSolrUrl, String core) {
+      solrEndpoints.add(new Endpoint(baseSolrUrl, core));
+      return this;
+    }
+
+    /**
+     * Provide multiple core/collection endpoints to be used when configuring {@link
+     * LBHttpSolrClient} instances.
      *
-     * <pre>
-     *   SolrClient client = builder.withBaseSolrUrls("http://my-solr-server:8983/solr").build();
-     *   QueryResponse resp = client.query("core1", new SolrQuery("*:*"));
-     * </pre>
+     * <p>Method may be called multiple times. All provided values will be used. However, all
+     * endpoints must be of the same type: providing a mix of "core" endpoints via this method and
+     * base endpoints via {@link #withBaseEndpoint(String)} is prohibited.
      *
-     * In this case the client is more flexible and can be used to send requests to any cores. This
-     * flexibility though requires that the core is specified on all requests.
+     * @param endpoints endpoint instances pointing to distinct cores/collections
      */
-    public Builder withBaseSolrUrls(String... solrUrls) {
-      for (String baseSolrUrl : solrUrls) {
-        this.baseSolrUrls.add(baseSolrUrl);
+    public Builder withCollectionEndpoints(Endpoint... endpoints) {
+      if (endpoints != null) {
+        for (Endpoint e : endpoints) {
+          solrEndpoints.add(e);
+        }
       }
+
       return this;
     }
 
@@ -302,7 +325,7 @@ public class LBHttpSolrClient extends LBSolrClient {
       return this;
     }
 
-    /** Create a {@link HttpSolrClient} based on provided configuration. */
+    /** Create a {@link LBHttpSolrClient} based on provided configuration. */
     public LBHttpSolrClient build() {
       return new LBHttpSolrClient(this);
     }
diff --git a/solr/solrj/src/java/org/apache/solr/client/solrj/impl/LBSolrClient.java b/solr/solrj/src/java/org/apache/solr/client/solrj/impl/LBSolrClient.java
index d42eb285b50..9e00e16ddb1 100644
--- a/solr/solrj/src/java/org/apache/solr/client/solrj/impl/LBSolrClient.java
+++ b/solr/solrj/src/java/org/apache/solr/client/solrj/impl/LBSolrClient.java
@@ -25,21 +25,23 @@ import java.net.ConnectException;
 import java.net.MalformedURLException;
 import java.net.SocketException;
 import java.net.SocketTimeoutException;
-import java.net.URL;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collection;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Iterator;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 import java.util.Set;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.Executors;
 import java.util.concurrent.ScheduledExecutorService;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.Collectors;
 import org.apache.solr.client.solrj.ResponseParser;
 import org.apache.solr.client.solrj.SolrClient;
 import org.apache.solr.client.solrj.SolrQuery;
@@ -56,6 +58,7 @@ import org.apache.solr.common.params.SolrParams;
 import org.apache.solr.common.util.ExecutorUtil;
 import org.apache.solr.common.util.NamedList;
 import org.apache.solr.common.util.SolrNamedThreadFactory;
+import org.apache.solr.common.util.URLUtil;
 import org.slf4j.MDC;
 
 public abstract class LBSolrClient extends SolrClient {
@@ -68,13 +71,13 @@ public abstract class LBSolrClient extends SolrClient {
 
   // keys to the maps are currently of the form "http://localhost:8983/solr"
   // which should be equivalent to HttpSolrServer.getBaseURL()
-  private final Map<String, ServerWrapper> aliveServers = new LinkedHashMap<>();
+  private final Map<String, EndpointWrapper> aliveServers = new LinkedHashMap<>();
   // access to aliveServers should be synchronized on itself
 
-  protected final Map<String, ServerWrapper> zombieServers = new ConcurrentHashMap<>();
+  protected final Map<String, EndpointWrapper> zombieServers = new ConcurrentHashMap<>();
 
   // changes to aliveServers are reflected in this array, no need to synchronize
-  private volatile ServerWrapper[] aliveServerList = new ServerWrapper[0];
+  private volatile EndpointWrapper[] aliveServerList = new EndpointWrapper[0];
 
   private volatile ScheduledExecutorService aliveCheckExecutor;
 
@@ -100,57 +103,147 @@ public abstract class LBSolrClient extends SolrClient {
     solrQuery.setDistrib(false);
   }
 
-  protected static class ServerWrapper {
-    final String baseUrl;
+  /**
+   * A Solr endpoint for {@link LBSolrClient} to include in its load-balancing
+   *
+   * <p>Used in many places instead of the more common String URL to allow {@link LBSolrClient} to
+   * more easily determine whether a URL is a "base" or "core-aware" URL.
+   */
+  public static class Endpoint {
+    private final String baseUrl;
+    private final String core;
+
+    /**
+     * Creates an {@link Endpoint} representing a "base" URL of a Solr node
+     *
+     * @param baseUrl a base Solr URL, in the form "http[s]://host:port/solr"
+     */
+    public Endpoint(String baseUrl) {
+      this(baseUrl, null);
+    }
+
+    /**
+     * Create an {@link Endpoint} representing a Solr core or collection
+     *
+     * @param baseUrl a base Solr URL, in the form "http[s]://host:port/solr"
+     * @param core the name of a Solr core or collection
+     */
+    public Endpoint(String baseUrl, String core) {
+      this.baseUrl = normalize(baseUrl);
+      this.core = core;
+    }
+
+    /**
+     * Return the base URL of the Solr node this endpoint represents
+     *
+     * @return a base Solr URL, in the form "http[s]://host:port/solr"
+     */
+    public String getBaseUrl() {
+      return baseUrl;
+    }
+
+    /**
+     * The core or collection this endpoint represents
+     *
+     * @return a core/collection name, or null if this endpoint doesn't represent a particular core.
+     */
+    public String getCore() {
+      return core;
+    }
+
+    /** Get the full URL, possibly including the collection/core if one was provided */
+    public String getUrl() {
+      if (core == null) {
+        return baseUrl;
+      }
+      return baseUrl + "/" + core;
+    }
+
+    @Override
+    public String toString() {
+      return getUrl();
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(baseUrl, core);
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+      if (this == obj) return true;
+      if (!(obj instanceof Endpoint)) return false;
+      final Endpoint rhs = (Endpoint) obj;
+
+      return Objects.equals(baseUrl, rhs.baseUrl) && Objects.equals(core, rhs.core);
+    }
 
-    // "standard" servers are used by default.  They normally live in the alive list
+    /**
+     * Create an {@link Endpoint} from a provided Solr URL
+     *
+     * <p>This method does its best to determine whether the provided URL is a Solr "base" URL or
+     * one which includes a core or collection name.
+     */
+    public static Endpoint from(String unknownUrl) {
+      if (URLUtil.isBaseUrl(unknownUrl)) {
+        return new Endpoint(unknownUrl);
+      }
+      return new Endpoint(
+          URLUtil.extractBaseUrl(unknownUrl), URLUtil.extractCoreFromCoreUrl(unknownUrl));
+    }
+  }
+
+  protected static class EndpointWrapper {
+    final Endpoint endpoint;
+
+    // "standard" endpoints are used by default.  They normally live in the alive list
     // and move to the zombie list when unavailable.  When they become available again,
     // they move back to the alive list.
     boolean standard = true;
 
     int failedPings = 0;
 
-    ServerWrapper(String baseUrl) {
-      this.baseUrl = baseUrl;
+    EndpointWrapper(Endpoint endpoint) {
+      this.endpoint = endpoint;
     }
 
-    public String getBaseUrl() {
-      return baseUrl;
+    public Endpoint getEndpoint() {
+      return endpoint;
     }
 
     @Override
     public String toString() {
-      return baseUrl;
+      return endpoint.toString();
     }
 
     @Override
     public int hashCode() {
-      return baseUrl.hashCode();
+      return toString().hashCode();
     }
 
     @Override
     public boolean equals(Object obj) {
       if (this == obj) return true;
-      if (!(obj instanceof ServerWrapper)) return false;
-      return baseUrl.equals(((ServerWrapper) obj).baseUrl);
+      if (!(obj instanceof EndpointWrapper)) return false;
+      return endpoint.equals(((EndpointWrapper) obj).getEndpoint());
     }
   }
 
-  protected static class ServerIterator {
-    String serverStr;
-    List<String> skipped;
+  protected static class EndpointIterator {
+    Endpoint endpoint;
+    List<Endpoint> skipped;
     int numServersTried;
-    Iterator<String> it;
-    Iterator<String> skippedIt;
+    Iterator<Endpoint> it;
+    Iterator<Endpoint> skippedIt;
     String exceptionMessage;
     long timeAllowedNano;
     long timeOutTime;
 
-    final Map<String, ServerWrapper> zombieServers;
+    final Map<String, EndpointWrapper> zombieServers;
     final Req req;
 
-    public ServerIterator(Req req, Map<String, ServerWrapper> zombieServers) {
-      this.it = req.getServers().iterator();
+    public EndpointIterator(Req req, Map<String, EndpointWrapper> zombieServers) {
+      this.it = req.getEndpoints().iterator();
       this.req = req;
       this.zombieServers = zombieServers;
       this.timeAllowedNano = getTimeAllowedInNanos(req.getRequest());
@@ -159,29 +252,28 @@ public abstract class LBSolrClient extends SolrClient {
     }
 
     public synchronized boolean hasNext() {
-      return serverStr != null;
+      return endpoint != null;
     }
 
     private void fetchNext() {
-      serverStr = null;
+      endpoint = null;
       if (req.numServersToTry != null && numServersTried > req.numServersToTry) {
         exceptionMessage = "Time allowed to handle this request exceeded";
         return;
       }
 
       while (it.hasNext()) {
-        serverStr = it.next();
-        serverStr = normalize(serverStr);
+        endpoint = it.next();
         // if the server is currently a zombie, just skip to the next one
-        ServerWrapper wrapper = zombieServers.get(serverStr);
+        EndpointWrapper wrapper = zombieServers.get(endpoint);
         if (wrapper != null) {
           final int numDeadServersToTry = req.getNumDeadServersToTry();
           if (numDeadServersToTry > 0) {
             if (skipped == null) {
               skipped = new ArrayList<>(numDeadServersToTry);
-              skipped.add(wrapper.getBaseUrl());
+              skipped.add(wrapper.getEndpoint());
             } else if (skipped.size() < numDeadServersToTry) {
-              skipped.add(wrapper.getBaseUrl());
+              skipped.add(wrapper.getEndpoint());
             }
           }
           continue;
@@ -189,12 +281,12 @@ public abstract class LBSolrClient extends SolrClient {
 
         break;
       }
-      if (serverStr == null && skipped != null) {
+      if (endpoint == null && skipped != null) {
         if (skippedIt == null) {
           skippedIt = skipped.iterator();
         }
         if (skippedIt.hasNext()) {
-          serverStr = skippedIt.next();
+          endpoint = skippedIt.next();
         }
       }
     }
@@ -203,11 +295,11 @@ public abstract class LBSolrClient extends SolrClient {
       return skippedIt != null;
     }
 
-    public synchronized String nextOrError() throws SolrServerException {
+    public synchronized Endpoint nextOrError() throws SolrServerException {
       return nextOrError(null);
     }
 
-    public synchronized String nextOrError(Exception previousEx) throws SolrServerException {
+    public synchronized Endpoint nextOrError(Exception previousEx) throws SolrServerException {
       String suffix = "";
       if (previousEx == null) {
         suffix = ":" + zombieServers.keySet();
@@ -217,7 +309,7 @@ public abstract class LBSolrClient extends SolrClient {
         throw new SolrServerException(
             "Time allowed to handle this request exceeded" + suffix, previousEx);
       }
-      if (serverStr == null) {
+      if (endpoint == null) {
         throw new SolrServerException(
             "No live SolrServers available to handle this request" + suffix, previousEx);
       }
@@ -232,7 +324,7 @@ public abstract class LBSolrClient extends SolrClient {
                 + suffix,
             previousEx);
       }
-      String rs = serverStr;
+      Endpoint rs = endpoint;
       fetchNext();
       return rs;
     }
@@ -241,18 +333,18 @@ public abstract class LBSolrClient extends SolrClient {
   // Req should be parameterized too, but that touches a whole lotta code
   public static class Req {
     protected SolrRequest<?> request;
-    protected List<String> servers;
+    protected List<Endpoint> endpoints;
     protected int numDeadServersToTry;
     private final Integer numServersToTry;
 
-    public Req(SolrRequest<?> request, List<String> servers) {
-      this(request, servers, null);
+    public Req(SolrRequest<?> request, Collection<Endpoint> endpoints) {
+      this(request, endpoints, null);
     }
 
-    public Req(SolrRequest<?> request, List<String> servers, Integer numServersToTry) {
+    public Req(SolrRequest<?> request, Collection<Endpoint> endpoints, Integer numServersToTry) {
       this.request = request;
-      this.servers = servers;
-      this.numDeadServersToTry = servers.size();
+      this.endpoints = endpoints.stream().collect(Collectors.toList());
+      this.numDeadServersToTry = endpoints.size();
       this.numServersToTry = numServersToTry;
     }
 
@@ -260,8 +352,8 @@ public abstract class LBSolrClient extends SolrClient {
       return request;
     }
 
-    public List<String> getServers() {
-      return servers;
+    public List<Endpoint> getEndpoints() {
+      return endpoints;
     }
 
     /**
@@ -293,17 +385,21 @@ public abstract class LBSolrClient extends SolrClient {
       return rsp;
     }
 
-    /** The server that returned the response */
+    /**
+     * The server URL that returned the response
+     *
+     * <p>May be either a true "base URL" or a core/collection URL, depending on the request
+     */
     public String getServer() {
       return server;
     }
   }
 
-  public LBSolrClient(List<String> baseSolrUrls) {
-    if (!baseSolrUrls.isEmpty()) {
-      for (String s : baseSolrUrls) {
-        ServerWrapper wrapper = createServerWrapper(s);
-        aliveServers.put(wrapper.getBaseUrl(), wrapper);
+  public LBSolrClient(List<Endpoint> solrEndpoints) {
+    if (!solrEndpoints.isEmpty()) {
+      for (Endpoint s : solrEndpoints) {
+        EndpointWrapper wrapper = createServerWrapper(s);
+        aliveServers.put(wrapper.getEndpoint().toString(), wrapper);
       }
       updateAliveList();
     }
@@ -311,12 +407,12 @@ public abstract class LBSolrClient extends SolrClient {
 
   protected void updateAliveList() {
     synchronized (aliveServers) {
-      aliveServerList = aliveServers.values().toArray(new ServerWrapper[0]);
+      aliveServerList = aliveServers.values().toArray(new EndpointWrapper[0]);
     }
   }
 
-  protected ServerWrapper createServerWrapper(String baseUrl) {
-    return new ServerWrapper(baseUrl);
+  protected EndpointWrapper createServerWrapper(Endpoint baseUrl) {
+    return new EndpointWrapper(baseUrl);
   }
 
   public static String normalize(String server) {
@@ -345,12 +441,14 @@ public abstract class LBSolrClient extends SolrClient {
     Exception ex = null;
     boolean isNonRetryable =
         req.request instanceof IsUpdateRequest || ADMIN_PATHS.contains(req.request.getPath());
-    ServerIterator serverIterator = new ServerIterator(req, zombieServers);
-    String serverStr;
-    while ((serverStr = serverIterator.nextOrError(ex)) != null) {
+    EndpointIterator endpointIterator = new EndpointIterator(req, zombieServers);
+    Endpoint serverStr;
+    while ((serverStr = endpointIterator.nextOrError(ex)) != null) {
       try {
-        MDC.put("LBSolrClient.url", serverStr);
-        ex = doRequest(serverStr, req, rsp, isNonRetryable, serverIterator.isServingZombieServer());
+        MDC.put("LBSolrClient.url", serverStr.toString());
+        ex =
+            doRequest(
+                serverStr, req, rsp, isNonRetryable, endpointIterator.isServingZombieServer());
         if (ex == null) {
           return rsp; // SUCCESS
         }
@@ -381,12 +479,12 @@ public abstract class LBSolrClient extends SolrClient {
   }
 
   protected Exception doRequest(
-      String baseUrl, Req req, Rsp rsp, boolean isNonRetryable, boolean isZombie)
+      Endpoint baseUrl, Req req, Rsp rsp, boolean isNonRetryable, boolean isZombie)
       throws SolrServerException, IOException {
     Exception ex = null;
     try {
-      rsp.server = baseUrl;
-      req.getRequest().setBasePath(baseUrl);
+      rsp.server = baseUrl.toString();
+      req.getRequest().setBasePath(baseUrl.toString());
       rsp.rsp = getClient(baseUrl).request(req.getRequest(), (String) null);
       if (isZombie) {
         zombieServers.remove(baseUrl);
@@ -401,7 +499,7 @@ public abstract class LBSolrClient extends SolrClient {
       } else {
         // Server is alive but the request was likely malformed or invalid
         if (isZombie) {
-          zombieServers.remove(baseUrl);
+          zombieServers.remove(baseUrl.toString());
         }
         throw e;
       }
@@ -433,12 +531,12 @@ public abstract class LBSolrClient extends SolrClient {
     return ex;
   }
 
-  protected abstract SolrClient getClient(String baseUrl);
+  protected abstract SolrClient getClient(Endpoint endpoint);
 
-  protected Exception addZombie(String serverStr, Exception e) {
-    ServerWrapper wrapper = createServerWrapper(serverStr);
+  protected Exception addZombie(Endpoint serverStr, Exception e) {
+    EndpointWrapper wrapper = createServerWrapper(serverStr);
     wrapper.standard = false;
-    zombieServers.put(serverStr, wrapper);
+    zombieServers.put(serverStr.toString(), wrapper);
     startAliveCheckExecutor();
     return e;
   }
@@ -466,8 +564,8 @@ public abstract class LBSolrClient extends SolrClient {
     return () -> {
       LBSolrClient lb = lbRef.get();
       if (lb != null && lb.zombieServers != null) {
-        for (Object zombieServer : lb.zombieServers.values()) {
-          lb.checkAZombieServer((ServerWrapper) zombieServer);
+        for (EndpointWrapper zombieServer : lb.zombieServers.values()) {
+          lb.checkAZombieServer(zombieServer);
         }
       }
     };
@@ -481,17 +579,21 @@ public abstract class LBSolrClient extends SolrClient {
     return requestWriter;
   }
 
-  private void checkAZombieServer(ServerWrapper zombieServer) {
+  private void checkAZombieServer(EndpointWrapper zombieServer) {
+    final Endpoint zombieEndpoint = zombieServer.getEndpoint();
     try {
       QueryRequest queryRequest = new QueryRequest(solrQuery);
-      queryRequest.setBasePath(zombieServer.baseUrl);
-      QueryResponse resp = queryRequest.process(getClient(zombieServer.getBaseUrl()));
+      queryRequest.setBasePath(zombieEndpoint.getBaseUrl());
+      // First the one on the endpoint, then the default collection
+      final String effectiveCollection =
+          Objects.requireNonNullElse(zombieEndpoint.getCore(), getDefaultCollection());
+      QueryResponse resp = queryRequest.process(getClient(zombieEndpoint), effectiveCollection);
       if (resp.getStatus() == 0) {
         // server has come back up.
         // make sure to remove from zombies before adding to alive to avoid a race condition
         // where another thread could mark it down, move it back to zombie, and then we delete
         // from zombie and lose it forever.
-        ServerWrapper wrapper = zombieServers.remove(zombieServer.getBaseUrl());
+        EndpointWrapper wrapper = zombieServers.remove(zombieServer.getEndpoint().toString());
         if (wrapper != null) {
           wrapper.failedPings = 0;
           if (wrapper.standard) {
@@ -508,45 +610,36 @@ public abstract class LBSolrClient extends SolrClient {
       // If the server doesn't belong in the standard set belonging to this load balancer
       // then simply drop it after a certain number of failed pings.
       if (!zombieServer.standard && zombieServer.failedPings >= NONSTANDARD_PING_LIMIT) {
-        zombieServers.remove(zombieServer.getBaseUrl());
+        zombieServers.remove(zombieEndpoint.getUrl());
       }
     }
   }
 
-  private ServerWrapper removeFromAlive(String key) {
+  private EndpointWrapper removeFromAlive(String key) {
     synchronized (aliveServers) {
-      ServerWrapper wrapper = aliveServers.remove(key);
+      EndpointWrapper wrapper = aliveServers.remove(key);
       if (wrapper != null) updateAliveList();
       return wrapper;
     }
   }
 
-  private void addToAlive(ServerWrapper wrapper) {
+  private void addToAlive(EndpointWrapper wrapper) {
     synchronized (aliveServers) {
-      ServerWrapper prev = aliveServers.put(wrapper.getBaseUrl(), wrapper);
+      EndpointWrapper prev = aliveServers.put(wrapper.getEndpoint().getBaseUrl(), wrapper);
       // TODO: warn if there was a previous entry?
       updateAliveList();
     }
   }
 
-  public void addSolrServer(String server) throws MalformedURLException {
-    addToAlive(createServerWrapper(server));
+  public void addSolrServer(Endpoint endpoint) throws MalformedURLException {
+    addToAlive(createServerWrapper(endpoint));
   }
 
-  public String removeSolrServer(String server) {
-    try {
-      server = new URL(server).toExternalForm();
-    } catch (MalformedURLException e) {
-      throw new RuntimeException(e);
-    }
-    if (server.endsWith("/")) {
-      server = server.substring(0, server.length() - 1);
-    }
-
+  public String removeSolrServer(Endpoint endpoint) {
     // there is a small race condition here - if the server is in the process of being moved between
     // lists, we could fail to remove it.
-    removeFromAlive(server);
-    zombieServers.remove(server);
+    removeFromAlive(endpoint.getUrl());
+    zombieServers.remove(endpoint.getUrl());
     return null;
   }
 
@@ -570,11 +663,11 @@ public abstract class LBSolrClient extends SolrClient {
       final SolrRequest<?> request, String collection, final Integer numServersToTry)
       throws SolrServerException, IOException {
     Exception ex = null;
-    ServerWrapper[] serverList = aliveServerList;
+    EndpointWrapper[] serverList = aliveServerList;
 
     final int maxTries = (numServersToTry == null ? serverList.length : numServersToTry.intValue());
     int numServersTried = 0;
-    Map<String, ServerWrapper> justFailed = null;
+    Map<String, EndpointWrapper> justFailed = null;
     if (ClientUtils.shouldApplyDefaultCollection(collection, request))
       collection = defaultCollection;
 
@@ -587,11 +680,15 @@ public abstract class LBSolrClient extends SolrClient {
         break;
       }
 
-      ServerWrapper wrapper = pickServer(serverList, request);
+      EndpointWrapper wrapper = pickServer(serverList, request);
+      final var endpoint = wrapper.getEndpoint();
       try {
         ++numServersTried;
-        request.setBasePath(wrapper.baseUrl);
-        return getClient(wrapper.getBaseUrl()).request(request, collection);
+        request.setBasePath(endpoint.getBaseUrl());
+        // Choose the endpoint's core/collection over any specified by the user
+        final var effectiveCollection =
+            endpoint.getCore() == null ? collection : endpoint.getCore();
+        return getClient(endpoint).request(request, effectiveCollection);
       } catch (SolrException e) {
         // Server is alive but the request was malformed or invalid
         throw e;
@@ -600,7 +697,7 @@ public abstract class LBSolrClient extends SolrClient {
           ex = e;
           moveAliveToDead(wrapper);
           if (justFailed == null) justFailed = new HashMap<>();
-          justFailed.put(wrapper.getBaseUrl(), wrapper);
+          justFailed.put(endpoint.getUrl(), wrapper);
         } else {
           throw e;
         }
@@ -610,20 +707,23 @@ public abstract class LBSolrClient extends SolrClient {
     }
 
     // try other standard servers that we didn't try just now
-    for (ServerWrapper wrapper : zombieServers.values()) {
+    for (EndpointWrapper wrapper : zombieServers.values()) {
+      final var endpoint = wrapper.getEndpoint();
       timeAllowedExceeded = isTimeExceeded(timeAllowedNano, timeOutTime);
       if (timeAllowedExceeded) {
         break;
       }
 
       if (wrapper.standard == false
-          || (justFailed != null && justFailed.containsKey(wrapper.getBaseUrl()))) continue;
+          || (justFailed != null && justFailed.containsKey(endpoint.getUrl()))) continue;
       try {
         ++numServersTried;
-        request.setBasePath(wrapper.baseUrl);
-        NamedList<Object> rsp = getClient(wrapper.baseUrl).request(request, collection);
+        request.setBasePath(endpoint.getBaseUrl());
+        final String effectiveCollection =
+            endpoint.getCore() == null ? collection : endpoint.getCore();
+        NamedList<Object> rsp = getClient(endpoint).request(request, effectiveCollection);
         // remove from zombie list *before* adding to alive to avoid a race that could lose a server
-        zombieServers.remove(wrapper.getBaseUrl());
+        zombieServers.remove(endpoint.getUrl());
         addToAlive(wrapper);
         return rsp;
       } catch (SolrException e) {
@@ -671,15 +771,15 @@ public abstract class LBSolrClient extends SolrClient {
    * @param request the request will be sent to the picked server
    * @return the picked server
    */
-  protected ServerWrapper pickServer(ServerWrapper[] aliveServerList, SolrRequest<?> request) {
+  protected EndpointWrapper pickServer(EndpointWrapper[] aliveServerList, SolrRequest<?> request) {
     int count = counter.incrementAndGet() & Integer.MAX_VALUE;
     return aliveServerList[count % aliveServerList.length];
   }
 
-  private void moveAliveToDead(ServerWrapper wrapper) {
-    wrapper = removeFromAlive(wrapper.getBaseUrl());
+  private void moveAliveToDead(EndpointWrapper wrapper) {
+    wrapper = removeFromAlive(wrapper.getEndpoint().toString());
     if (wrapper == null) return; // another thread already detected the failure and removed it
-    zombieServers.put(wrapper.getBaseUrl(), wrapper);
+    zombieServers.put(wrapper.getEndpoint().toString(), wrapper);
     startAliveCheckExecutor();
   }
 
diff --git a/solr/solrj/src/java/org/apache/solr/client/solrj/request/UpdateRequest.java b/solr/solrj/src/java/org/apache/solr/client/solrj/request/UpdateRequest.java
index 53ca9bf5cc1..e89b47153b1 100644
--- a/solr/solrj/src/java/org/apache/solr/client/solrj/request/UpdateRequest.java
+++ b/solr/solrj/src/java/org/apache/solr/client/solrj/request/UpdateRequest.java
@@ -31,6 +31,7 @@ import java.util.Map;
 import java.util.Map.Entry;
 import java.util.Objects;
 import java.util.Set;
+import java.util.stream.Collectors;
 import org.apache.solr.client.solrj.SolrClient;
 import org.apache.solr.client.solrj.SolrServerException;
 import org.apache.solr.client.solrj.impl.LBSolrClient;
@@ -267,7 +268,12 @@ public class UpdateRequest extends AbstractUpdateRequest {
           updateRequest.setBasicAuthCredentials(getBasicAuthUser(), getBasicAuthPassword());
           updateRequest.setResponseParser(getResponseParser());
           updateRequest.addHeaders(getHeaders());
-          request = new LBSolrClient.Req(updateRequest, urls);
+          request =
+              new LBSolrClient.Req(
+                  updateRequest,
+                  urls.stream()
+                      .map(url -> LBSolrClient.Endpoint.from(url))
+                      .collect(Collectors.toList()));
           routes.put(leaderUrl, request);
         }
         UpdateRequest urequest = (UpdateRequest) request.getRequest();
@@ -320,7 +326,12 @@ public class UpdateRequest extends AbstractUpdateRequest {
           urequest.deleteById(deleteId, route, version);
           urequest.setCommitWithin(getCommitWithin());
           urequest.setBasicAuthCredentials(getBasicAuthUser(), getBasicAuthPassword());
-          request = new LBSolrClient.Req(urequest, urls);
+          request =
+              new LBSolrClient.Req(
+                  urequest,
+                  urls.stream()
+                      .map(url -> LBSolrClient.Endpoint.from(url))
+                      .collect(Collectors.toList()));
           routes.put(leaderUrl, request);
         }
       }
diff --git a/solr/solrj/src/test/org/apache/solr/client/solrj/TestLBHttp2SolrClient.java b/solr/solrj/src/test/org/apache/solr/client/solrj/TestLBHttp2SolrClient.java
index ac8a358e7d9..4eeb7407c06 100644
--- a/solr/solrj/src/test/org/apache/solr/client/solrj/TestLBHttp2SolrClient.java
+++ b/solr/solrj/src/test/org/apache/solr/client/solrj/TestLBHttp2SolrClient.java
@@ -31,6 +31,7 @@ import org.apache.lucene.util.IOUtils;
 import org.apache.solr.SolrTestCaseJ4;
 import org.apache.solr.client.solrj.impl.Http2SolrClient;
 import org.apache.solr.client.solrj.impl.LBHttp2SolrClient;
+import org.apache.solr.client.solrj.impl.LBSolrClient;
 import org.apache.solr.client.solrj.response.QueryResponse;
 import org.apache.solr.client.solrj.response.SolrResponseBase;
 import org.apache.solr.common.SolrInputDocument;
@@ -123,18 +124,16 @@ public class TestLBHttp2SolrClient extends SolrTestCaseJ4 {
   }
 
   public void testSimple() throws Exception {
-    String[] solrUrls = new String[solr.length];
-    for (int i = 0; i < solr.length; i++) {
-      solrUrls[i] = solr[i].getUrl();
-    }
+    final var baseSolrEndpoints = bootstrapBaseSolrEndpoints(solr.length);
     try (LBHttp2SolrClient client =
-        new LBHttp2SolrClient.Builder(httpClient, solrUrls)
+        new LBHttp2SolrClient.Builder(httpClient, baseSolrEndpoints)
+            .withDefaultCollection(solr[0].getDefaultCollection())
             .setAliveCheckInterval(500, TimeUnit.MILLISECONDS)
             .build()) {
       SolrQuery solrQuery = new SolrQuery("*:*");
       Set<String> names = new HashSet<>();
       QueryResponse resp = null;
-      for (String ignored : solrUrls) {
+      for (int i = 0; i < solr.length; i++) {
         resp = client.query(solrQuery);
         assertEquals(10, resp.getResults().getNumFound());
         names.add(resp.getResults().get(0).getFieldValue("name").toString());
@@ -145,7 +144,7 @@ public class TestLBHttp2SolrClient extends SolrTestCaseJ4 {
       solr[1].jetty.stop();
       solr[1].jetty = null;
       names.clear();
-      for (String ignored : solrUrls) {
+      for (int i = 0; i < solr.length; i++) {
         resp = client.query(solrQuery);
         assertEquals(10, resp.getResults().getNumFound());
         names.add(resp.getResults().get(0).getFieldValue("name").toString());
@@ -158,7 +157,7 @@ public class TestLBHttp2SolrClient extends SolrTestCaseJ4 {
       // Wait for the alive check to complete
       Thread.sleep(1200);
       names.clear();
-      for (String ignored : solrUrls) {
+      for (int i = 0; i < solr.length; i++) {
         resp = client.query(solrQuery);
         assertEquals(10, resp.getResults().getNumFound());
         names.add(resp.getResults().get(0).getFieldValue("name").toString());
@@ -167,17 +166,11 @@ public class TestLBHttp2SolrClient extends SolrTestCaseJ4 {
     }
   }
 
-  private LBHttp2SolrClient getLBHttp2SolrClient(Http2SolrClient httpClient, String... s) {
-    return new LBHttp2SolrClient.Builder(httpClient, s).build();
-  }
-
   public void testTwoServers() throws Exception {
-    String[] solrUrls = new String[2];
-    for (int i = 0; i < 2; i++) {
-      solrUrls[i] = solr[i].getUrl();
-    }
+    final var baseSolrEndpoints = bootstrapBaseSolrEndpoints(2);
     try (LBHttp2SolrClient client =
-        new LBHttp2SolrClient.Builder(httpClient, solrUrls)
+        new LBHttp2SolrClient.Builder(httpClient, baseSolrEndpoints)
+            .withDefaultCollection(solr[0].getDefaultCollection())
             .setAliveCheckInterval(500, TimeUnit.MILLISECONDS)
             .build()) {
       SolrQuery solrQuery = new SolrQuery("*:*");
@@ -207,13 +200,11 @@ public class TestLBHttp2SolrClient extends SolrTestCaseJ4 {
   }
 
   public void testReliability() throws Exception {
-    String[] solrUrls = new String[solr.length];
-    for (int i = 0; i < solr.length; i++) {
-      solrUrls[i] = solr[i].getUrl();
-    }
+    final var baseSolrEndpoints = bootstrapBaseSolrEndpoints(solr.length);
 
     try (LBHttp2SolrClient client =
-        new LBHttp2SolrClient.Builder(httpClient, solrUrls)
+        new LBHttp2SolrClient.Builder(httpClient, baseSolrEndpoints)
+            .withDefaultCollection(solr[0].getDefaultCollection())
             .setAliveCheckInterval(500, TimeUnit.MILLISECONDS)
             .build()) {
 
@@ -222,7 +213,7 @@ public class TestLBHttp2SolrClient extends SolrTestCaseJ4 {
       solr[1].jetty = null;
 
       // query the servers
-      for (String ignored : solrUrls) client.query(new SolrQuery("*:*"));
+      for (int i = 0; i < solr.length; i++) client.query(new SolrQuery("*:*"));
 
       // Start the killed server once again
       solr[1].startJetty();
@@ -250,6 +241,14 @@ public class TestLBHttp2SolrClient extends SolrTestCaseJ4 {
     }
   }
 
+  private LBSolrClient.Endpoint[] bootstrapBaseSolrEndpoints(int max) {
+    LBSolrClient.Endpoint[] solrUrls = new LBSolrClient.Endpoint[max];
+    for (int i = 0; i < max; i++) {
+      solrUrls[i] = new LBSolrClient.Endpoint(solr[i].getBaseUrl());
+    }
+    return solrUrls;
+  }
+
   private static class SolrInstance {
     String name;
     File homeDir;
diff --git a/solr/solrj/src/test/org/apache/solr/client/solrj/TestLBHttpSolrClient.java b/solr/solrj/src/test/org/apache/solr/client/solrj/TestLBHttpSolrClient.java
index fea222fcffe..d9ad7a47176 100644
--- a/solr/solrj/src/test/org/apache/solr/client/solrj/TestLBHttpSolrClient.java
+++ b/solr/solrj/src/test/org/apache/solr/client/solrj/TestLBHttpSolrClient.java
@@ -126,18 +126,19 @@ public class TestLBHttpSolrClient extends SolrTestCaseJ4 {
   public void testSimple() throws Exception {
     String[] solrUrls = new String[solr.length];
     for (int i = 0; i < solr.length; i++) {
-      solrUrls[i] = solr[i].getUrl();
+      solrUrls[i] = solr[i].getBaseUrl();
     }
     try (LBHttpSolrClient client =
         new LBHttpSolrClient.Builder()
             .withHttpClient(httpClient)
-            .withBaseSolrUrls(solrUrls)
+            .withBaseEndpoints(solrUrls)
+            .withDefaultCollection(solr[0].getDefaultCollection())
             .setAliveCheckInterval(500)
             .build()) {
       SolrQuery solrQuery = new SolrQuery("*:*");
       Set<String> names = new HashSet<>();
       QueryResponse resp = null;
-      for (String value : solrUrls) {
+      for (int i = 0; i < solr.length; i++) {
         resp = client.query(solrQuery);
         assertEquals(10, resp.getResults().getNumFound());
         names.add(resp.getResults().get(0).getFieldValue("name").toString());
@@ -148,7 +149,7 @@ public class TestLBHttpSolrClient extends SolrTestCaseJ4 {
       solr[1].jetty.stop();
       solr[1].jetty = null;
       names.clear();
-      for (String value : solrUrls) {
+      for (int i = 0; i < solr.length; i++) {
         resp = client.query(solrQuery);
         assertEquals(10, resp.getResults().getNumFound());
         names.add(resp.getResults().get(0).getFieldValue("name").toString());
@@ -161,7 +162,7 @@ public class TestLBHttpSolrClient extends SolrTestCaseJ4 {
       // Wait for the alive check to complete
       Thread.sleep(1200);
       names.clear();
-      for (String value : solrUrls) {
+      for (int i = 0; i < solr.length; i++) {
         resp = client.query(solrQuery);
         assertEquals(10, resp.getResults().getNumFound());
         names.add(resp.getResults().get(0).getFieldValue("name").toString());
@@ -173,12 +174,13 @@ public class TestLBHttpSolrClient extends SolrTestCaseJ4 {
   public void testTwoServers() throws Exception {
     String[] solrUrls = new String[2];
     for (int i = 0; i < 2; i++) {
-      solrUrls[i] = solr[i].getUrl();
+      solrUrls[i] = solr[i].getBaseUrl();
     }
     try (LBHttpSolrClient client =
         new LBHttpSolrClient.Builder()
             .withHttpClient(httpClient)
-            .withBaseSolrUrls(solrUrls)
+            .withBaseEndpoints(solrUrls)
+            .withDefaultCollection(solr[0].getDefaultCollection())
             .setAliveCheckInterval(500)
             .build()) {
       SolrQuery solrQuery = new SolrQuery("*:*");
@@ -210,13 +212,14 @@ public class TestLBHttpSolrClient extends SolrTestCaseJ4 {
   public void testReliability() throws Exception {
     String[] solrUrls = new String[solr.length];
     for (int i = 0; i < solr.length; i++) {
-      solrUrls[i] = solr[i].getUrl();
+      solrUrls[i] = solr[i].getBaseUrl();
     }
 
     try (LBHttpSolrClient client =
         new LBHttpSolrClient.Builder()
             .withHttpClient(httpClient)
-            .withBaseSolrUrls(solrUrls)
+            .withBaseEndpoints(solrUrls)
+            .withDefaultCollection(solr[0].getDefaultCollection())
             .withConnectionTimeout(500, TimeUnit.MILLISECONDS)
             .withSocketTimeout(500, TimeUnit.MILLISECONDS)
             .setAliveCheckInterval(500)
diff --git a/solr/solrj/src/test/org/apache/solr/client/solrj/impl/CloudHttp2SolrClientTest.java b/solr/solrj/src/test/org/apache/solr/client/solrj/impl/CloudHttp2SolrClientTest.java
index 0934fd5d331..0b82d36a31d 100644
--- a/solr/solrj/src/test/org/apache/solr/client/solrj/impl/CloudHttp2SolrClientTest.java
+++ b/solr/solrj/src/test/org/apache/solr/client/solrj/impl/CloudHttp2SolrClientTest.java
@@ -1038,9 +1038,9 @@ public class CloudHttp2SolrClientTest extends SolrCloudTestCase {
     while (it.hasNext()) {
       Map.Entry<String, ? extends LBSolrClient.Req> entry = it.next();
       assertEquals(
-          "wrong number of servers: " + entry.getValue().getServers(),
+          "wrong number of servers: " + entry.getValue().getEndpoints(),
           1,
-          entry.getValue().getServers().size());
+          entry.getValue().getEndpoints().size());
     }
   }
 
diff --git a/solr/solrj/src/test/org/apache/solr/client/solrj/impl/CloudSolrClientCacheTest.java b/solr/solrj/src/test/org/apache/solr/client/solrj/impl/CloudSolrClientCacheTest.java
index 9603dccbac3..406023254c2 100644
--- a/solr/solrj/src/test/org/apache/solr/client/solrj/impl/CloudSolrClientCacheTest.java
+++ b/solr/solrj/src/test/org/apache/solr/client/solrj/impl/CloudSolrClientCacheTest.java
@@ -128,7 +128,7 @@ public class CloudSolrClientCacheTest extends SolrTestCaseJ4 {
               if (res instanceof Exception) throw (Throwable) res;
               LBSolrClient.Rsp rsp = new LBSolrClient.Rsp();
               rsp.rsp = (NamedList<Object>) res;
-              rsp.server = req.servers.get(0);
+              rsp.server = req.endpoints.get(0).toString();
               return rsp;
             });
     return mockLbclient;
diff --git a/solr/solrj/src/test/org/apache/solr/client/solrj/impl/CloudSolrClientTest.java b/solr/solrj/src/test/org/apache/solr/client/solrj/impl/CloudSolrClientTest.java
index 8c2705bcc62..3dc395c2f0b 100644
--- a/solr/solrj/src/test/org/apache/solr/client/solrj/impl/CloudSolrClientTest.java
+++ b/solr/solrj/src/test/org/apache/solr/client/solrj/impl/CloudSolrClientTest.java
@@ -1035,9 +1035,9 @@ public class CloudSolrClientTest extends SolrCloudTestCase {
     while (it.hasNext()) {
       Map.Entry<String, LBSolrClient.Req> entry = it.next();
       assertEquals(
-          "wrong number of servers: " + entry.getValue().getServers(),
+          "wrong number of servers: " + entry.getValue().getEndpoints(),
           1,
-          entry.getValue().getServers().size());
+          entry.getValue().getEndpoints().size());
     }
   }
 
diff --git a/solr/solrj/src/test/org/apache/solr/client/solrj/impl/HttpSolrClientConPoolTest.java b/solr/solrj/src/test/org/apache/solr/client/solrj/impl/HttpSolrClientConPoolTest.java
index e3c589dae6b..2c654248a4d 100644
--- a/solr/solrj/src/test/org/apache/solr/client/solrj/impl/HttpSolrClientConPoolTest.java
+++ b/solr/solrj/src/test/org/apache/solr/client/solrj/impl/HttpSolrClientConPoolTest.java
@@ -122,8 +122,8 @@ public class HttpSolrClientConPoolTest extends SolrJettyTestBase {
     try {
       final LBHttpSolrClient roundRobin =
           new LBHttpSolrClient.Builder()
-              .withBaseSolrUrl(fooUrl)
-              .withBaseSolrUrl(barUrl)
+              .withBaseEndpoint(fooUrl)
+              .withBaseEndpoint(barUrl)
               .withDefaultCollection(DEFAULT_TEST_COLLECTION_NAME)
               .withHttpClient(httpClient)
               .build();
@@ -162,7 +162,8 @@ public class HttpSolrClientConPoolTest extends SolrJettyTestBase {
           } else {
             final UpdateRequest updateRequest = new UpdateRequest();
             updateRequest.add(doc); // here we mimic CloudSolrClient impl
-            final List<String> urls = Arrays.asList(fooUrl, barUrl);
+            final List<LBSolrClient.Endpoint> urls =
+                Arrays.asList(new LBSolrClient.Endpoint(fooUrl), new LBSolrClient.Endpoint(barUrl));
             Collections.shuffle(urls, random());
             LBSolrClient.Req req = new LBSolrClient.Req(updateRequest, urls);
             roundRobin.request(req);
diff --git a/solr/solrj/src/test/org/apache/solr/client/solrj/impl/LBHttp2SolrClientTest.java b/solr/solrj/src/test/org/apache/solr/client/solrj/impl/LBHttp2SolrClientTest.java
index 78f3251ff62..ebf3a5da31f 100644
--- a/solr/solrj/src/test/org/apache/solr/client/solrj/impl/LBHttp2SolrClientTest.java
+++ b/solr/solrj/src/test/org/apache/solr/client/solrj/impl/LBHttp2SolrClientTest.java
@@ -38,7 +38,8 @@ public class LBHttp2SolrClientTest extends SolrTestCase {
     try (Http2SolrClient http2SolrClient =
             new Http2SolrClient.Builder(url).withTheseParamNamesInTheUrl(urlParamNames).build();
         LBHttp2SolrClient testClient =
-            new LBHttp2SolrClient.Builder(http2SolrClient, url).build()) {
+            new LBHttp2SolrClient.Builder(http2SolrClient, new LBSolrClient.Endpoint(url))
+                .build()) {
 
       assertArrayEquals(
           "Wrong urlParamNames found in lb client.",
diff --git a/solr/solrj/src/test/org/apache/solr/client/solrj/impl/LBHttpSolrClientBadInputTest.java b/solr/solrj/src/test/org/apache/solr/client/solrj/impl/LBHttpSolrClientBadInputTest.java
index 0432c26add5..4e50201e18d 100644
--- a/solr/solrj/src/test/org/apache/solr/client/solrj/impl/LBHttpSolrClientBadInputTest.java
+++ b/solr/solrj/src/test/org/apache/solr/client/solrj/impl/LBHttpSolrClientBadInputTest.java
@@ -42,7 +42,8 @@ public class LBHttpSolrClientBadInputTest extends SolrJettyTestBase {
   public void testDeleteByIdReportsInvalidIdLists() throws Exception {
     try (SolrClient client =
         new LBHttpSolrClient.Builder()
-            .withBaseSolrUrls(getBaseUrl() + "/" + ANY_COLLECTION)
+            .withBaseEndpoint(getBaseUrl())
+            .withDefaultCollection(ANY_COLLECTION)
             .build()) {
       assertExceptionThrownWithMessageContaining(
           IllegalArgumentException.class,
@@ -71,7 +72,7 @@ public class LBHttpSolrClientBadInputTest extends SolrJettyTestBase {
     }
 
     try (SolrClient client =
-        new LBHttpSolrClient.Builder().withBaseSolrUrls(getBaseUrl()).build()) {
+        new LBHttpSolrClient.Builder().withBaseEndpoint(getBaseUrl()).build()) {
       assertExceptionThrownWithMessageContaining(
           IllegalArgumentException.class,
           List.of("ids", "null"),
diff --git a/solr/solrj/src/test/org/apache/solr/client/solrj/impl/LBHttpSolrClientBuilderTest.java b/solr/solrj/src/test/org/apache/solr/client/solrj/impl/LBHttpSolrClientBuilderTest.java
index 6db869cf96b..03f1413845d 100644
--- a/solr/solrj/src/test/org/apache/solr/client/solrj/impl/LBHttpSolrClientBuilderTest.java
+++ b/solr/solrj/src/test/org/apache/solr/client/solrj/impl/LBHttpSolrClientBuilderTest.java
@@ -35,7 +35,7 @@ public class LBHttpSolrClientBuilderTest extends SolrTestCase {
   @Test
   public void providesHttpClientToClient() {
     try (LBHttpSolrClient createdClient =
-        new Builder().withBaseSolrUrl(ANY_BASE_SOLR_URL).withHttpClient(ANY_HTTP_CLIENT).build()) {
+        new Builder().withBaseEndpoint(ANY_BASE_SOLR_URL).withHttpClient(ANY_HTTP_CLIENT).build()) {
       assertEquals(createdClient.getHttpClient(), ANY_HTTP_CLIENT);
     }
   }
@@ -44,7 +44,7 @@ public class LBHttpSolrClientBuilderTest extends SolrTestCase {
   public void providesResponseParserToClient() {
     try (LBHttpSolrClient createdClient =
         new Builder()
-            .withBaseSolrUrl(ANY_BASE_SOLR_URL)
+            .withBaseEndpoint(ANY_BASE_SOLR_URL)
             .withResponseParser(ANY_RESPONSE_PARSER)
             .build()) {
       assertEquals(createdClient.getParser(), ANY_RESPONSE_PARSER);
@@ -54,7 +54,7 @@ public class LBHttpSolrClientBuilderTest extends SolrTestCase {
   @Test
   public void testDefaultsToBinaryResponseParserWhenNoneProvided() {
     try (LBHttpSolrClient createdClient =
-        new Builder().withBaseSolrUrl(ANY_BASE_SOLR_URL).build()) {
+        new Builder().withBaseEndpoint(ANY_BASE_SOLR_URL).build()) {
       final ResponseParser usedParser = createdClient.getParser();
 
       assertTrue(usedParser instanceof BinaryResponseParser);
@@ -70,7 +70,7 @@ public class LBHttpSolrClientBuilderTest extends SolrTestCase {
     HttpClient httpClient = HttpClientUtil.createClient(clientParams);
 
     try (LBHttpSolrClient createdClient =
-        new Builder().withBaseSolrUrl(ANY_BASE_SOLR_URL).withHttpClient(httpClient).build()) {
+        new Builder().withBaseEndpoint(ANY_BASE_SOLR_URL).withHttpClient(httpClient).build()) {
       assertEquals(createdClient.getHttpClient(), httpClient);
       assertEquals(67890, createdClient.connectionTimeoutMillis);
       assertEquals(12345, createdClient.soTimeoutMillis);
@@ -82,7 +82,7 @@ public class LBHttpSolrClientBuilderTest extends SolrTestCase {
   public void testDefaultCollectionPassedFromBuilderToClient() throws IOException {
     try (LBHttpSolrClient createdClient =
         new LBHttpSolrClient.Builder()
-            .withBaseSolrUrl(ANY_BASE_SOLR_URL)
+            .withBaseEndpoint(ANY_BASE_SOLR_URL)
             .withDefaultCollection("aCollection")
             .build()) {
       assertEquals("aCollection", createdClient.getDefaultCollection());
diff --git a/solr/solrj/src/test/org/apache/solr/client/solrj/impl/LBHttpSolrClientTest.java b/solr/solrj/src/test/org/apache/solr/client/solrj/impl/LBHttpSolrClientTest.java
index 7a71a699ec8..226997c32c9 100644
--- a/solr/solrj/src/test/org/apache/solr/client/solrj/impl/LBHttpSolrClientTest.java
+++ b/solr/solrj/src/test/org/apache/solr/client/solrj/impl/LBHttpSolrClientTest.java
@@ -40,7 +40,8 @@ public class LBHttpSolrClientTest extends SolrTestCase {
                 .withHttpClient(httpClient)
                 .withResponseParser(null)
                 .build();
-        HttpSolrClient httpSolrClient = testClient.makeSolrClient("http://127.0.0.1:8080")) {
+        HttpSolrClient httpSolrClient =
+            testClient.makeSolrClient(new LBSolrClient.Endpoint("http://127.0.0.1:8080"))) {
       assertNull("Generated server should have null parser.", httpSolrClient.getParser());
     } finally {
       HttpClientUtil.close(httpClient);
@@ -54,7 +55,8 @@ public class LBHttpSolrClientTest extends SolrTestCase {
                   .withHttpClient(httpClient)
                   .withResponseParser(parser)
                   .build();
-          HttpSolrClient httpSolrClient = testClient.makeSolrClient("http://127.0.0.1:8080")) {
+          HttpSolrClient httpSolrClient =
+              testClient.makeSolrClient(new LBSolrClient.Endpoint("http://127.0.0.1:8080"))) {
         assertEquals(
             "Invalid parser passed to generated server.", parser, httpSolrClient.getParser());
       }
diff --git a/solr/solrj/src/test/org/apache/solr/client/solrj/impl/LBSolrClientTest.java b/solr/solrj/src/test/org/apache/solr/client/solrj/impl/LBSolrClientTest.java
index c21cd44e297..b29629b5db2 100644
--- a/solr/solrj/src/test/org/apache/solr/client/solrj/impl/LBSolrClientTest.java
+++ b/solr/solrj/src/test/org/apache/solr/client/solrj/impl/LBSolrClientTest.java
@@ -18,9 +18,9 @@
 package org.apache.solr.client.solrj.impl;
 
 import java.util.ArrayList;
-import java.util.Arrays;
 import java.util.HashMap;
 import java.util.List;
+import java.util.stream.Collectors;
 import org.apache.lucene.tests.util.LuceneTestCase;
 import org.apache.solr.SolrTestCase;
 import org.apache.solr.client.solrj.SolrServerException;
@@ -31,64 +31,114 @@ import org.junit.Test;
 
 public class LBSolrClientTest extends SolrTestCase {
 
+  private static final List<LBSolrClient.Endpoint> SOLR_ENDPOINTS =
+      List.of("1", "2", "3", "4").stream()
+          .map(url -> new LBSolrClient.Endpoint(url))
+          .collect(Collectors.toList());
+
+  @Test
+  public void testEndpointCorrectlyBuildsFullUrl() {
+    final var baseUrlEndpoint = new LBSolrClient.Endpoint("http://localhost:8983/solr");
+    assertEquals("http://localhost:8983/solr", baseUrlEndpoint.getUrl());
+    assertEquals("http://localhost:8983/solr", baseUrlEndpoint.getBaseUrl());
+    assertNull(
+        "Expected core to be null, but was: " + baseUrlEndpoint.getCore(),
+        baseUrlEndpoint.getCore());
+
+    final var coreEndpoint = new LBSolrClient.Endpoint("http://localhost:8983/solr", "collection1");
+    assertEquals("http://localhost:8983/solr/collection1", coreEndpoint.getUrl());
+    assertEquals("http://localhost:8983/solr", coreEndpoint.getBaseUrl());
+    assertEquals("collection1", coreEndpoint.getCore());
+  }
+
+  @Test
+  public void testEndpointNormalizesProvidedBaseUrl() {
+    final var normalizedBaseUrl = "http://localhost:8983/solr";
+    final var noTrailingSlash = new LBSolrClient.Endpoint(normalizedBaseUrl);
+    final var trailingSlash = new LBSolrClient.Endpoint(normalizedBaseUrl + "/");
+
+    assertEquals(normalizedBaseUrl, noTrailingSlash.getBaseUrl());
+    assertEquals(normalizedBaseUrl, noTrailingSlash.getUrl());
+    assertEquals(normalizedBaseUrl, trailingSlash.getBaseUrl());
+    assertEquals(normalizedBaseUrl, trailingSlash.getUrl());
+  }
+
+  @Test
+  public void testEndpointFactoryParsesUrlsCorrectly() {
+    final var parsedFromBaseUrl =
+        LBSolrClient.Endpoint.from("http://localhost:8983/solr" + rareTrailingSlash());
+    assertEquals("http://localhost:8983/solr", parsedFromBaseUrl.getBaseUrl());
+    assertNull(
+        "Expected core to be null, but was: " + parsedFromBaseUrl.getCore(),
+        parsedFromBaseUrl.getCore());
+
+    final var parsedFromCoreUrl =
+        LBSolrClient.Endpoint.from("http://localhost:8983/solr/collection1" + rareTrailingSlash());
+    assertEquals("http://localhost:8983/solr", parsedFromCoreUrl.getBaseUrl());
+    assertEquals("collection1", parsedFromCoreUrl.getCore());
+  }
+
   @Test
   public void testServerIterator() throws SolrServerException {
-    LBSolrClient.Req req =
-        new LBSolrClient.Req(new QueryRequest(), Arrays.asList("1", "2", "3", "4"));
-    LBSolrClient.ServerIterator serverIterator =
-        new LBSolrClient.ServerIterator(req, new HashMap<>());
-    List<String> actualServers = new ArrayList<>();
-    while (serverIterator.hasNext()) {
-      actualServers.add(serverIterator.nextOrError());
+    LBSolrClient.Req req = new LBSolrClient.Req(new QueryRequest(), SOLR_ENDPOINTS);
+    LBSolrClient.EndpointIterator endpointIterator =
+        new LBSolrClient.EndpointIterator(req, new HashMap<>());
+    List<LBSolrClient.Endpoint> actualServers = new ArrayList<>();
+    while (endpointIterator.hasNext()) {
+      actualServers.add(endpointIterator.nextOrError());
     }
-    assertEquals(Arrays.asList("1", "2", "3", "4"), actualServers);
-    assertFalse(serverIterator.hasNext());
-    LuceneTestCase.expectThrows(SolrServerException.class, serverIterator::nextOrError);
+    assertEquals(SOLR_ENDPOINTS, actualServers);
+    assertFalse(endpointIterator.hasNext());
+    LuceneTestCase.expectThrows(SolrServerException.class, endpointIterator::nextOrError);
   }
 
   @Test
   public void testServerIteratorWithZombieServers() throws SolrServerException {
-    HashMap<String, LBSolrClient.ServerWrapper> zombieServers = new HashMap<>();
-    LBSolrClient.Req req =
-        new LBSolrClient.Req(new QueryRequest(), Arrays.asList("1", "2", "3", "4"));
-    LBSolrClient.ServerIterator serverIterator =
-        new LBSolrClient.ServerIterator(req, zombieServers);
-    zombieServers.put("2", new LBSolrClient.ServerWrapper("2"));
-
-    assertTrue(serverIterator.hasNext());
-    assertEquals("1", serverIterator.nextOrError());
-    assertTrue(serverIterator.hasNext());
-    assertEquals("3", serverIterator.nextOrError());
-    assertTrue(serverIterator.hasNext());
-    assertEquals("4", serverIterator.nextOrError());
-    assertTrue(serverIterator.hasNext());
-    assertEquals("2", serverIterator.nextOrError());
+    HashMap<String, LBSolrClient.EndpointWrapper> zombieServers = new HashMap<>();
+    LBSolrClient.Req req = new LBSolrClient.Req(new QueryRequest(), SOLR_ENDPOINTS);
+    LBSolrClient.EndpointIterator endpointIterator =
+        new LBSolrClient.EndpointIterator(req, zombieServers);
+    zombieServers.put("2", new LBSolrClient.EndpointWrapper(new LBSolrClient.Endpoint("2")));
+
+    assertTrue(endpointIterator.hasNext());
+    assertEquals(new LBSolrClient.Endpoint("1"), endpointIterator.nextOrError());
+    assertTrue(endpointIterator.hasNext());
+    assertEquals(new LBSolrClient.Endpoint("2"), endpointIterator.nextOrError());
+    assertTrue(endpointIterator.hasNext());
+    assertEquals(new LBSolrClient.Endpoint("3"), endpointIterator.nextOrError());
+    assertTrue(endpointIterator.hasNext());
+    assertEquals(new LBSolrClient.Endpoint("4"), endpointIterator.nextOrError());
   }
 
   @Test
   public void testServerIteratorTimeAllowed() throws SolrServerException, InterruptedException {
     ModifiableSolrParams params = new ModifiableSolrParams();
     params.set(CommonParams.TIME_ALLOWED, 300);
-    LBSolrClient.Req req =
-        new LBSolrClient.Req(new QueryRequest(params), Arrays.asList("1", "2", "3", "4"), 2);
-    LBSolrClient.ServerIterator serverIterator =
-        new LBSolrClient.ServerIterator(req, new HashMap<>());
-    assertTrue(serverIterator.hasNext());
-    serverIterator.nextOrError();
+    LBSolrClient.Req req = new LBSolrClient.Req(new QueryRequest(params), SOLR_ENDPOINTS, 2);
+    LBSolrClient.EndpointIterator endpointIterator =
+        new LBSolrClient.EndpointIterator(req, new HashMap<>());
+    assertTrue(endpointIterator.hasNext());
+    endpointIterator.nextOrError();
     Thread.sleep(300);
-    LuceneTestCase.expectThrows(SolrServerException.class, serverIterator::nextOrError);
+    LuceneTestCase.expectThrows(SolrServerException.class, endpointIterator::nextOrError);
   }
 
   @Test
   public void testServerIteratorMaxRetry() throws SolrServerException {
-    LBSolrClient.Req req =
-        new LBSolrClient.Req(new QueryRequest(), Arrays.asList("1", "2", "3", "4"), 2);
-    LBSolrClient.ServerIterator serverIterator =
-        new LBSolrClient.ServerIterator(req, new HashMap<>());
-    assertTrue(serverIterator.hasNext());
-    serverIterator.nextOrError();
-    assertTrue(serverIterator.hasNext());
-    serverIterator.nextOrError();
-    LuceneTestCase.expectThrows(SolrServerException.class, serverIterator::nextOrError);
+    LBSolrClient.Req req = new LBSolrClient.Req(new QueryRequest(), SOLR_ENDPOINTS, 2);
+    LBSolrClient.EndpointIterator endpointIterator =
+        new LBSolrClient.EndpointIterator(req, new HashMap<>());
+    assertTrue(endpointIterator.hasNext());
+    endpointIterator.nextOrError();
+    assertTrue(endpointIterator.hasNext());
+    endpointIterator.nextOrError();
+    LuceneTestCase.expectThrows(SolrServerException.class, endpointIterator::nextOrError);
+  }
+
+  private String rareTrailingSlash() {
+    if (rarely()) {
+      return "/";
+    }
+    return "";
   }
 }