You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@cassandra.apache.org by dj...@apache.org on 2023/05/19 22:35:24 UTC

[cassandra-sidecar] branch trunk updated: CEP-28: Implement Bulk API endpoints and introduce the Sidecar Client to Support Cassandra Analytics

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

djoshi pushed a commit to branch trunk
in repository https://gitbox.apache.org/repos/asf/cassandra-sidecar.git


The following commit(s) were added to refs/heads/trunk by this push:
     new 38cdacb  CEP-28: Implement Bulk API endpoints and introduce the Sidecar Client to Support Cassandra Analytics
38cdacb is described below

commit 38cdacb2e7418e2aefbcffb1754dcd324c46028d
Author: Dinesh Joshi <dj...@apache.org>
AuthorDate: Fri May 19 15:34:05 2023 -0700

    CEP-28: Implement Bulk API endpoints and introduce the Sidecar Client to Support Cassandra Analytics
    
    This commit implements the remaining endpoints needed to perform Bulk Analytics operations that allow
    reading and writing data from Cassandra in Bulk. The new endpoints include:
    
    - Endpoint to create snapshots
    - Endpoint to clear a snapshot
    - Endpoint to upload SSTable components
    - Endpoint to clean up uploads for SSTable components
    - Endpoint to import SSTable components
    - Endpoint to retrieve gossip info
    - Endpoint to retrieve the time skew for the server
    - Endpoint to retrieve the ring information
    
    Sidecar Client
    
    Introduces the fully featured sidecar client to access Cassandra Sidecar endpoints.
    It offers support for retries and Sidecar instance selection policies. The client
    project itself is technology-agnostic, but we provide a vertx implementation for
    the `HttpClient`. The Sidecar vertx-client can be published as a shaded-jar to be
    consumed by clients where the dependencies can cause issues, especially in environments
    where the dependencies are not always controlled by the consumers (for example Spark).
    
    Patch by Doug, Francisco, Saranya, Yifan, Dinesh; reviewed by Dinesh Joshi and Yifan Cai for CASSANDRA-16222
    
    Co-authored-by: Saranya Krishnakumar <sa...@apple.com>
    Co-authored-by: Yifan Cai <yc...@apache.org>
    Co-authored-by: Francisco Guerrero <fr...@apple.com>
    Co-authored-by: Doug Rohrer <dr...@apple.com>
    Co-authored-by: Dinesh Joshi <dj...@apache.org>
---
 .circleci/config.yml                               |   8 +-
 .gitignore                                         |   2 +-
 CONTRIBUTING.md                                    |  24 +-
 build.gradle                                       |  67 +-
 cassandra-integration-tests/build.gradle           |  39 -
 .../src/test/resources/logback-test.xml            |  12 -
 cassandra40/build.gradle                           |  36 +-
 ...=> Cassandra40ClusterMembershipOperations.java} |  25 +-
 .../sidecar/cassandra40/Cassandra40Factory.java    | 102 ++-
 .../cassandra40/Cassandra40StorageOperations.java  |  38 +-
 .../cassandra40/Cassandra40TableOperations.java    |  72 ++
 .../ClusterMembershipJmxOperations.java            |  17 +-
 .../cassandra40/EndpointSnitchJmxOperations.java   |  35 +-
 .../sidecar/cassandra40/RingProvider.java          | 211 +++++
 .../sidecar/cassandra40/StorageJmxOperations.java  | 101 ++-
 .../sidecar/cassandra40/TableJmxOperations.java    |  53 ++
 checkstyle.xml                                     |   1 +
 client/README.md                                   |  45 +
 client/build.gradle                                |  94 ++
 .../cassandra/sidecar/client/HttpClient.java       |  54 ++
 .../cassandra/sidecar/client/HttpClientConfig.java | 398 +++++++++
 .../cassandra/sidecar/client/HttpResponse.java     |  65 ++
 .../cassandra/sidecar/client/HttpResponseImpl.java | 133 +++
 .../cassandra/sidecar/client/RequestContext.java   | 442 ++++++++++
 .../cassandra/sidecar/client/RequestExecutor.java  | 438 ++++++++++
 .../cassandra/sidecar/client/SidecarClient.java    | 401 +++++++++
 .../cassandra/sidecar/client/SidecarConfig.java    | 121 +++
 .../cassandra/sidecar/client/SidecarInstance.java  |  19 +-
 .../sidecar/client/SidecarInstanceImpl.java        | 104 +++
 .../sidecar/client/SidecarInstancesProvider.java   |  17 +-
 .../client/SimpleSidecarInstancesProvider.java     |  53 ++
 .../cassandra/sidecar/client/StreamBuffer.java     | 123 +++
 .../cassandra/sidecar/client/StreamConsumer.java   |  30 +-
 .../exception/ResourceNotFoundException.java       |  19 +-
 .../exception/RetriesExhaustedException.java       |  51 ++
 .../exception/UnexpectedStatusCodeException.java   |  53 ++
 .../request/CleanSSTableUploadSessionRequest.java  |  39 +-
 .../client/request/ClearSnapshotRequest.java       |  29 +-
 .../client/request/CreateSnapshotRequest.java      |  29 +-
 .../sidecar/client/request/DecodableRequest.java   |  74 ++
 .../sidecar/client/request/GossipInfoRequest.java  |  30 +-
 .../client/request/ImportSSTableRequest.java       | 211 +++++
 .../client/request/ListSnapshotFilesRequest.java   |  54 ++
 .../client/request/NodeSettingsRequest.java        |  30 +-
 .../cassandra/sidecar/client/request/Request.java  |  92 ++
 .../sidecar/client/request/RingRequest.java        |  34 +-
 .../client/request/SSTableComponentRequest.java    |  90 ++
 .../sidecar/client/request}/SchemaRequest.java     |  34 +-
 .../sidecar/client/request/SnapshotRequest.java    |  54 ++
 .../sidecar/client/request/TimeSkewRequest.java    |  28 +-
 .../client/request/UploadSSTableRequest.java       | 101 +++
 .../sidecar/client/request/UploadableRequest.java  |  15 +-
 .../sidecar/client/retry/BasicRetryPolicy.java     | 297 +++++++
 .../retry/ExponentialBackoffRetryPolicy.java       | 124 +++
 .../client/retry/IgnoreConflictRetryPolicy.java    |  74 ++
 .../sidecar/client/retry/NoRetryPolicy.java        |  58 ++
 .../sidecar/client/retry/RetryAction.java          |  17 +-
 .../sidecar/client/retry/RetryPolicy.java          | 104 +++
 .../retry/RunnableOnStatusCodeRetryPolicy.java     |  77 ++
 .../client/selection/InstanceSelectionPolicy.java  |  13 +-
 .../selection/OrderedInstanceSelectionPolicy.java  |  55 ++
 .../selection/RandomInstanceSelectionPolicy.java   |  61 ++
 .../selection/SingleInstanceSelectionPolicy.java   |  73 ++
 .../sidecar/client/HttpClientConfigTest.java       | 154 ++++
 .../sidecar/client/SidecarConfigTest.java          |  72 ++
 .../sidecar/client/SidecarInstanceImplTest.java    |  10 +-
 .../sidecar/client/SidecarInstanceTest.java        |  91 ++
 .../client/SimpleSidecarInstancesProviderTest.java |  67 ++
 .../cassandra/sidecar/client/StreamBufferTest.java | 101 +++
 .../sidecar/client/retry/BasicRetryPolicyTest.java | 321 +++++++
 .../retry/ExponentialBackoffRetryPolicyTest.java   | 144 ++++
 .../retry/IgnoreConflictRetryPolicyTest.java       | 112 +++
 .../sidecar/client/retry/NoRetryPolicyTest.java    | 102 +++
 .../retry/RunnableOnStatusCodeRetryPolicyTest.java |  97 +++
 .../OrderedInstanceSelectionPolicyTest.java        |  79 ++
 .../RandomInstanceSelectionPolicyTest.java         |  72 ++
 .../SingleInstanceSelectionPolicyTest.java         |  56 ++
 .../sidecar/client/SidecarClientTest.java          | 957 +++++++++++++++++++++
 .../sidecar/client/request/BaseRequestTest.java    |  88 ++
 .../request/FullSchemaRequestTestParameters.java   |  56 ++
 .../request/GossipInfoRequestTestParameters.java   |  72 ++
 .../ListSnapshotFilesRequestTestParameters.java    | 108 +++
 .../request/NodeSettingsRequestTestParameters.java |  56 ++
 .../client/request/RequestExecutorTest.java        | 247 ++++++
 .../client/request/RequestTestParameters.java      |  21 +-
 .../RingRequestForKeyspaceTestParameters.java      |  77 ++
 .../client/request/RingRequestTestParameters.java  |  82 ++
 .../request/SchemaRequestTestParameters.java       |  99 +++
 .../request/TimeSkewRequestTestParameters.java     |  56 ++
 .../resources/sstables/nb-1-big-TOC.txt            |   8 +
 common/build.gradle                                |  46 +-
 .../cassandra/sidecar/common/ApiEndpointsV1.java   |  81 ++
 .../{CQLSession.java => CQLSessionProvider.java}   |  67 +-
 .../sidecar/common/CassandraAdapterDelegate.java   | 113 ++-
 .../sidecar/common/CassandraVersionProvider.java   |  27 +-
 ...ctory.java => ClusterMembershipOperations.java} |  15 +-
 .../sidecar/common/ICassandraAdapter.java          |  20 +-
 .../sidecar/common/ICassandraFactory.java          |   4 +-
 .../apache/cassandra/sidecar/common/JmxClient.java |   4 +-
 .../sidecar/common/MockCassandraFactory.java       |   2 +-
 .../cassandra/sidecar/common/NodeSettings.java     |  22 +
 .../sidecar/common/SimpleCassandraVersion.java     |  33 +-
 .../sidecar/common/StorageOperations.java          |  20 +
 .../cassandra/sidecar/common/TableOperations.java  |  56 ++
 .../sidecar/common/data/GossipInfoResponse.java    | 288 +++++++
 .../common/data/ListSnapshotFilesResponse.java     |   5 +-
 .../sidecar/common/data/QualifiedTableName.java    |  14 +-
 .../cassandra/sidecar/common/data/RingEntry.java   | 398 +++++++++
 .../data/{SchemaRequest.java => RingResponse.java} |  35 +-
 .../sidecar/common/data/SSTableComponent.java      |  43 +-
 .../sidecar/common/data/SSTableImportResponse.java |  79 ++
 .../sidecar/common/data/SSTableUploadResponse.java |  77 ++
 .../sidecar/common/data/SSTableUploads.java        |  66 ++
 .../sidecar/common/data/SchemaResponse.java        |   7 +-
 .../{SchemaRequest.java => TimeSkewResponse.java}  |  35 +-
 .../cassandra/sidecar/common/dns/DnsResolver.java  |  61 ++
 .../sidecar/common}/exceptions/RangeException.java |   6 +-
 .../sidecar/common/utils/GossipInfoParser.java     | 105 +++
 .../cassandra/sidecar/common/utils/HttpRange.java  |  62 +-
 .../Preconditions.java}                            |  33 +-
 .../common/utils/ValidationConfiguration.java      |  26 +-
 .../common/utils/YAMLValidationConfiguration.java  |  33 +-
 .../org/apache/cassandra/sidecar/mocks/V30.java    |   4 +-
 .../org/apache/cassandra/sidecar/mocks/V40.java    |   4 +-
 .../org/apache/cassandra/sidecar/mocks/V41.java    |   4 +-
 .../common/SimpleCassandraVersionProviderTest.java |  10 +-
 .../sidecar/common/SimpleCassandraVersionTest.java |   9 +
 .../common/data/GossipInfoResponseTest.java        |  53 ++
 .../common/data/ListSnapshotFilesRequestTest.java  | 152 ----
 .../sidecar/common/data/RingResponseTest.java      |  69 ++
 .../data/StreamSSTableComponentRequestTest.java    | 174 ----
 .../sidecar/common/utils/GossipInfoParserTest.java |  86 ++
 .../sidecar/common/utils/HttpRangeTest.java        |  67 +-
 .../common/TestValidationConfiguration.java        |  26 +-
 gradle.properties                                  |   4 +-
 settings.gradle                                    |   6 +-
 spotbugs-exclude.xml                               |  38 +-
 src/main/dist/conf/sidecar.yaml                    |  37 +-
 .../common/util/concurrent/SidecarRateLimiter.java |   7 +-
 .../cassandra/sidecar/CassandraSidecarDaemon.java  |  68 +-
 .../apache/cassandra/sidecar/Configuration.java    | 491 +++++++++--
 .../org/apache/cassandra/sidecar/MainModule.java   | 165 +++-
 .../sidecar/YAMLSidecarConfiguration.java          | 155 +++-
 .../cassandra/sidecar/cluster/InstancesConfig.java |   4 +-
 .../sidecar/cluster/InstancesConfigImpl.java       |  29 +-
 .../sidecar/cluster/instance/InstanceMetadata.java |  11 +-
 .../cluster/instance/InstanceMetadataImpl.java     |  53 +-
 .../sidecar/concurrent/ConcurrencyLimiter.java     | 117 +++
 .../sidecar/concurrent/ExecutorPools.java          | 216 +++++
 .../sidecar/config/CacheConfiguration.java         |  32 +-
 .../sidecar/config/WorkerPoolConfiguration.java    |  40 +-
 .../apache/cassandra/sidecar/data/RingRequest.java |  88 ++
 .../sidecar/data/SSTableImportRequest.java         | 197 +++++
 .../sidecar/data/SSTableUploadRequest.java         |  96 +++
 .../cassandra/sidecar}/data/SchemaRequest.java     |  17 +-
 .../cassandra/sidecar/data/SnapshotRequest.java    |  56 +-
 .../data/StreamSSTableComponentRequest.java        |  27 +-
 .../sidecar/logging/SidecarLoggerHandler.java      | 105 +++
 .../cassandra/sidecar/models/HttpResponse.java     |  13 +-
 .../cassandra/sidecar/routes/AbstractHandler.java  | 204 ++++-
 .../sidecar/routes/CassandraHealthService.java     |  18 +-
 .../sidecar/routes/CassandraSettingsService.java   |  84 --
 .../sidecar/routes/FileStreamHandler.java          |  60 +-
 .../sidecar/routes/GossipInfoHandler.java          |  83 ++
 .../cassandra/sidecar/routes/HealthService.java    |   2 +-
 .../cassandra/sidecar/routes/JsonErrorHandler.java | 165 ++++
 .../sidecar/routes/ListSnapshotFilesHandler.java   | 176 ----
 .../cassandra/sidecar/routes/RingHandler.java      | 111 +++
 .../cassandra/sidecar/routes/SchemaHandler.java    |  99 +--
 .../cassandra/sidecar/routes/SnapshotsHandler.java | 366 ++++++++
 .../routes/StreamSSTableComponentHandler.java      | 103 +--
 .../sidecar/routes/SwaggerOpenApiResource.java     |  24 +-
 .../cassandra/sidecar/routes/TimeSkewHandler.java  |  36 +-
 .../routes/cassandra/NodeSettingsHandler.java      |  82 ++
 .../sstableuploads/SSTableCleanupHandler.java      | 101 +++
 .../sstableuploads/SSTableImportHandler.java       | 195 +++++
 .../sstableuploads/SSTableUploadHandler.java       | 224 +++++
 .../sidecar/snapshots/SnapshotDirectory.java       |  18 +
 .../sidecar/snapshots/SnapshotPathBuilder.java     | 189 ++--
 .../cassandra/sidecar/utils/BaseFileSystem.java    | 125 +++
 .../cassandra/sidecar/utils/CacheFactory.java      | 101 +++
 .../sidecar}/utils/CassandraInputValidator.java    |   8 +-
 .../cassandra/sidecar/utils/ChecksumVerifier.java  |  36 +-
 .../cassandra/sidecar/utils/FileStreamer.java      |  89 +-
 .../cassandra/sidecar/utils/HttpExceptions.java    |  94 ++
 .../sidecar/utils/InstanceMetadataFetcher.java     |  74 +-
 .../sidecar/utils/MD5ChecksumVerifier.java         | 102 +++
 .../cassandra/sidecar/utils/RequestUtils.java      |  33 +-
 .../cassandra/sidecar/utils/SSTableImporter.java   | 581 +++++++++++++
 .../cassandra/sidecar/utils/SSTableUploader.java   | 151 ++++
 .../sidecar/utils/SSTableUploadsPathBuilder.java   | 160 ++++
 .../{YAMLKeyConstants.java => SidecarYaml.java}    |  27 +-
 .../apache/cassandra/sidecar/utils/SslUtils.java   |  12 +-
 .../cassandra/sidecar/utils/TimeProvider.java      |  17 +-
 .../cassandra/sidecar/utils/TimeSkewInfo.java      |  35 +-
 .../cassandra/sidecar/IntegrationTestBase.java     | 131 +++
 .../cassandra/sidecar/IntegrationTestModule.java   |  96 +++
 .../cassandra/sidecar/common/DelegateTest.java     |   4 +-
 .../cassandra/sidecar/common/JmxClientTest.java    |  17 +
 .../cassandra/sidecar/common/StatusTest.java       |   4 +-
 .../containers/ExtendedCassandraContainer.java     |  11 +-
 .../common/testing/CassandraIntegrationTest.java   |   0
 .../common/testing/CassandraTestContext.java       |  19 +-
 .../common/testing/CassandraTestTemplate.java      |  49 +-
 .../sidecar/common/testing/TestVersion.java        |   4 +-
 .../common/testing/TestVersionSupplier.java        |   7 +-
 .../routes/GossipInfoHandlerIntegrationTest.java   |  63 ++
 .../sidecar/routes/RingHandlerIntegrationTest.java | 115 +++
 .../routes/SnapshotsHandlerIntegrationTest.java    | 231 +++++
 .../SSTableImportHandlerIntegrationTest.java       | 197 +++++
 .../sidecar/AbstractHealthServiceTest.java         |  18 +-
 .../sidecar/LoggerHandlerInjectionTest.java        |  64 +-
 .../org/apache/cassandra/sidecar/TestModule.java   |  33 +-
 .../apache/cassandra/sidecar/TestSslModule.java    |  12 +
 .../org/apache/cassandra/sidecar/ThrottleTest.java |   4 +-
 .../sidecar/YAMLSidecarConfigurationTest.java      |  82 +-
 .../sidecar/concurrent/ConcurrencyLimiterTest.java | 159 ++++
 .../sidecar/concurrent/ExecutorPoolsTest.java      | 113 +++
 .../cassandra/sidecar/data/RingRequestTest.java    |  94 ++
 .../sidecar/data/SnapshotRequestTest.java          |  67 ++
 .../data/StreamSSTableComponentRequestTest.java    |  73 ++
 .../ExtractHostAddressWithoutPortTest.java}        |  30 +-
 ...HandlerTest.java => GossipInfoHandlerTest.java} | 125 +--
 .../sidecar/routes/JsonErrorHandlerTest.java       | 157 ++++
 ...SchemaHandlerTest.java => RingHandlerTest.java} | 155 ++--
 .../sidecar/routes/SchemaHandlerTest.java          |  17 +-
 ...sHandlerTest.java => SnapshotsHandlerTest.java} |  78 +-
 .../routes/StreamSSTableComponentHandlerTest.java  | 349 +++++---
 .../sidecar/routes/TimeSkewInfoHandlerTest.java    | 104 +++
 .../NodeSettingsHandlerTest.java}                  |  78 +-
 .../sstableuploads/BaseUploadsHandlerTest.java     | 204 +++++
 .../sstableuploads/SSTableCleanupHandlerTest.java  |  88 ++
 .../sstableuploads/SSTableImportHandlerTest.java   | 349 ++++++++
 .../sstableuploads/SSTableUploadHandlerTest.java   | 259 ++++++
 .../sidecar/snapshots/SnapshotDirectoryTest.java   |  18 +
 .../sidecar/snapshots/SnapshotPathBuilderTest.java |   5 +-
 .../sidecar/snapshots/SnapshotSearchTest.java      |  23 +-
 .../cassandra/sidecar/snapshots/SnapshotUtils.java |  84 +-
 .../cassandra/sidecar/utils/CacheFactoryTest.java  | 192 +++++
 .../utils}/CassandraInputValidatorTest.java        |   0
 .../cassandra/sidecar/utils/RequestUtilsTest.java  |  65 +-
 .../utils/SSTableImporterImportQueueTest.java      |  93 ++
 .../sidecar/utils/SSTableImporterOptionsTest.java  | 211 +++++
 .../sidecar/utils/SSTableImporterTest.java         | 238 +++++
 .../cassandra/sidecar/utils/TimeSkewInfoTest.java  |  52 ++
 .../sidecar_custom_allowable_time_skew.yaml        |  25 +
 src/test/resources/sidecar_multiple_instances.yaml |  18 +-
 src/test/resources/sidecar_single_instance.yaml    |  15 +-
 .../sidecar_validation_configuration.yaml          |  28 +-
 .../sidecar_with_single_multiple_instances.yaml    |   2 +
 .../snapshots/AbstractSnapshotPathBuilderTest.java | 182 ++--
 vertx-client-shaded/build.gradle                   | 123 +++
 vertx-client/build.gradle                          |  93 ++
 .../sidecar/client/StreamConsumerWriteStream.java  |  94 ++
 .../cassandra/sidecar/client/VertxHttpClient.java  | 355 ++++++++
 .../sidecar/client/VertxRequestExecutor.java       |  74 ++
 .../sidecar/client/VertxStreamBuffer.java          |  87 ++
 .../client/StreamConsumerWriteStreamTest.java      |  62 ++
 .../sidecar/client/VertxSidecarClientTest.java     |  57 ++
 .../sidecar/client/VertxStreamBufferTest.java      |  96 +++
 .../client/request/BaseVertxRequestTest.java       |  62 ++
 vertx-client/src/test/resources/log4j2.xml         |  24 +
 262 files changed, 20808 insertions(+), 2633 deletions(-)

diff --git a/.circleci/config.yml b/.circleci/config.yml
index 18ebecd..e27581b 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -47,7 +47,7 @@ jobs:
     steps:
      - checkout
 
-     - run: ./gradlew -i clean test -x :cassandra-integration-tests:test --stacktrace
+     - run: ./gradlew -i clean test -x integrationTest --stacktrace
 
      - store_artifacts:
          path: build/reports
@@ -67,7 +67,7 @@ jobs:
       - install_java:
           version: adoptopenjdk-8-hotspot
       - run: sudo update-java-alternatives -s adoptopenjdk-8-hotspot-amd64 && java -version
-      - run: ./gradlew -i clean test --stacktrace
+      - run: ./gradlew -i clean test integrationTest --stacktrace
 
       - store_artifacts:
           path: build/reports
@@ -86,7 +86,7 @@ jobs:
     steps:
       - checkout
 
-      - run: ./gradlew -i clean test -x :cassandra-integration-tests:test --stacktrace
+      - run: ./gradlew -i clean test -x integrationTest --stacktrace
 
       - store_artifacts:
           path: build/reports
@@ -106,7 +106,7 @@ jobs:
       - install_java:
           version: adoptopenjdk-11-hotspot
       - run: sudo update-java-alternatives -s adoptopenjdk-11-hotspot-amd64 && java -version
-      - run: ./gradlew -i clean test --stacktrace
+      - run: ./gradlew -i clean test integrationTest --stacktrace
 
       - store_artifacts:
           path: build/reports
diff --git a/.gitignore b/.gitignore
index f17cb17..07014fa 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,7 @@
 .gitignore
 
 # C*
+.out/
 build/
 src/gen-java/
 src/resources/org/apache/cassandra/config/
@@ -72,7 +73,6 @@ lib/jsr223/scala/*.jar
 
 /.ant-targets-build.xml
 
-
 src/dist/*
 *.logdir_IS_UNDEFINED
 
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 679eb6b..efdd437 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -63,7 +63,9 @@ EVENT LOOP THREAD!
 ### <a name="thread-pools"></a>Thread Pool Model
 
 Vertx uses different thread pools for internal processing. By default, vertx will use the event loop thread pool,
-the worker thread pool, and the internal worker thread pool.
+the worker thread pool, and the internal worker thread pool. We also introduced `ExecutorPools` in Sidecar to run
+blocking executions and can be configured separately. `ExecutorPools` should be preferred over the worker and internal
+worker pools from vertx.
 
 #### The event loop thread pool (i.e. vert.x-eventloop-thread-1)
 
@@ -92,6 +94,20 @@ An internal worker thread pool used by vertx for internal operations. Similarly
 internal worker thread pool has 20 threads by default. Do not use this thread pool directly, use the worker
 thread pool instead.
 
+#### ExecutorPools
+
+It manages worker pools to schedule and execute _blocking_ tasks on dedicated threadpools to avoid blocking
+netty eventloop. It is a facade to handle one-off and periodic blocking execution.
+
+It exposes 2 worker pools. The `service` worker pool is for short-lived tasks, which should not occupy a thread for too 
+long. Typically, those tasks are client-facing, triggered by the http requests. The `internal` worker pool is for
+internal background tasks. Those tasks can live longer and potentially get queued up if prior tasks take a longer
+duration. In other words, they are not time sensitive.
+
+The `service` worker pool has the name `sidecar-worker-pool`.
+
+The `internal` worker pool has the name `sidecar-internal-worker-pool`.
+
 ### <a href="timers"></a>One-shot Timers and Periodic Timers
 
 Use vertx APIs to set one-shot timers and periodic timers. If you need to execute a one-time operation in the future,
@@ -100,7 +116,7 @@ threads that are managed internal by vertx. For example
 
 ```java
 logger.debug("Retrying streaming after {} millis", millis);
-vertx.setTimer(millis, t -> acquireAndSend(context, filename, fileLength, range, startTime));
+executorPools.service().setTimer(millis, t -> acquireAndSend(context, filename, fileLength, range, startTime));
 ```
 
 ### <a href="resteasy"></a>RestEasy Integration
@@ -178,7 +194,7 @@ Here's an example of a route that uses multiple handlers.
 
 ```java
 router.route().method(HttpMethod.GET)
-      .path("/api/v1/keyspace/:keyspace/table/:table/snapshots/:snapshot/component/:component")
+      .path("/api/v1/keyspaces/:keyspace/tables/:table/snapshots/:snapshot/components/:component")
       .handler(validateQualifiedTableHandler)
       .handler(streamSSTableComponentHandler)
       .handler(fileStreamHandler);
@@ -326,4 +342,4 @@ To import the formatting configuration run the following gradle task:
 ```
 
 This will install the style settings into the `.idea` directory located at the root of the project directory.
-You can then use the provided configuration that adhere to the project source file coding standards.
\ No newline at end of file
+You can then use the provided configuration that adheres to the project source file coding standards.
diff --git a/build.gradle b/build.gradle
index d959335..e780572 100644
--- a/build.gradle
+++ b/build.gradle
@@ -11,6 +11,7 @@ plugins {
     id 'java'
     id 'java-test-fixtures'
     id 'application'
+    id 'maven-publish'
 
     // since we're using a specific version here, we delay applying the plugin till the all projects
     id "com.github.spotbugs" version "3.0.0" apply false
@@ -83,6 +84,7 @@ run {
                                  "-javaagent:$projectDir/agents/jolokia-jvm-1.6.0-agent.jar=port=7777,host=localhost"]
 }
 
+// add integration test
 sourceSets {
     integrationTest {
         java {
@@ -93,6 +95,13 @@ sourceSets {
     }
 }
 
+// and mark it as test root
+idea {
+    module {
+        testSourceDirs += sourceSets.integrationTest.java.srcDirs
+    }
+}
+
 configurations {
     jolokia
 
@@ -100,20 +109,23 @@ configurations {
 }
 
 dependencies {
-    implementation("io.vertx:vertx-web:${project.vertxVersion}")
+    implementation("io.vertx:vertx-web:${project.vertxVersion}") {
+        exclude group: 'junit', module: 'junit'
+    }
     implementation("io.vertx:vertx-dropwizard-metrics:${project.vertxVersion}")
     implementation("io.vertx:vertx-web-client:${project.vertxVersion}")
 
-    implementation('com.datastax.cassandra:cassandra-driver-core:3.9.0+')
+    implementation('com.datastax.cassandra:cassandra-driver-core:3.11.3')
     implementation('com.google.inject:guice:4.2.2')
     implementation('io.swagger.core.v3:swagger-jaxrs2:2.1.0')
     implementation("org.jboss.resteasy:resteasy-vertx:4.7.4.Final")
     implementation('org.jboss.resteasy:resteasy-jackson2-provider:4.7.4.Final')
     implementation(group: 'org.jboss.spec.javax.servlet', name: 'jboss-servlet-api_4.0_spec', version: '2.0.0.Final')
-    implementation('org.jetbrains:annotations:23.0.0')
+    compileOnly('org.jetbrains:annotations:23.0.0')
+    implementation("com.github.ben-manes.caffeine:caffeine:2.9.3")
 
     // Trying to be exactly compatible with Cassandra's deps
-    implementation('org.slf4j:slf4j-api:1.7.25')
+    implementation("org.slf4j:slf4j-api:${project.slf4jVersion}")
     implementation('ch.qos.logback:logback-core:1.2.3')
     implementation('ch.qos.logback:logback-classic:1.2.3')
 
@@ -127,26 +139,55 @@ dependencies {
 
     testImplementation "org.junit.jupiter:junit-jupiter-api:${project.junitVersion}"
     testImplementation "org.junit.jupiter:junit-jupiter-params:${project.junitVersion}"
-    testImplementation "org.assertj:assertj-core:3.23.1"
+    testImplementation "org.assertj:assertj-core:3.24.2"
     testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:${project.junitVersion}"
 
-    testImplementation('org.cassandraunit:cassandra-unit-shaded:3.11.2.0')
+    testImplementation('com.google.guava:guava-testlib:31.1-jre') {
+        exclude group: 'junit', module: 'junit'
+    }
+    testImplementation('org.cassandraunit:cassandra-unit-shaded:3.11.2.0') {
+        exclude group: 'junit', module: 'junit'
+    }
+
     testImplementation('com.datastax.cassandra:cassandra-driver-core:3.9.0:tests')
-    testImplementation('org.mockito:mockito-all:1.10.19')
+    testImplementation('org.mockito:mockito-core:4.10.0')
+    testImplementation('org.mockito:mockito-inline:4.10.0')
     testImplementation("io.vertx:vertx-junit5:${project.vertxVersion}")
     testImplementation(testFixtures(project(":common")))
-    
+
     implementation(project(":common"))
     implementation(project(":cassandra40"))
 
     testFixturesApi(testFixtures(project(":common")))
     testFixturesImplementation("io.vertx:vertx-junit5:${project.vertxVersion}")
     testFixturesImplementation('com.google.inject:guice:4.2.2')
-    testFixturesImplementation('org.mockito:mockito-all:1.10.19')
+    testFixturesImplementation('org.mockito:mockito-core:4.10.0')
     testFixturesImplementation("org.junit.jupiter:junit-jupiter-api:${project.junitVersion}")
     testFixturesImplementation("org.junit.jupiter:junit-jupiter-params:${project.junitVersion}")
-    testFixturesImplementation("org.assertj:assertj-core:3.23.1")
-    testFixturesImplementation("io.vertx:vertx-web:${project.vertxVersion}")
+    testFixturesImplementation("org.assertj:assertj-core:3.24.2")
+    testFixturesImplementation("io.vertx:vertx-web:${project.vertxVersion}") {
+        exclude group: 'junit', module: 'junit'
+    }
+
+    integrationTestImplementation("org.testcontainers:testcontainers:${project.testcontainersVersion}")
+    integrationTestImplementation("org.testcontainers:cassandra:${project.testcontainersVersion}")
+    integrationTestImplementation("org.testcontainers:junit-jupiter:${project.testcontainersVersion}")
+}
+
+java {
+    withJavadocJar()
+    withSourcesJar()
+}
+
+publishing {
+    publications {
+        maven(MavenPublication) {
+            from components.java
+            groupId project.group
+            artifactId "${archivesBaseName}"
+            version System.getenv("CODE_VERSION") ?: "${version}"
+        }
+    }
 }
 
 task copyCodeStyle(type: Copy) {
@@ -177,6 +218,7 @@ clean.doLast {
 }
 
 test {
+    systemProperty "vertxweb.environment", "dev"
     // ordinarily we don't need integration tests
     // see the integrationTest task
     useJUnitPlatform()
@@ -187,6 +229,7 @@ test {
 }
 
 tasks.register("integrationTest", Test) {
+    systemProperty "vertxweb.environment", "dev"
     jacoco {
         enabled = false
     }
@@ -250,7 +293,7 @@ resolve {
     outputDir = file('build/generated/swagger')
 }
 
-
+integrationTest.onlyIf { "true" != System.getenv("skipIntegrationTest") }
 
 // copyDist gets called on every build
 copyDist.dependsOn installDist, copyJolokia
diff --git a/cassandra-integration-tests/build.gradle b/cassandra-integration-tests/build.gradle
deleted file mode 100644
index 634742e..0000000
--- a/cassandra-integration-tests/build.gradle
+++ /dev/null
@@ -1,39 +0,0 @@
-plugins {
-    id 'java'
-    id "java-test-fixtures"
-}
-
-repositories {
-    mavenCentral()
-}
-
-dependencies {
-    testFixturesApi project(":cassandra40")
-
-    implementation('ch.qos.logback:logback-core:1.2.3')
-    implementation('ch.qos.logback:logback-classic:1.2.3')
-    implementation('com.datastax.cassandra:cassandra-driver-core:3.9.0+')
-
-    testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:${project.junitVersion}")
-    testFixturesApi('org.assertj:assertj-core:3.23.1')
-    testFixturesApi("org.testcontainers:testcontainers:${project.testcontainersVersion}")
-    testFixturesApi("org.testcontainers:cassandra:${project.testcontainersVersion}")
-    testFixturesApi("org.testcontainers:junit-jupiter:${project.testcontainersVersion}")
-}
-
-// Docker is required to run integration tests
-test {
-    useJUnitPlatform() {
-        excludeTags "integrationTest"
-    }
-}
-
-tasks.register("integrationTest", Test) {
-    jacoco {
-        enabled = false
-    }
-    useJUnitPlatform() {
-        includeTags "integrationTest"
-    }
-    group = "verification"
-}
diff --git a/cassandra-integration-tests/src/test/resources/logback-test.xml b/cassandra-integration-tests/src/test/resources/logback-test.xml
deleted file mode 100644
index cef0461..0000000
--- a/cassandra-integration-tests/src/test/resources/logback-test.xml
+++ /dev/null
@@ -1,12 +0,0 @@
-<configuration>
-
-    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
-        <encoder>
-            <pattern>%d{HH:mm:ss.SSS} %-5level %logger - %msg%n</pattern>
-        </encoder>
-    </appender>
-
-    <root level="INFO">
-        <appender-ref ref="STDOUT"/>
-    </root>
-</configuration>
diff --git a/cassandra40/build.gradle b/cassandra40/build.gradle
index 15e28e5..170b073 100644
--- a/cassandra40/build.gradle
+++ b/cassandra40/build.gradle
@@ -1,9 +1,10 @@
 plugins {
-    id 'java'
+    id 'java-library'
     id 'idea'
+    id 'maven-publish'
 }
 
-group 'org.apache.cassandra'
+group 'org.apache.cassandra.sidecar'
 version project.version
 
 sourceCompatibility = 1.8
@@ -12,7 +13,34 @@ repositories {
     mavenCentral()
 }
 
+test {
+    useJUnitPlatform()
+}
+
 dependencies {
-    compile project(":common")
-    implementation('org.jetbrains:annotations:23.0.0')
+    implementation(project(":common"))
+    compileOnly('org.jetbrains:annotations:23.0.0')
+    compileOnly('com.datastax.cassandra:cassandra-driver-core:3.11.3')
+    implementation("com.google.guava:guava:${project.rootProject.guavaVersion}")
+    implementation("org.slf4j:slf4j-api:${project.slf4jVersion}")
+
+    testImplementation "org.junit.jupiter:junit-jupiter-api:${project.junitVersion}"
+    testImplementation "org.junit.jupiter:junit-jupiter-params:${project.junitVersion}"
+    testImplementation "org.assertj:assertj-core:3.24.2"
+    testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:${project.junitVersion}"
+
+    testImplementation('org.mockito:mockito-core:4.10.0')
+    testImplementation('org.mockito:mockito-inline:4.10.0')
+
+}
+
+publishing {
+    publications {
+        maven(MavenPublication) {
+            from components.java
+            groupId project.group
+            artifactId "${archivesBaseName}"
+            version System.getenv("CODE_VERSION") ?: "${version}"
+        }
+    }
 }
diff --git a/cassandra40/src/main/java/org/apache/cassandra/sidecar/cassandra40/Cassandra40StorageOperations.java b/cassandra40/src/main/java/org/apache/cassandra/sidecar/cassandra40/Cassandra40ClusterMembershipOperations.java
similarity index 54%
copy from cassandra40/src/main/java/org/apache/cassandra/sidecar/cassandra40/Cassandra40StorageOperations.java
copy to cassandra40/src/main/java/org/apache/cassandra/sidecar/cassandra40/Cassandra40ClusterMembershipOperations.java
index a64b866..c9e274e 100644
--- a/cassandra40/src/main/java/org/apache/cassandra/sidecar/cassandra40/Cassandra40StorageOperations.java
+++ b/cassandra40/src/main/java/org/apache/cassandra/sidecar/cassandra40/Cassandra40ClusterMembershipOperations.java
@@ -18,21 +18,16 @@
 
 package org.apache.cassandra.sidecar.cassandra40;
 
-import java.util.Map;
-
+import org.apache.cassandra.sidecar.common.ClusterMembershipOperations;
 import org.apache.cassandra.sidecar.common.JmxClient;
-import org.apache.cassandra.sidecar.common.StorageOperations;
-import org.jetbrains.annotations.NotNull;
-import org.jetbrains.annotations.Nullable;
 
-import static java.util.Objects.requireNonNull;
+import static org.apache.cassandra.sidecar.cassandra40.ClusterMembershipJmxOperations.FAILURE_DETECTOR_OBJ_NAME;
 
 /**
- * An implementation of the {@link StorageOperations} that interfaces with Cassandra 4.0
+ * An implementation of the {@link ClusterMembershipOperations} that interfaces with Cassandra 4.0
  */
-public class Cassandra40StorageOperations implements StorageOperations
+public class Cassandra40ClusterMembershipOperations implements ClusterMembershipOperations
 {
-    private static final String STORAGE_SERVICE_OBJ_NAME = "org.apache.cassandra.db:type=StorageService";
     private final JmxClient jmxClient;
 
     /**
@@ -40,7 +35,7 @@ public class Cassandra40StorageOperations implements StorageOperations
      *
      * @param jmxClient the JMX client used to communicate with the Cassandra instance
      */
-    public Cassandra40StorageOperations(JmxClient jmxClient)
+    public Cassandra40ClusterMembershipOperations(JmxClient jmxClient)
     {
         this.jmxClient = jmxClient;
     }
@@ -49,13 +44,9 @@ public class Cassandra40StorageOperations implements StorageOperations
      * {@inheritDoc}
      */
     @Override
-    public void takeSnapshot(@NotNull String tag, @NotNull String keyspace, @NotNull String table,
-                             @Nullable Map<String, String> options)
+    public String gossipInfo()
     {
-        requireNonNull(tag, "snapshot tag must be non-null");
-        requireNonNull(keyspace, "keyspace for the  must be non-null");
-        requireNonNull(table, "table must be non-null");
-        jmxClient.proxy(StorageJmxOperations.class, STORAGE_SERVICE_OBJ_NAME)
-                 .takeSnapshot(tag, options, keyspace + "." + table);
+        return jmxClient.proxy(ClusterMembershipJmxOperations.class, FAILURE_DETECTOR_OBJ_NAME)
+                        .getAllEndpointStatesWithPort();
     }
 }
diff --git a/cassandra40/src/main/java/org/apache/cassandra/sidecar/cassandra40/Cassandra40Factory.java b/cassandra40/src/main/java/org/apache/cassandra/sidecar/cassandra40/Cassandra40Factory.java
index b62b80a..9064748 100644
--- a/cassandra40/src/main/java/org/apache/cassandra/sidecar/cassandra40/Cassandra40Factory.java
+++ b/cassandra40/src/main/java/org/apache/cassandra/sidecar/cassandra40/Cassandra40Factory.java
@@ -18,13 +18,23 @@
 
 package org.apache.cassandra.sidecar.cassandra40;
 
-import org.apache.cassandra.sidecar.common.CQLSession;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.datastax.driver.core.Metadata;
+import com.datastax.driver.core.Row;
+import com.datastax.driver.core.Session;
+import org.apache.cassandra.sidecar.common.CQLSessionProvider;
+import org.apache.cassandra.sidecar.common.ClusterMembershipOperations;
 import org.apache.cassandra.sidecar.common.ICassandraAdapter;
 import org.apache.cassandra.sidecar.common.ICassandraFactory;
 import org.apache.cassandra.sidecar.common.JmxClient;
 import org.apache.cassandra.sidecar.common.MinimumVersion;
 import org.apache.cassandra.sidecar.common.NodeSettings;
 import org.apache.cassandra.sidecar.common.StorageOperations;
+import org.apache.cassandra.sidecar.common.TableOperations;
+import org.apache.cassandra.sidecar.common.dns.DnsResolver;
+import org.jetbrains.annotations.Nullable;
 
 /**
  * Factory to produce the 4.0 adapter
@@ -32,30 +42,108 @@ import org.apache.cassandra.sidecar.common.StorageOperations;
 @MinimumVersion("4.0.0")
 public class Cassandra40Factory implements ICassandraFactory
 {
+    private static final Logger LOGGER = LoggerFactory.getLogger(Cassandra40Factory.class);
+    private final DnsResolver dnsResolver;
+
+    public Cassandra40Factory(DnsResolver dnsResolver)
+    {
+        this.dnsResolver = dnsResolver;
+    }
 
     /**
      * Returns a new adapter for Cassandra 4.0 clusters.
      *
-     * @param session the session to the Cassandra database
-     * @param jmxClient  the JMX client to connect to the Cassandra database
+     * @param session   the session to the Cassandra database
+     * @param jmxClient the JMX client to connect to the Cassandra database
      * @return a new adapter for the 4.0 clusters
      */
     @Override
-    public ICassandraAdapter create(CQLSession session, JmxClient jmxClient)
+    public ICassandraAdapter create(CQLSessionProvider session, JmxClient jmxClient)
     {
         return new ICassandraAdapter()
         {
+            /**
+             * {@inheritDoc}
+             */
+            @Override
+            @Nullable
+            public Metadata metadata()
+            {
+                Session activeSession = session.localCql();
+                if (activeSession == null)
+                {
+                    LOGGER.warn("There is no active session to Cassandra");
+                    return null;
+                }
 
+                if (activeSession.getCluster() == null)
+                {
+                    LOGGER.warn("There is no available cluster for session={}", activeSession);
+                    return null;
+                }
+
+                if (activeSession.getCluster().getMetadata() == null)
+                {
+                    LOGGER.warn("There is no available metadata for session={}, cluster={}",
+                                activeSession, activeSession.getCluster());
+                }
+
+                return activeSession.getCluster().getMetadata();
+            }
+
+            /**
+             * {@inheritDoc}
+             */
             @Override
-            public NodeSettings getSettings()
+            @Nullable
+            public NodeSettings nodeSettings()
             {
-                return null;
+                Session activeSession = session.localCql();
+                if (activeSession == null)
+                {
+                    return null;
+                }
+
+                Row oneResult = activeSession.execute("select release_version, partitioner from system.local")
+                                             .one();
+
+                return new NodeSettings(oneResult.getString("release_version"),
+                                        oneResult.getString("partitioner"));
             }
 
+            /**
+             * {@inheritDoc}
+             */
             @Override
             public StorageOperations storageOperations()
             {
-                return new Cassandra40StorageOperations(jmxClient);
+                return new Cassandra40StorageOperations(jmxClient, dnsResolver);
+            }
+
+            /**
+             * {@inheritDoc}
+             */
+            @Override
+            public ClusterMembershipOperations clusterMembershipOperations()
+            {
+                return new Cassandra40ClusterMembershipOperations(jmxClient);
+            }
+
+            /**
+             * {@inheritDoc}
+             */
+            @Override
+            public TableOperations tableOperations()
+            {
+                return new Cassandra40TableOperations(jmxClient);
+            }
+
+            /**
+             * {@inheritDoc}
+             */
+            public String toString()
+            {
+                return "Cassandra40Adapter" + "@" + Integer.toHexString(hashCode());
             }
         };
     }
diff --git a/cassandra40/src/main/java/org/apache/cassandra/sidecar/cassandra40/Cassandra40StorageOperations.java b/cassandra40/src/main/java/org/apache/cassandra/sidecar/cassandra40/Cassandra40StorageOperations.java
index a64b866..f1cdd45 100644
--- a/cassandra40/src/main/java/org/apache/cassandra/sidecar/cassandra40/Cassandra40StorageOperations.java
+++ b/cassandra40/src/main/java/org/apache/cassandra/sidecar/cassandra40/Cassandra40StorageOperations.java
@@ -18,31 +18,40 @@
 
 package org.apache.cassandra.sidecar.cassandra40;
 
+import java.net.UnknownHostException;
 import java.util.Map;
 
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
 import org.apache.cassandra.sidecar.common.JmxClient;
 import org.apache.cassandra.sidecar.common.StorageOperations;
+import org.apache.cassandra.sidecar.common.data.RingResponse;
+import org.apache.cassandra.sidecar.common.dns.DnsResolver;
 import org.jetbrains.annotations.NotNull;
 import org.jetbrains.annotations.Nullable;
 
 import static java.util.Objects.requireNonNull;
+import static org.apache.cassandra.sidecar.cassandra40.StorageJmxOperations.STORAGE_SERVICE_OBJ_NAME;
 
 /**
  * An implementation of the {@link StorageOperations} that interfaces with Cassandra 4.0
  */
 public class Cassandra40StorageOperations implements StorageOperations
 {
-    private static final String STORAGE_SERVICE_OBJ_NAME = "org.apache.cassandra.db:type=StorageService";
+    private static final Logger LOGGER = LoggerFactory.getLogger(Cassandra40StorageOperations.class);
     private final JmxClient jmxClient;
+    private final RingProvider ringProvider;
 
     /**
      * Creates a new instance with the provided {@link JmxClient}
      *
      * @param jmxClient the JMX client used to communicate with the Cassandra instance
      */
-    public Cassandra40StorageOperations(JmxClient jmxClient)
+    public Cassandra40StorageOperations(JmxClient jmxClient, DnsResolver dnsResolver)
     {
         this.jmxClient = jmxClient;
+        this.ringProvider = new RingProvider(jmxClient, dnsResolver);
     }
 
     /**
@@ -58,4 +67,29 @@ public class Cassandra40StorageOperations implements StorageOperations
         jmxClient.proxy(StorageJmxOperations.class, STORAGE_SERVICE_OBJ_NAME)
                  .takeSnapshot(tag, options, keyspace + "." + table);
     }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public void clearSnapshot(@NotNull String tag, @NotNull String keyspace, @NotNull String table)
+    {
+        requireNonNull(tag, "snapshot tag must be non-null");
+        requireNonNull(keyspace, "keyspace for the  must be non-null");
+        requireNonNull(table, "table must be non-null");
+        LOGGER.debug("Table is not supported by Cassandra JMX endpoints. " +
+                     "Clearing snapshot with tag={} and keyspace={}; table={} is ignored", tag, keyspace, table);
+        final String[] keyspaces = { keyspace };
+        jmxClient.proxy(StorageJmxOperations.class, STORAGE_SERVICE_OBJ_NAME)
+                 .clearSnapshot(tag, keyspaces);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public RingResponse ring(@Nullable String keyspace) throws UnknownHostException
+    {
+        return ringProvider.ring(keyspace);
+    }
 }
diff --git a/cassandra40/src/main/java/org/apache/cassandra/sidecar/cassandra40/Cassandra40TableOperations.java b/cassandra40/src/main/java/org/apache/cassandra/sidecar/cassandra40/Cassandra40TableOperations.java
new file mode 100644
index 0000000..f94f626
--- /dev/null
+++ b/cassandra40/src/main/java/org/apache/cassandra/sidecar/cassandra40/Cassandra40TableOperations.java
@@ -0,0 +1,72 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.sidecar.cassandra40;
+
+import java.util.Collections;
+import java.util.List;
+
+import org.apache.cassandra.sidecar.common.JmxClient;
+import org.apache.cassandra.sidecar.common.TableOperations;
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * An implementation of the {@link TableOperations} that interfaces with Cassandra 4.0
+ */
+public class Cassandra40TableOperations implements TableOperations
+{
+    private final JmxClient jmxClient;
+
+    public Cassandra40TableOperations(JmxClient jmxClient)
+    {
+        this.jmxClient = jmxClient;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public List<String> importNewSSTables(@NotNull String keyspace,
+                                          @NotNull String tableName,
+                                          @NotNull String directory,
+                                          boolean resetLevel,
+                                          boolean clearRepaired,
+                                          boolean verifySSTables,
+                                          boolean verifyTokens,
+                                          boolean invalidateCaches,
+                                          boolean extendedVerify,
+                                          boolean copyData)
+    {
+        return jmxClient.proxy(TableJmxOperations.class, tableMBeanName(keyspace, tableName))
+                        .importNewSSTables(Collections.singleton(directory),
+                                           resetLevel,
+                                           clearRepaired,
+                                           verifySSTables,
+                                           verifyTokens,
+                                           invalidateCaches,
+                                           extendedVerify,
+                                           copyData);
+    }
+
+    String tableMBeanName(String keyspace, String tableName)
+    {
+        return String.format("org.apache.cassandra.db:type=%s,keyspace=%s,table=%s",
+                             tableName.contains(".") ? "IndexTables" : "Tables",
+                             keyspace, tableName);
+    }
+}
diff --git a/src/main/java/org/apache/cassandra/sidecar/exceptions/RangeException.java b/cassandra40/src/main/java/org/apache/cassandra/sidecar/cassandra40/ClusterMembershipJmxOperations.java
similarity index 65%
copy from src/main/java/org/apache/cassandra/sidecar/exceptions/RangeException.java
copy to cassandra40/src/main/java/org/apache/cassandra/sidecar/cassandra40/ClusterMembershipJmxOperations.java
index 3dc783c..b91aa9c 100644
--- a/src/main/java/org/apache/cassandra/sidecar/exceptions/RangeException.java
+++ b/cassandra40/src/main/java/org/apache/cassandra/sidecar/cassandra40/ClusterMembershipJmxOperations.java
@@ -16,15 +16,18 @@
  * limitations under the License.
  */
 
-package org.apache.cassandra.sidecar.exceptions;
+package org.apache.cassandra.sidecar.cassandra40;
 
 /**
- * Custom exception
+ * An interface that pulls a method from Cassandra Gossiper and Failure Detector proxies
  */
-public class RangeException extends RuntimeException
+public interface ClusterMembershipJmxOperations
 {
-    public RangeException(String msg)
-    {
-        super(msg);
-    }
+    String FAILURE_DETECTOR_OBJ_NAME = "org.apache.cassandra.net:type=FailureDetector";
+
+    /**
+     * Retrieves gossip info with ports included for the nodes
+     * @return gossip info text
+     */
+    String getAllEndpointStatesWithPort();
 }
diff --git a/common/src/main/java/org/apache/cassandra/sidecar/common/data/SchemaRequest.java b/cassandra40/src/main/java/org/apache/cassandra/sidecar/cassandra40/EndpointSnitchJmxOperations.java
similarity index 50%
copy from common/src/main/java/org/apache/cassandra/sidecar/common/data/SchemaRequest.java
copy to cassandra40/src/main/java/org/apache/cassandra/sidecar/cassandra40/EndpointSnitchJmxOperations.java
index 1f136b9..97f2478 100644
--- a/common/src/main/java/org/apache/cassandra/sidecar/common/data/SchemaRequest.java
+++ b/cassandra40/src/main/java/org/apache/cassandra/sidecar/cassandra40/EndpointSnitchJmxOperations.java
@@ -15,34 +15,31 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.cassandra.sidecar.common.data;
 
-import org.jetbrains.annotations.Nullable;
+package org.apache.cassandra.sidecar.cassandra40;
+
+import java.net.UnknownHostException;
 
 /**
- * Holder class for the {@link org.apache.cassandra.sidecar.routes.SchemaHandler}
- * request parameters
+ * An interface that pulls methods from Cassandra endpoint snitch info
  */
-public class SchemaRequest extends QualifiedTableName
+public interface EndpointSnitchJmxOperations
 {
+    String ENDPOINT_SNITCH_INFO_OBJ_NAME = "org.apache.cassandra.db:type=EndpointSnitchInfo";
+
     /**
-     * Constructs a {@link SchemaRequest} with the {@link org.jetbrains.annotations.Nullable} {@code keyspace}.
+     * Provides the Rack name depending on the respective snitch used, given the host name/ip
      *
-     * @param keyspace the keyspace in Cassandra
+     * @param host the ip or hostname
+     * @throws UnknownHostException when the host is not resolved
      */
-    public SchemaRequest(@Nullable String keyspace)
-    {
-        super(keyspace, null, false);
-    }
+    String getRack(String host) throws UnknownHostException;
 
     /**
-     * {@inheritDoc}
+     * Provides the Datacenter name depending on the respective snitch used, given the hostname/ip
+     *
+     * @param host the ip or hostname
+     * @throws UnknownHostException when the host is not resolved
      */
-    public String toString()
-    {
-        return "SchemaRequest{" +
-               "keyspace='" + keyspace() + '\'' +
-               ", tableName='" + tableName() + '\'' +
-               '}';
-    }
+    String getDatacenter(String host) throws UnknownHostException;
 }
diff --git a/cassandra40/src/main/java/org/apache/cassandra/sidecar/cassandra40/RingProvider.java b/cassandra40/src/main/java/org/apache/cassandra/sidecar/cassandra40/RingProvider.java
new file mode 100644
index 0000000..92a91c9
--- /dev/null
+++ b/cassandra40/src/main/java/org/apache/cassandra/sidecar/cassandra40/RingProvider.java
@@ -0,0 +1,211 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.sidecar.cassandra40;
+
+import java.net.UnknownHostException;
+import java.text.DecimalFormat;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import com.google.common.net.HostAndPort;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import org.apache.cassandra.sidecar.common.JmxClient;
+import org.apache.cassandra.sidecar.common.data.RingEntry;
+import org.apache.cassandra.sidecar.common.data.RingResponse;
+import org.apache.cassandra.sidecar.common.dns.DnsResolver;
+import org.jetbrains.annotations.Nullable;
+
+import static org.apache.cassandra.sidecar.cassandra40.EndpointSnitchJmxOperations.ENDPOINT_SNITCH_INFO_OBJ_NAME;
+import static org.apache.cassandra.sidecar.cassandra40.StorageJmxOperations.STORAGE_SERVICE_OBJ_NAME;
+
+/**
+ * Aggregates the ring view of cluster
+ */
+public class RingProvider
+{
+    private static final Logger LOGGER = LoggerFactory.getLogger(RingProvider.class);
+    private static final String UNKNOWN_SHORT = "?";
+    private static final String UNKNOWN = "Unknown";
+    private static final String STATUS_UP = "Up";
+    private static final String STATUS_DOWN = "Down";
+    private static final String STATE_JOINING = "Joining";
+    private static final String STATE_LEAVING = "Leaving";
+    private static final String STATE_MOVING = "Moving";
+    private static final String STATE_NORMAL = "Normal";
+    private static final String DECIMAL_FORMAT = "##0.00%";
+
+    private final JmxClient jmxClient;
+    private final DnsResolver dnsResolver;
+
+    public RingProvider(JmxClient jmxClient, DnsResolver dnsResolver)
+    {
+        this.jmxClient = jmxClient;
+        this.dnsResolver = dnsResolver;
+    }
+
+    @SuppressWarnings("UnstableApiUsage")
+    public RingResponse ring(@Nullable String keyspace) throws UnknownHostException
+    {
+        StorageJmxOperations probe = jmxClient.proxy(StorageJmxOperations.class, STORAGE_SERVICE_OBJ_NAME);
+        EndpointSnitchJmxOperations epSnitchInfo = jmxClient.proxy(EndpointSnitchJmxOperations.class,
+                                                                   ENDPOINT_SNITCH_INFO_OBJ_NAME);
+
+        // Collect required data from the probe
+        List<String> liveNodes = probe.getLiveNodesWithPort();
+        List<String> deadNodes = probe.getUnreachableNodesWithPort();
+        Status status = new Status(liveNodes, deadNodes);
+        List<String> joiningNodes = probe.getJoiningNodesWithPort();
+        List<String> leavingNodes = probe.getLeavingNodesWithPort();
+        List<String> movingNodes = probe.getMovingNodesWithPort();
+        State state = new State(joiningNodes, leavingNodes, movingNodes);
+        Map<String, String> loadMap = probe.getLoadMapWithPort();
+        Map<String, String> tokensToEndpoints = probe.getTokenToEndpointWithPortMap();
+        Map<String, String> endpointsToHostIds = probe.getEndpointWithPortToHostId();
+
+        boolean showEffectiveOwnership = true;
+        // Calculate per-token ownership of the ring
+        Map<String, Float> ownerships;
+        try
+        {
+            ownerships = probe.effectiveOwnershipWithPort(keyspace);
+        }
+        catch (IllegalStateException ex)
+        {
+            ownerships = probe.getOwnershipWithPort();
+            LOGGER.warn("Unable to retrieve effective ownership information for keyspace={}", keyspace, ex);
+            showEffectiveOwnership = false;
+        }
+
+        // DecimalFormat is not thread-safe, so we need to create a new instance per thread
+        DecimalFormat ownsFormat = new DecimalFormat(DECIMAL_FORMAT);
+        RingResponse response = new RingResponse(tokensToEndpoints.size());
+        for (Map.Entry<String, String> entry : tokensToEndpoints.entrySet())
+        {
+            String endpoint = entry.getValue();
+            String token = entry.getKey();
+            HostAndPort hap = resolve(endpoint, dnsResolver);
+            Float owns = ownerships.get(endpoint);
+            RingEntry ringEntry = new RingEntry.Builder()
+                                  .datacenter(epSnitchInfo.getDatacenter(endpoint))
+                                  .rack(queryRack(epSnitchInfo, endpoint))
+                                  .status(status.of(endpoint))
+                                  .state(state.of(endpoint))
+                                  .load(loadMap.getOrDefault(endpoint, UNKNOWN_SHORT))
+                                  .owns(formatOwns(showEffectiveOwnership, ownsFormat, owns))
+                                  .token(token)
+                                  .address(hap.getHost())
+                                  .port(hap.getPort())
+                                  .fqdn(dnsResolver.reverseResolve(hap.getHost()))
+                                  .hostId(endpointsToHostIds.getOrDefault(endpoint, UNKNOWN))
+                                  .build();
+            response.add(ringEntry);
+        }
+
+        return response;
+    }
+
+    /**
+     * Resolves the endpoint to the format "IP_ADDRESS:PORT"
+     *
+     * @return host and port. Port defaults to -1 if not included in endpoint.
+     * @throws UnknownHostException when endpoint cannot be resolved
+     */
+    @SuppressWarnings("UnstableApiUsage")
+    private static HostAndPort resolve(String endpoint, DnsResolver resolver) throws UnknownHostException
+    {
+        HostAndPort hap = HostAndPort.fromString(endpoint);
+        String address = resolver.resolve(hap.getHost());
+        return HostAndPort.fromParts(address, hap.getPortOrDefault(-1));
+    }
+
+    private static String formatOwns(boolean showEffectiveOwnership, DecimalFormat ownsFormat, Float owns)
+    {
+        if (showEffectiveOwnership && owns != null)
+            return ownsFormat.format(owns);
+        return UNKNOWN_SHORT;
+    }
+
+    /**
+     * Data class to get status of endpoints
+     */
+    static class Status
+    {
+        private final Set<String> liveNodes;
+        private final Set<String> deadNodes;
+
+        Status(List<String> liveNodes, List<String> deadNodes)
+        {
+            this.liveNodes = new HashSet<>(liveNodes);
+            this.deadNodes = new HashSet<>(deadNodes);
+        }
+
+        String of(String endpoint)
+        {
+            if (liveNodes.contains(endpoint))
+                return STATUS_UP;
+            if (deadNodes.contains(endpoint))
+                return STATUS_DOWN;
+            return UNKNOWN_SHORT;
+        }
+    }
+
+    /**
+     * Data class to get states of endpoints
+     */
+    static class State
+    {
+        private final Set<String> joiningNodes;
+        private final Set<String> leavingNodes;
+        private final Set<String> movingNodes;
+
+        State(List<String> joiningNodes, List<String> leavingNodes, List<String> movingNodes)
+        {
+            this.joiningNodes = new HashSet<>(joiningNodes);
+            this.leavingNodes = new HashSet<>(leavingNodes);
+            this.movingNodes = new HashSet<>(movingNodes);
+        }
+
+        String of(String endpoint)
+        {
+            if (joiningNodes.contains(endpoint))
+                return STATE_JOINING;
+            else if (leavingNodes.contains(endpoint))
+                return STATE_LEAVING;
+            else if (movingNodes.contains(endpoint))
+                return STATE_MOVING;
+            return STATE_NORMAL;
+        }
+    }
+
+    private static String queryRack(EndpointSnitchJmxOperations epSnitchInfo, String endpoint)
+    {
+        try
+        {
+            return epSnitchInfo.getRack(endpoint);
+        }
+        catch (UnknownHostException e)
+        {
+            return UNKNOWN;
+        }
+    }
+}
diff --git a/cassandra40/src/main/java/org/apache/cassandra/sidecar/cassandra40/StorageJmxOperations.java b/cassandra40/src/main/java/org/apache/cassandra/sidecar/cassandra40/StorageJmxOperations.java
index 3e9250a..ea108c4 100644
--- a/cassandra40/src/main/java/org/apache/cassandra/sidecar/cassandra40/StorageJmxOperations.java
+++ b/cassandra40/src/main/java/org/apache/cassandra/sidecar/cassandra40/StorageJmxOperations.java
@@ -1,12 +1,103 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
 package org.apache.cassandra.sidecar.cassandra40;
 
+import java.util.List;
 import java.util.Map;
 
 /**
- * An interface that pulls a method from the Cassandra Storage Service Proxy
+ * An interface that pulls methods from the Cassandra Storage Service Proxy
  */
 public interface StorageJmxOperations
 {
+    String STORAGE_SERVICE_OBJ_NAME = "org.apache.cassandra.db:type=StorageService";
+
+    /**
+     * Retrieve the list of live nodes in the cluster, where "liveness" is
+     * determined by the failure detector of the node being queried.
+     *
+     * @return set of IP addresses, as Strings
+     */
+    List<String> getLiveNodesWithPort();
+
+    /**
+     * Retrieve the list of unreachable nodes in the cluster, as determined
+     * by this node's failure detector.
+     *
+     * @return set of IP addresses, as Strings
+     */
+    List<String> getUnreachableNodesWithPort();
+
+    /**
+     * Retrieve the list of nodes currently bootstrapping into the ring.
+     *
+     * @return set of IP addresses, as Strings
+     */
+    List<String> getJoiningNodesWithPort();
+
+    /**
+     * Retrieve the list of nodes currently leaving the ring.
+     *
+     * @return set of IP addresses, as Strings
+     */
+    List<String> getLeavingNodesWithPort();
+
+    /**
+     * Retrieve the list of nodes currently moving in the ring.
+     *
+     * @return set of IP addresses, as Strings
+     */
+    List<String> getMovingNodesWithPort();
+
+    /**
+     * Human-readable load value.  Keys are IP addresses.
+     */
+    Map<String, String> getLoadMapWithPort();
+
+    /**
+     * Retrieve a map of tokens to endpoints, including the bootstrapping
+     * ones.
+     *
+     * @return a map of tokens to endpoints in ascending order
+     */
+    Map<String, String> getTokenToEndpointWithPortMap();
+
+    /**
+     * Effective ownership is % of the data each node owns given the keyspace
+     * we calculate the percentage using replication factor.
+     * If Keyspace == null, this method will try to verify if all the keyspaces
+     * in the cluster have the same replication strategies and if yes then we will
+     * use the first else an empty Map is returned.
+     */
+    Map<String, Float> effectiveOwnershipWithPort(String keyspace) throws IllegalStateException;
+
+    /**
+     * given a list of tokens (representing the nodes in the cluster), returns
+     * a mapping from {@code "token -> %age of cluster owned by that token"}
+     */
+    Map<String, Float> getOwnershipWithPort();
+
+    /**
+     * Retrieve the mapping of endpoint to host ID
+     */
+    Map<String, String> getEndpointWithPortToHostId();
+
     /**
      * Takes the snapshot of a multiple column family from different keyspaces. A snapshot name must be specified.
      *
@@ -15,4 +106,12 @@ public interface StorageJmxOperations
      * @param entities list of keyspaces / tables in the form of empty | ks1 ks2 ... | ks1.cf1,ks2.cf2,...
      */
     void takeSnapshot(String tag, Map<String, String> options, String... entities);
+
+    /**
+     * Remove the snapshot with the given name from the given keyspaces.
+     *
+     * @param tag           the tag used to create the snapshot (name of the snapshot)
+     * @param keyspaceNames an optional list of keyspaces
+     */
+    void clearSnapshot(String tag, String... keyspaceNames);
 }
diff --git a/cassandra40/src/main/java/org/apache/cassandra/sidecar/cassandra40/TableJmxOperations.java b/cassandra40/src/main/java/org/apache/cassandra/sidecar/cassandra40/TableJmxOperations.java
new file mode 100644
index 0000000..fca81d6
--- /dev/null
+++ b/cassandra40/src/main/java/org/apache/cassandra/sidecar/cassandra40/TableJmxOperations.java
@@ -0,0 +1,53 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.sidecar.cassandra40;
+
+import java.util.List;
+import java.util.Set;
+
+/**
+ * An interface that pulls methods from the Cassandra MBean interface for ColumnFamilyStore
+ */
+public interface TableJmxOperations
+{
+
+    /**
+     * Load new sstables from the given directory
+     *
+     * @param srcPaths         the path to the new sstables - if it is an empty set, the data directories will
+     *                         be scanned
+     * @param resetLevel       if the level should be reset to 0 on the new sstables
+     * @param clearRepaired    if repaired info should be wiped from the new sstables
+     * @param verifySSTables   if the new sstables should be verified that they are not corrupt
+     * @param verifyTokens     if the tokens in the new sstables should be verified that they are owned by the
+     *                         current node
+     * @param invalidateCaches if row cache should be invalidated for the keys in the new sstables
+     * @param extendedVerify   if we should run an extended verify checking all values in the new sstables
+     * @param copyData         if we should copy data from source paths instead of moving them
+     * @return list of failed import directories
+     */
+    List<String> importNewSSTables(Set<String> srcPaths,
+                                   boolean resetLevel,
+                                   boolean clearRepaired,
+                                   boolean verifySSTables,
+                                   boolean verifyTokens,
+                                   boolean invalidateCaches,
+                                   boolean extendedVerify,
+                                   boolean copyData);
+}
diff --git a/checkstyle.xml b/checkstyle.xml
index 4108268..fd7c1b0 100644
--- a/checkstyle.xml
+++ b/checkstyle.xml
@@ -139,6 +139,7 @@ page at http://checkstyle.sourceforge.net/config.html -->
 
         <module name="JavadocType">
             <property name="scope" value="protected" />
+            <property name="allowUnknownTags" value="true" />
             <property name="severity" value="error" />
         </module>
 
diff --git a/client/README.md b/client/README.md
new file mode 100644
index 0000000..c6e79a7
--- /dev/null
+++ b/client/README.md
@@ -0,0 +1,45 @@
+# Cassandra Sidecar Client
+
+The Sidecar Client is a fully featured client to access Cassandra Sidecar endpoints. It offers support for
+retries and Sidecar instance selection policies. The client project itself is technology-agnostic, but we provide
+a vertx implementation for the `HttpClient`.
+
+## Usage
+
+The `SidecarClient` provides convenience methods to access the sidecar service endpoints. For example, to access
+the ring endpoint we will need to do the following.
+
+```java
+CompletableFuture<RingResponse> ringFuture = sidecarClient.ring(keyspace);
+```
+
+To instantiate the `SidecarClient`, an implementation of the `RequestExecutor` must be provided. Here is an example
+on how to instantiate the `SidecarClient` using vertx's implementation:
+
+```java
+        Vertx vertx = Vertx.vertx(new VertxOptions().setWorkerPoolSize(16));
+
+        HttpClientConfig httpClientConfig = new HttpClientConfig.Builder<>()
+                                            .ssl(false)
+                                            .timeoutMillis(10_000)
+                                            .idleTimeoutMillis(30_000)
+                                            .receiveBufferSize(8196)
+                                            .maxChunkSize(4092)
+                                            .userAgent("sample-agent/0.0.1")
+                                            .build();
+
+        SidecarConfig sidecarConfig = new SidecarConfig.Builder()
+                                      .maxRetries(10)
+                                      .retryDelayMillis(200)
+                                      .maxRetryDelayMillis(10_000)
+                                      .build();
+
+        RetryPolicy defaultRetryPolicy = new ExponentialBackoffRetryPolicy(sidecarConfig.maxRetries(),
+                                                                           sidecarConfig.retryDelayMillis(),
+                                                                           sidecarConfig.maxRetryDelayMillis());
+
+        VertxHttpClient vertxHttpClient = new VertxHttpClient(vertx, httpClientConfig);
+        VertxRequestExecutor requestExecutor = new VertxRequestExecutor(vertxHttpClient);
+        SimpleSidecarInstancesProvider instancesProvider = new SimpleSidecarInstancesProvider(new ArrayList<>(clusterConfig));
+        return new SidecarClient(instancesProvider, requestExecutor, sidecarConfig, defaultRetryPolicy);
+```
diff --git a/client/build.gradle b/client/build.gradle
new file mode 100644
index 0000000..4e530f0
--- /dev/null
+++ b/client/build.gradle
@@ -0,0 +1,94 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+plugins {
+    id('idea')
+    // A Java library
+    id('java-library')
+    // which produces test fixtures
+    id('java-test-fixtures')
+    // and is published
+    id('maven-publish')
+}
+
+group 'org.apache.cassandra.sidecar'
+version project.version
+
+sourceCompatibility = 1.8
+
+repositories {
+    mavenCentral()
+}
+
+test {
+    useJUnitPlatform()
+    testLogging {
+        events "passed", "skipped", "failed"
+    }
+}
+
+configurations {
+    all*.exclude(group: 'ch.qos.logback')
+}
+
+dependencies {
+    api(project(':common'))
+    implementation(group: 'com.fasterxml.jackson.core', name: 'jackson-databind', version: '2.14.2')
+    implementation("org.slf4j:slf4j-api:${project.slf4jVersion}")
+
+    compileOnly('org.jetbrains:annotations:23.0.0')
+    compileOnly(group: 'io.netty', name: 'netty-codec-http', version: '4.1.69.Final')
+
+    testImplementation(platform('org.junit:junit-bom:5.9.2'))
+    testImplementation('org.junit.jupiter:junit-jupiter')
+    testImplementation("org.assertj:assertj-core:3.24.2")
+    testImplementation('org.mockito:mockito-core:4.10.0')
+    testImplementation('com.squareup.okhttp3:mockwebserver:4.10.0')
+    testImplementation(group: 'io.netty', name: 'netty-codec-http', version: '4.1.69.Final')
+
+    testFixturesImplementation(platform('org.junit:junit-bom:5.9.2'))
+    testFixturesImplementation('org.junit.jupiter:junit-jupiter')
+    testFixturesImplementation("org.assertj:assertj-core:3.24.2")
+    testFixturesImplementation('org.mockito:mockito-core:4.10.0')
+    testFixturesImplementation('com.squareup.okhttp3:mockwebserver:4.10.0')
+    testFixturesCompileOnly(group: 'io.netty', name: 'netty-codec-http', version: '4.1.69.Final')
+}
+
+java {
+    withJavadocJar()
+    withSourcesJar()
+}
+
+publishing {
+    publications {
+        maven(MavenPublication) {
+            from components.java
+            groupId project.group
+            artifactId "${archivesBaseName}"
+            version System.getenv("CODE_VERSION") ?: "${version}"
+        }
+    }
+}
+
+javadoc {
+    if (JavaVersion.current().isJava9Compatible()) {
+        options.addBooleanOption('html5', true)
+    }
+}
+
+check.dependsOn(checkstyleMain, checkstyleTest, jacocoTestReport)
diff --git a/client/src/main/java/org/apache/cassandra/sidecar/client/HttpClient.java b/client/src/main/java/org/apache/cassandra/sidecar/client/HttpClient.java
new file mode 100644
index 0000000..9c6d925
--- /dev/null
+++ b/client/src/main/java/org/apache/cassandra/sidecar/client/HttpClient.java
@@ -0,0 +1,54 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.sidecar.client;
+
+import java.util.concurrent.CompletableFuture;
+
+/**
+ * An interface to represent an HTTP client
+ */
+public interface HttpClient extends AutoCloseable
+{
+    /**
+     * @return the configuration parameters for this client
+     */
+    HttpClientConfig config();
+
+    /**
+     * Returns an {@link HttpResponse} {@link CompletableFuture} for the {@code request} on the
+     * provided {@code sidecarInstance}.
+     *
+     * @param sidecarInstance the Sidecar instance
+     * @param requestContext  the request context
+     * @return an {@link HttpResponse} {@link CompletableFuture} for the request on the provided instance
+     */
+    CompletableFuture<HttpResponse> execute(SidecarInstance sidecarInstance, RequestContext requestContext);
+
+    /**
+     * Consumes a stream for the {@code request} using the {@code streamConsumer} object on the provided
+     * {@code sidecarInstance}.
+     *
+     * @param sidecarInstance the Sidecar instance
+     * @param requestContext  the request context
+     * @param streamConsumer  the object that consumes the stream
+     * @return an {@link HttpResponse} {@link CompletableFuture} for the request on the provided instance
+     */
+    CompletableFuture<HttpResponse> stream(SidecarInstance sidecarInstance, RequestContext requestContext,
+                                           StreamConsumer streamConsumer);
+}
diff --git a/client/src/main/java/org/apache/cassandra/sidecar/client/HttpClientConfig.java b/client/src/main/java/org/apache/cassandra/sidecar/client/HttpClientConfig.java
new file mode 100644
index 0000000..f39f425
--- /dev/null
+++ b/client/src/main/java/org/apache/cassandra/sidecar/client/HttpClientConfig.java
@@ -0,0 +1,398 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.sidecar.client;
+
+import java.io.InputStream;
+
+/**
+ * Encapsulates {@code HttpClient} configuration parameters.
+ */
+public class HttpClientConfig
+{
+    public static final long DEFAULT_TIMEOUT_MILLIS = 30_000;
+    public static final boolean DEFAULT_SSL = true;
+    public static final int DEFAULT_MAX_POOL_SIZE = 20;
+    public static final String DEFAULT_USER_AGENT = "sidecar-client/1.0.0";
+    public static final int DEFAULT_IDLE_TIMEOUT_MILLIS = 0; // no timeout
+    public static final int DEFAULT_MAX_CHUNK_SIZE = 6 * 1024 * 1024; // 6 MiB
+    public static final int DEFAULT_RECEIVE_BUFFER_SIZE = -1;
+    public static final int DEFAULT_READ_BUFFER_SIZE = 8 * 1024; // 8 KiB
+    public static final String DEFAULT_TRUST_STORE_TYPE = "JKS";
+    public static final String DEFAULT_KEY_STORE_TYPE = "PKCS12";
+
+    private final long timeoutMillis;
+    private final boolean ssl;
+    private final int maxPoolSize;
+    private final String userAgent;
+    private final int idleTimeoutMillis;
+    private final int maxChunkSize;
+    private final int receiveBufferSize;
+    private final int readBufferSize;
+    private final InputStream trustStoreInputStream;
+    private final String trustStorePassword;
+    private final String trustStoreType;
+    private final InputStream keyStoreInputStream;
+    private final String keyStorePassword;
+    private final String keyStoreType;
+
+    private HttpClientConfig(Builder<?> builder)
+    {
+        timeoutMillis = builder.timeoutMillis;
+        ssl = builder.ssl;
+        maxPoolSize = builder.maxPoolSize;
+        userAgent = builder.userAgent;
+        idleTimeoutMillis = builder.idleTimeoutMillis;
+        maxChunkSize = builder.maxChunkSize;
+        receiveBufferSize = builder.receiveBufferSize;
+        readBufferSize = builder.readBufferSize;
+        trustStoreInputStream = builder.trustStoreInputStream;
+        trustStorePassword = builder.trustStorePassword;
+        trustStoreType = builder.trustStoreType;
+        keyStoreInputStream = builder.keyStoreInputStream;
+        keyStorePassword = builder.keyStorePassword;
+        keyStoreType = builder.keyStoreType;
+    }
+
+    /**
+     * @return the amount of time in milliseconds to wait to receive any data before failing the request
+     */
+    public long timeoutMillis()
+    {
+        return timeoutMillis;
+    }
+
+    /**
+     * @return true if SSL is used, false otherwise
+     */
+    public boolean ssl()
+    {
+        return ssl;
+    }
+
+    /**
+     * @return the maximum pool size for connections
+     */
+    public int maxPoolSize()
+    {
+        return maxPoolSize;
+    }
+
+    /**
+     * @return the user agent to use for the HTTP client
+     */
+    public String userAgent()
+    {
+        return userAgent;
+    }
+
+    /**
+     * @return the amount of time to wait for data to be sent or received before the connection is timed out and closed
+     */
+    public int idleTimeoutMillis()
+    {
+        return idleTimeoutMillis;
+    }
+
+    /**
+     * @return the maximum HTTP chunk size
+     */
+    public int maxChunkSize()
+    {
+        return maxChunkSize;
+    }
+
+    /**
+     * @return the TCP receive buffer size
+     */
+    public int receiveBufferSize()
+    {
+        return receiveBufferSize;
+    }
+
+    public int sendReadBufferSize()
+    {
+        return readBufferSize;
+    }
+
+    /**
+     * @return the {@link InputStream} for the truststore to use for the request trust options
+     */
+    public InputStream trustStoreInputStream()
+    {
+        return trustStoreInputStream;
+    }
+
+    /**
+     * @return the password for the provided {@link #trustStorePassword}
+     */
+    public String trustStorePassword()
+    {
+        return trustStorePassword;
+    }
+
+    /**
+     * @return the trust store type
+     */
+    public String trustStoreType()
+    {
+        return trustStoreType;
+    }
+
+    /**
+     * @return the {@link InputStream} for the keystore to use for the request key cert options
+     */
+    public InputStream keyStoreInputStream()
+    {
+        return keyStoreInputStream;
+    }
+
+    /**
+     * @return the password for the provided {@link #keyStorePassword}
+     */
+    public String keyStorePassword()
+    {
+        return keyStorePassword;
+    }
+
+    /**
+     * @return the keystore type
+     */
+    public String keyStoreType()
+    {
+        return keyStoreType;
+    }
+
+    /**
+     * {@code HttpClient} builder static inner class.
+     *
+     * @param <T> the type of the Builder
+     */
+    public static class Builder<T extends Builder<T>>
+    {
+        private long timeoutMillis = DEFAULT_TIMEOUT_MILLIS;
+        private boolean ssl = DEFAULT_SSL;
+        private int maxPoolSize = DEFAULT_MAX_POOL_SIZE;
+        public int idleTimeoutMillis = DEFAULT_IDLE_TIMEOUT_MILLIS;
+        private String userAgent = DEFAULT_USER_AGENT;
+        private int maxChunkSize = DEFAULT_MAX_CHUNK_SIZE;
+        private int receiveBufferSize = DEFAULT_RECEIVE_BUFFER_SIZE;
+        private int readBufferSize = DEFAULT_READ_BUFFER_SIZE;
+        private InputStream trustStoreInputStream;
+        private String trustStorePassword;
+        private String trustStoreType = DEFAULT_TRUST_STORE_TYPE;
+        private InputStream keyStoreInputStream;
+        private String keyStorePassword;
+        private String keyStoreType = DEFAULT_KEY_STORE_TYPE;
+
+        /**
+         * @return a reference to itself
+         */
+        @SuppressWarnings("unchecked")
+        protected T self()
+        {
+            return (T) this;
+        }
+
+        /**
+         * Sets the {@code timeoutMillis} and returns a reference to this Builder enabling method chaining.
+         * Defaults to {@code 30,000} milliseconds ({@code 30} seconds).
+         *
+         * @param timeoutMillis the {@code timeoutMillis} to set
+         * @return a reference to this Builder
+         */
+        public T timeoutMillis(long timeoutMillis)
+        {
+            this.timeoutMillis = timeoutMillis;
+            return self();
+        }
+
+        /**
+         * Sets the {@code ssl} and returns a reference to this Builder enabling method chaining. Defaults to
+         * {@code false}.
+         *
+         * @param ssl the {@code ssl} to set
+         * @return a reference to this Builder
+         */
+        public T ssl(boolean ssl)
+        {
+            this.ssl = ssl;
+            return self();
+        }
+
+        /**
+         * Sets the maximum pool size for connections and returns a reference to this Builder enabling method chaining.
+         * Defaults to {@code 20}.
+         *
+         * @param maxPoolSize the maximum pool size
+         * @return a reference to this Builder
+         */
+        public T maxPoolSize(int maxPoolSize)
+        {
+            this.maxPoolSize = maxPoolSize;
+            return self();
+        }
+
+        /**
+         * Set the Web Client {@code userAgent} and returns a reference to this Builder enabling method chaining.
+         * Defaults to sidecar-client/1.0.0
+         *
+         * @param userAgent the {@code userAgent} to set
+         * @return a reference to this Builder
+         */
+        public T userAgent(String userAgent)
+        {
+            this.userAgent = userAgent;
+            return self();
+        }
+
+        /**
+         * Sets the {@code idleTimeoutMillis}. Zero means do not timeout. This determines if a connection will timeout
+         * and be closed if no data is received nor sent within the timeout.
+         *
+         * @param idleTimeoutMillis to {@code idleTimeoutMillis} to set
+         * @return a reference to this Builder
+         */
+        public T idleTimeoutMillis(int idleTimeoutMillis)
+        {
+            this.idleTimeoutMillis = idleTimeoutMillis;
+            return self();
+        }
+
+        /**
+         * Set the maximum HTTP chunk size {@code maxChunkSize} and returns a reference to this Builder enabling
+         * method chaining. Defaults to {@code 6 MiB}.
+         *
+         * @param maxChunkSize the {@code maxChunkSize} to set
+         * @return a reference to this Builder
+         */
+        public T maxChunkSize(int maxChunkSize)
+        {
+            this.maxChunkSize = maxChunkSize;
+            return self();
+        }
+
+        /**
+         * Sets the {@code receiveBufferSize} and returns a reference to this Builder enabling method chaining.
+         * The default value of TCP receive buffer size is -1.
+         *
+         * @param receiveBufferSize the {@code receiveBufferSize} to set
+         * @return a reference to this Builder
+         */
+        public T receiveBufferSize(int receiveBufferSize)
+        {
+            this.receiveBufferSize = receiveBufferSize;
+            return self();
+        }
+
+        /**
+         * Sets the {@code readBufferSize} that will be used to read the data from the files for upload.
+         * Changing this value will impact how much the data will be read at a time from the file system.
+         *
+         * @param readBufferSize the buffer size
+         * @return a reference to this Builder
+         */
+        public T readBufferSize(int readBufferSize)
+        {
+            this.readBufferSize = readBufferSize;
+            return self();
+        }
+
+        /**
+         * Sets the {@code trustStoreInputStream} and returns a reference to this Builder enabling method chaining.
+         *
+         * @param trustStoreInputStream the {@code trustStoreInputStream} to set
+         * @return a reference to this Builder
+         */
+        public T trustStoreInputStream(InputStream trustStoreInputStream)
+        {
+            this.trustStoreInputStream = trustStoreInputStream;
+            return self();
+        }
+
+        /**
+         * Sets the {@code trustStorePassword} and returns a reference to this Builder enabling method chaining.
+         *
+         * @param trustStorePassword the {@code trustStorePassword} to set
+         * @return a reference to this Builder
+         */
+        public T trustStorePassword(String trustStorePassword)
+        {
+            this.trustStorePassword = trustStorePassword;
+            return self();
+        }
+
+        /**
+         * Sets the {@code trustStoreType} and returns a reference to this Builder enabling method chaining.
+         *
+         * @param trustStoreType the {@code trustStoreType} to set
+         * @return a reference to this Builder
+         */
+        public T trustStoreType(String trustStoreType)
+        {
+            this.trustStoreType = trustStoreType;
+            return self();
+        }
+
+        /**
+         * Sets the {@code keyStoreInputStream} and returns a reference to this Builder enabling method chaining.
+         *
+         * @param keyStoreInputStream the {@code keyStoreInputStream} to set
+         * @return a reference to this Builder
+         */
+        public T keyStoreInputStream(InputStream keyStoreInputStream)
+        {
+            this.keyStoreInputStream = keyStoreInputStream;
+            return self();
+        }
+
+        /**
+         * Sets the {@code keyStorePassword} and returns a reference to this Builder enabling method chaining.
+         *
+         * @param keyStorePassword the {@code keyStorePassword} to set
+         * @return a reference to this Builder
+         */
+        public T keyStorePassword(String keyStorePassword)
+        {
+            this.keyStorePassword = keyStorePassword;
+            return self();
+        }
+
+        /**
+         * Sets the {@code keyStoreType} and returns a reference to this Builder enabling method chaining.
+         *
+         * @param keyStoreType the {@code keyStoreType} to set
+         * @return a reference to this Builder
+         */
+        public T keyStoreType(String keyStoreType)
+        {
+            this.keyStoreType = keyStoreType;
+            return self();
+        }
+
+        /**
+         * Returns a {@code SidecarClientConfig} built from the parameters previously set.
+         *
+         * @return a {@code SidecarClientConfig} built with parameters of this {@code SidecarClientConfig.Builder}
+         */
+        public HttpClientConfig build()
+        {
+            return new HttpClientConfig(this);
+        }
+    }
+}
diff --git a/client/src/main/java/org/apache/cassandra/sidecar/client/HttpResponse.java b/client/src/main/java/org/apache/cassandra/sidecar/client/HttpResponse.java
new file mode 100644
index 0000000..e5fd774
--- /dev/null
+++ b/client/src/main/java/org/apache/cassandra/sidecar/client/HttpResponse.java
@@ -0,0 +1,65 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.sidecar.client;
+
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Represents the HTTP response received from the remote Sidecar service
+ */
+public interface HttpResponse
+{
+    /**
+     * @return the numerical status code
+     */
+    int statusCode();
+
+    /**
+     * @return the status message for the response
+     */
+    String statusMessage();
+
+    /**
+     * @return the raw bytes received from the service
+     */
+    byte[] raw();
+
+    /**
+     * @return the content of the response body with {@link StandardCharsets#UTF_8} encoding
+     */
+    default String contentAsString()
+    {
+        return contentAsString(StandardCharsets.UTF_8.name());
+    }
+
+    /**
+     * Returns the content of the response body the specified {@code charset}.
+     *
+     * @param charset the charset for the response
+     * @return the content of the response body the specified {@code charset}
+     */
+    String contentAsString(String charset);
+
+    /**
+     * @return the headers for the response
+     */
+    Map<String, List<String>> headers();
+}
diff --git a/client/src/main/java/org/apache/cassandra/sidecar/client/HttpResponseImpl.java b/client/src/main/java/org/apache/cassandra/sidecar/client/HttpResponseImpl.java
new file mode 100644
index 0000000..432a9dc
--- /dev/null
+++ b/client/src/main/java/org/apache/cassandra/sidecar/client/HttpResponseImpl.java
@@ -0,0 +1,133 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.sidecar.client;
+
+import java.io.UnsupportedEncodingException;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * A simple implementation of the {@link HttpResponse}
+ */
+public class HttpResponseImpl implements HttpResponse
+{
+    private final int statusCode;
+    private final String statusMessage;
+    private final byte[] raw;
+    private final Map<String, List<String>> headers;
+
+    /**
+     * Constructs a response object with the provided values
+     *
+     * @param statusCode    the status code of the response
+     * @param statusMessage the status message of the response
+     * @param headers       the headers from the response
+     */
+    public HttpResponseImpl(int statusCode, String statusMessage, Map<String, List<String>> headers)
+    {
+        this(statusCode, statusMessage, null, headers);
+    }
+
+    /**
+     * Constructs a response object with the provided values
+     *
+     * @param statusCode    the status code of the response
+     * @param statusMessage the status message of the response
+     * @param raw           the raw bytes received from the response
+     * @param headers       the headers from the response
+     */
+    public HttpResponseImpl(int statusCode, String statusMessage, byte[] raw, Map<String, List<String>> headers)
+    {
+        this.statusCode = statusCode;
+        this.statusMessage = statusMessage;
+        this.raw = raw;
+        this.headers = Collections.unmodifiableMap(headers);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public int statusCode()
+    {
+        return statusCode;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public String statusMessage()
+    {
+        return statusMessage;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public byte[] raw()
+    {
+        return raw;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public String contentAsString(String charset)
+    {
+        if (raw == null)
+        {
+            return null;
+        }
+        try
+        {
+            return new String(raw, charset);
+        }
+        catch (UnsupportedEncodingException unsupportedEncodingException)
+        {
+            throw new RuntimeException("Unable to decode for charset " + charset, unsupportedEncodingException);
+        }
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public Map<String, List<String>> headers()
+    {
+        return headers;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public String toString()
+    {
+        return "HttpResponseImpl{" +
+               "statusCode=" + statusCode +
+               ", statusMessage='" + statusMessage + '\'' +
+               ", contentAsString='" + contentAsString() + '\'' +
+               ", headers=" + headers +
+               '}';
+    }
+}
diff --git a/client/src/main/java/org/apache/cassandra/sidecar/client/RequestContext.java b/client/src/main/java/org/apache/cassandra/sidecar/client/RequestContext.java
new file mode 100644
index 0000000..0c3a16e
--- /dev/null
+++ b/client/src/main/java/org/apache/cassandra/sidecar/client/RequestContext.java
@@ -0,0 +1,442 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.sidecar.client;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.apache.cassandra.sidecar.client.request.CleanSSTableUploadSessionRequest;
+import org.apache.cassandra.sidecar.client.request.ClearSnapshotRequest;
+import org.apache.cassandra.sidecar.client.request.CreateSnapshotRequest;
+import org.apache.cassandra.sidecar.client.request.GossipInfoRequest;
+import org.apache.cassandra.sidecar.client.request.ImportSSTableRequest;
+import org.apache.cassandra.sidecar.client.request.ListSnapshotFilesRequest;
+import org.apache.cassandra.sidecar.client.request.NodeSettingsRequest;
+import org.apache.cassandra.sidecar.client.request.Request;
+import org.apache.cassandra.sidecar.client.request.RingRequest;
+import org.apache.cassandra.sidecar.client.request.SSTableComponentRequest;
+import org.apache.cassandra.sidecar.client.request.SchemaRequest;
+import org.apache.cassandra.sidecar.client.request.TimeSkewRequest;
+import org.apache.cassandra.sidecar.client.request.UploadSSTableRequest;
+import org.apache.cassandra.sidecar.client.retry.ExponentialBackoffRetryPolicy;
+import org.apache.cassandra.sidecar.client.retry.NoRetryPolicy;
+import org.apache.cassandra.sidecar.client.retry.RetryPolicy;
+import org.apache.cassandra.sidecar.client.selection.InstanceSelectionPolicy;
+import org.apache.cassandra.sidecar.client.selection.SingleInstanceSelectionPolicy;
+import org.apache.cassandra.sidecar.common.utils.HttpRange;
+
+/**
+ * The context for a given request that include the {@link InstanceSelectionPolicy}, the {@link RetryPolicy}, and
+ * the {@link Request}
+ */
+public class RequestContext
+{
+    protected static final SchemaRequest FULL_SCHEMA_REQUEST = new SchemaRequest();
+    protected static final TimeSkewRequest TIME_SKEW_REQUEST = new TimeSkewRequest();
+    protected static final NodeSettingsRequest NODE_SETTINGS_REQUEST = new NodeSettingsRequest();
+    protected static final RingRequest RING_REQUEST = new RingRequest();
+    protected static final GossipInfoRequest GOSSIP_INFO_REQUEST = new GossipInfoRequest();
+    protected static final RetryPolicy DEFAULT_RETRY_POLICY = new NoRetryPolicy();
+    protected static final RetryPolicy DEFAULT_EXPONENTIAL_BACKOFF_RETRY_POLICY =
+    new ExponentialBackoffRetryPolicy(10, 500L, 60_000L);
+
+    private final InstanceSelectionPolicy instanceSelectionPolicy;
+    private final Request request;
+    private final RetryPolicy retryPolicy;
+    private final Map<String, String> customHeaders;
+
+    private RequestContext(Builder builder)
+    {
+        instanceSelectionPolicy = builder.instanceSelectionPolicy;
+        request = builder.request;
+        retryPolicy = builder.retryPolicy;
+        customHeaders = Collections.unmodifiableMap(builder.customHeaders);
+    }
+
+    /**
+     * @return the instance selection policy for the request
+     */
+    public InstanceSelectionPolicy instanceSelectionPolicy()
+    {
+        return instanceSelectionPolicy;
+    }
+
+    /**
+     * @return the retry policy associated to the request
+     */
+    public RetryPolicy retryPolicy()
+    {
+        return retryPolicy;
+    }
+
+    /**
+     * @return the request for the Sidecar service
+     */
+    public Request request()
+    {
+        return request;
+    }
+
+    /**
+     * @return the custom headers for the request
+     */
+    public Map<String, String> customHeaders()
+    {
+        return customHeaders;
+    }
+
+    /**
+     * {@code RequestContext} builder static inner class.
+     */
+    public static final class Builder
+    {
+        private InstanceSelectionPolicy instanceSelectionPolicy;
+        private Request request;
+        private RetryPolicy retryPolicy = DEFAULT_RETRY_POLICY;
+        private final Map<String, String> customHeaders;
+
+        public Builder()
+        {
+            customHeaders = new HashMap<>();
+        }
+
+        private Builder(Builder builder)
+        {
+            request = builder.request;
+            instanceSelectionPolicy = builder.instanceSelectionPolicy;
+            retryPolicy = builder.retryPolicy;
+            customHeaders = new HashMap<>(builder.customHeaders);
+        }
+
+        /**
+         * Sets the {@code instanceSelectionPolicy} and returns a reference to this Builder enabling method chaining.
+         *
+         * @param instanceSelectionPolicy the {@code instanceSelectionPolicy} to set
+         * @return a reference to this Builder
+         */
+        public Builder instanceSelectionPolicy(InstanceSelectionPolicy instanceSelectionPolicy)
+        {
+            this.instanceSelectionPolicy = instanceSelectionPolicy;
+            return this;
+        }
+
+        /**
+         * Sets the {@code instanceSelectionPolicy} to the {@link SingleInstanceSelectionPolicy} for the provided
+         * {@code sidecarInstance} and returns a reference to this Builder enabling method chaining.
+         *
+         * @param sidecarInstance the Sidecar instance where the request will be performed
+         * @return a reference to this Builder
+         */
+        public Builder singleInstanceSelectionPolicy(SidecarInstance sidecarInstance)
+        {
+            return instanceSelectionPolicy(new SingleInstanceSelectionPolicy(sidecarInstance));
+        }
+
+        /**
+         * Sets the {@code request} and returns a reference to this Builder enabling method chaining.
+         *
+         * @param request the {@code request} to set
+         * @return a reference to this Builder
+         */
+        public Builder request(Request request)
+        {
+            this.request = request;
+            return this;
+        }
+
+        /**
+         * Sets the {@code retryPolicy} and returns a reference to this Builder enabling method chaining.
+         *
+         * @param retryPolicy the {@code retryPolicy} to set
+         * @return a reference to this Builder
+         */
+        public Builder retryPolicy(RetryPolicy retryPolicy)
+        {
+            this.retryPolicy = retryPolicy;
+            return this;
+        }
+
+        /**
+         * Sets the {@code request} to be a {@link SchemaRequest} for the full schema and returns a reference to
+         * this Builder enabling method chaining.
+         *
+         * @return a reference to this Builder
+         */
+        public Builder schemaRequest()
+        {
+            return request(FULL_SCHEMA_REQUEST);
+        }
+
+        /**
+         * Sets the {@code request} to be a {@link SchemaRequest} for the specified {@code keyspace} and returns a
+         * reference to this Builder enabling method chaining
+         *
+         * @param keyspace the keyspace in Cassandra
+         * @return a reference to this Builder
+         */
+        public Builder schemaRequest(String keyspace)
+        {
+            return request(new SchemaRequest(keyspace));
+        }
+
+        /**
+         * Sets the {@code request} to be a {@link TimeSkewRequest} and returns a reference to this Builder enabling
+         * method chaining.
+         *
+         * @return a reference to this Builder
+         */
+        public Builder timeSkewRequest()
+        {
+            return request(TIME_SKEW_REQUEST);
+        }
+
+        /**
+         * Sets the {@code request} to be a {@link NodeSettingsRequest} and returns a reference to this Builder
+         * enabling method chaining.
+         *
+         * @return a reference to this Builder
+         */
+        public Builder nodeSettingsRequest()
+        {
+            return request(NODE_SETTINGS_REQUEST);
+        }
+
+        /**
+         * Sets the {@code request} to be a {@link RingRequest} and returns a reference to this Builder enabling
+         * method chaining.
+         *
+         * @return a reference to this Builder
+         */
+        public Builder ringRequest()
+        {
+            return request(RING_REQUEST);
+        }
+
+        /**
+         * Sets the {@code request} to be a {@link RingRequest} for the given {@code keyspace} and returns a reference
+         * to this Builder enabling method chaining.
+         *
+         * @param keyspace the keyspace in Cassandra
+         * @return a reference to this Builder
+         */
+        public Builder ringRequest(String keyspace)
+        {
+            return request(new RingRequest(keyspace));
+        }
+
+        /**
+         * Sets the {@code request} to be a {@link GossipInfoRequest} and returns a reference to this Builder enabling
+         * method chaining.
+         *
+         * @return a reference to this Builder
+         */
+        public Builder gossipInfoRequest()
+        {
+            return request(GOSSIP_INFO_REQUEST);
+        }
+
+        /**
+         * Sets the {@code request} to be a {@link SSTableComponentRequest} for the given {@code keyspace},
+         * {@code tableName}, {@code snapshotName}, {@code componentName}, requesting the specified {@code range} and
+         * returns a reference to this Builder enabling method chaining.
+         *
+         * @param keyspace      the keyspace in Cassandra
+         * @param tableName     the table name in Cassandra
+         * @param snapshotName  the name of the snapshot
+         * @param componentName the name of the SSTable component
+         * @param range         the HTTP range for the request
+         * @return a reference to this Builder
+         */
+        public Builder ssTableComponentRequest(String keyspace,
+                                               String tableName,
+                                               String snapshotName,
+                                               String componentName,
+                                               HttpRange range)
+        {
+            return request(new SSTableComponentRequest(keyspace, tableName, snapshotName, componentName, range));
+        }
+
+        /**
+         * Sets the {@code request} to be a {@link ListSnapshotFilesRequest} for the given {@code keyspace},
+         * {@code tableName}, and {@code snapshotName} and returns a reference to this Builder enabling method chaining.
+         *
+         * @param keyspace     the keyspace in Cassandra
+         * @param tableName    the table name in Cassandra
+         * @param snapshotName the name of the snapshot
+         * @return a reference to this Builder
+         */
+        public Builder listSnapshotFilesRequest(String keyspace, String tableName, String snapshotName)
+        {
+            return listSnapshotFilesRequest(keyspace, tableName, snapshotName, false);
+        }
+
+        /**
+         * Sets the {@code request} to be a {@link CreateSnapshotRequest} for the given {@code keyspace},
+         * {@code tableName}, and {@code snapshotName} and returns a reference to this Builder enabling method chaining.
+         *
+         * @param keyspace     the keyspace in Cassandra
+         * @param tableName    the table name in Cassandra
+         * @param snapshotName the name of the snapshot
+         * @return a reference to this Builder
+         */
+        public Builder createSnapshotRequest(String keyspace, String tableName, String snapshotName)
+        {
+            return request(new CreateSnapshotRequest(keyspace, tableName, snapshotName));
+        }
+
+        /**
+         * Sets the {@code request} to be a {@link ListSnapshotFilesRequest} for the given {@code keyspace},
+         * {@code tableName}, and {@code snapshotName} and returns a reference to this Builder enabling method chaining.
+         *
+         * @param keyspace                   the keyspace in Cassandra
+         * @param tableName                  the table name in Cassandra
+         * @param snapshotName               the name of the snapshot
+         * @param includeSecondaryIndexFiles whether to include secondary index files
+         * @return a reference to this Builder
+         */
+        public Builder listSnapshotFilesRequest(String keyspace, String tableName, String snapshotName,
+                                                boolean includeSecondaryIndexFiles)
+        {
+            return request(new ListSnapshotFilesRequest(keyspace, tableName, snapshotName, includeSecondaryIndexFiles));
+        }
+
+        /**
+         * Sets the {@code request} to be a {@link ClearSnapshotRequest} for the given {@code keyspace},
+         * {@code tableName}, and {@code snapshotName} and returns a reference to this Builder enabling method chaining.
+         *
+         * @param keyspace     the keyspace in Cassandra
+         * @param tableName    the table name in Cassandra
+         * @param snapshotName the name of the snapshot
+         * @return a reference to this Builder
+         */
+        public Builder clearSnapshotRequest(String keyspace, String tableName, String snapshotName)
+        {
+            return request(new ClearSnapshotRequest(keyspace, tableName, snapshotName));
+        }
+
+        /**
+         * Sets the {@code request} to be a {@link CleanSSTableUploadSessionRequest} for the given {@code uploadId}
+         * and returns a reference to this Builder enabling method chaining.
+         *
+         * @param uploadId the unique identifier for the upload
+         * @return a reference to this Builder
+         */
+        public Builder cleanSSTableUploadSessionRequest(String uploadId)
+        {
+            return request(new CleanSSTableUploadSessionRequest(uploadId));
+        }
+
+        /**
+         * Sets the {@code request} to be a {@link ImportSSTableRequest} for the given {@code keyspace},
+         * {@code tableName}, {@code uploadId}, and selected {@link ImportSSTableRequest.ImportOptions importOptions}
+         * and returns a reference to this Builder enabling method chaining.
+         *
+         * @param keyspace      the keyspace in Cassandra
+         * @param tableName     the table name in Cassandra
+         * @param uploadId      an identifier for the upload
+         * @param importOptions additional options for the import process
+         * @return a reference to this Builder
+         */
+        public Builder importSSTableRequest(String keyspace, String tableName, String uploadId,
+                                            ImportSSTableRequest.ImportOptions importOptions)
+        {
+            return request(new ImportSSTableRequest(keyspace, tableName, uploadId, importOptions));
+        }
+
+        /**
+         * Sets the {@code} request to be a {@link UploadSSTableRequest} for the given {@code keyspace},
+         * {@code tableName}, {@code uploadId}, {@code component}, and returns a
+         * reference to this Builder enabling method chaining.
+         *
+         * @param keyspace  the keyspace in Cassandra
+         * @param tableName the table name in Cassandra
+         * @param uploadId  an identifier for the upload
+         * @param component SSTable component being uploaded
+         * @param checksum  hash value to check integrity of SSTable component uploaded
+         * @param filename  the path to the file to be uploaded
+         * @return a reference to this Builder
+         */
+        public Builder uploadSSTableRequest(String keyspace, String tableName, String uploadId, String component,
+                                            String checksum, String filename)
+        {
+            return request(new UploadSSTableRequest(keyspace, tableName, uploadId, component, checksum, filename));
+        }
+
+        /**
+         * Sets the {@code retryPolicy} to be an
+         * {@link org.apache.cassandra.sidecar.client.retry.ExponentialBackoffRetryPolicy} configured with
+         * {@code 10} {@code maxRetries}, {@code 500} {@code retryDelayMillis}, and {@code 60,000}
+         * {@code maxRetryDelayMillis}; and returns a reference to this Builder enabling method chaining.
+         *
+         * @return a reference to this Builder
+         */
+        public Builder exponentialBackoffRetryPolicy()
+        {
+            return retryPolicy(DEFAULT_EXPONENTIAL_BACKOFF_RETRY_POLICY);
+        }
+
+        /**
+         * Sets the {@code retryPolicy} to be an
+         * {@link org.apache.cassandra.sidecar.client.retry.ExponentialBackoffRetryPolicy} configured with the provided
+         * {@code maxRetries}, {@code retryDelayMillis}, and {@code maxRetryDelayMillis}; and returns a reference to
+         * this Builder enabling method chaining.
+         *
+         * @param maxRetries          the maximum number of retries
+         * @param retryDelayMillis    the delay between retries in milliseconds
+         * @param maxRetryDelayMillis the maximum retry delay in milliseconds
+         * @return a reference to this Builder
+         */
+        public Builder exponentialBackoffRetryPolicy(int maxRetries, long retryDelayMillis, long maxRetryDelayMillis)
+        {
+            return retryPolicy(new ExponentialBackoffRetryPolicy(maxRetries, retryDelayMillis, maxRetryDelayMillis));
+        }
+
+        /**
+         * Adds a custom header for the request and returns a reference to this Builder enabling method chaining.
+         *
+         * @param name  the header name
+         * @param value the header value
+         * @return a reference to this Builder
+         */
+        public Builder addCustomHeader(String name, String value)
+        {
+            customHeaders.put(name, value);
+            return this;
+        }
+
+        /**
+         * @return a new Builder with references to the parameters previously set in this Builder
+         */
+        public Builder copy()
+        {
+            return new Builder(this);
+        }
+
+        /**
+         * Returns a {@code RequestContext} built from the parameters previously set.
+         *
+         * @return a {@code RequestContext} built with parameters of this {@code RequestContext.Builder}
+         */
+        public RequestContext build()
+        {
+            return new RequestContext(this);
+        }
+    }
+}
diff --git a/client/src/main/java/org/apache/cassandra/sidecar/client/RequestExecutor.java b/client/src/main/java/org/apache/cassandra/sidecar/client/RequestExecutor.java
new file mode 100644
index 0000000..84c7702
--- /dev/null
+++ b/client/src/main/java/org/apache/cassandra/sidecar/client/RequestExecutor.java
@@ -0,0 +1,438 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.sidecar.client;
+
+import java.io.IOException;
+import java.util.Iterator;
+import java.util.Objects;
+import java.util.concurrent.CancellationException;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import io.netty.handler.codec.http.HttpResponseStatus;
+import org.apache.cassandra.sidecar.client.exception.RetriesExhaustedException;
+import org.apache.cassandra.sidecar.client.request.DecodableRequest;
+import org.apache.cassandra.sidecar.client.request.Request;
+
+import static java.util.Objects.requireNonNull;
+
+/**
+ * Executes requests to Cassandra Sidecar
+ */
+public class RequestExecutor implements AutoCloseable
+{
+    protected final Logger logger = LoggerFactory.getLogger(this.getClass());
+
+    protected final HttpClient httpClient;
+    protected final ScheduledExecutorService singleThreadExecutorService;
+
+    protected RequestExecutor(HttpClient httpClient)
+    {
+        this.httpClient = requireNonNull(httpClient, "The httpClient is required");
+        this.singleThreadExecutorService = Executors.newSingleThreadScheduledExecutor();
+    }
+
+    /**
+     * Executes the request and waits if necessary for at most the configured time in the
+     * {@link HttpClientConfig#timeoutMillis()} for this future to complete, and then returns its result, if available.
+     *
+     * @param context the request context
+     * @param <T>     the expected type for the instance
+     * @return the result value
+     * @throws CancellationException if this future was cancelled
+     * @throws ExecutionException    if this future completed exceptionally
+     * @throws InterruptedException  if the current thread was interrupted while waiting
+     * @throws TimeoutException      if the wait timed out
+     */
+    public <T> T executeRequest(RequestContext context)
+    throws ExecutionException, InterruptedException, TimeoutException
+    {
+        return executeRequest(context, httpClient.config().timeoutMillis(), TimeUnit.MILLISECONDS);
+    }
+
+    /**
+     * Executes the request and waits if necessary for at most the provided {@code timeout} with units {@code unit}
+     * for this future to complete, and then returns its result, if available.
+     *
+     * @param context the request context
+     * @param timeout the maximum time to wait
+     * @param unit    the time unit of the timeout argument
+     * @param <T>     the expected type for the instance
+     * @return the result value
+     * @throws CancellationException if this future was cancelled
+     * @throws ExecutionException    if this future completed exceptionally
+     * @throws InterruptedException  if the current thread was interrupted while waiting
+     * @throws TimeoutException      if the wait timed out
+     */
+    public <T> T executeRequest(RequestContext context, long timeout, TimeUnit unit)
+    throws ExecutionException, InterruptedException, TimeoutException
+    {
+        return this.<T>executeRequestAsync(context).get(timeout, unit);
+    }
+
+    /**
+     * Returns the expected instance of type {@code <T>} after executing the {@code request} and processing it.
+     *
+     * @param context the request context
+     * @param <T>     the expected type for the instance
+     * @return the expected instance of type {@code <T>} after executing the {@code request} and processing it
+     */
+    public <T> CompletableFuture<T> executeRequestAsync(RequestContext context)
+    {
+        CompletableFuture<T> resultFuture = new CompletableFuture<>();
+        CompletableFuture<HttpResponse> responseFuture = new CompletableFuture<>();
+        Iterator<SidecarInstance> iterator = context.instanceSelectionPolicy().iterator();
+        executeWithRetries(responseFuture, iterator, context, 1, null);
+
+        responseFuture.whenComplete((response, retryThrowable) ->
+                                    processResponse(resultFuture, context.request(), response, retryThrowable));
+
+        return resultFuture;
+    }
+
+    /**
+     * Streams the request from the context to the {@code streamConsumer}.
+     *
+     * @param context        the request context
+     * @param streamConsumer the object that consumes the stream
+     */
+    public void streamRequest(RequestContext context, StreamConsumer streamConsumer)
+    {
+        Objects.requireNonNull(streamConsumer, "streamConsumer must be non-null");
+        Iterator<SidecarInstance> iterator = context.instanceSelectionPolicy().iterator();
+
+        CompletableFuture<HttpResponse> responseFuture = new CompletableFuture<>();
+        streamWithRetries(responseFuture, streamConsumer, iterator, context, 1, null);
+
+        responseFuture.whenComplete(((response, throwable) -> {
+            if (throwable != null)
+            {
+                streamConsumer.onError(throwable);
+            }
+        }));
+    }
+
+    /**
+     * Closes the underlying HTTP client
+     */
+    public void close() throws Exception
+    {
+        httpClient.close();
+    }
+
+    /**
+     * Executes the request from the {@code context}, it iterates over the {@link SidecarInstance}s until the response
+     * satisfies the {@code retryPolicy}.
+     *
+     * @param future    a future for the {@link HttpResponse}
+     * @param iterator  the iterator of instances
+     * @param context   the request context
+     * @param attempt   the number of attempts for this request
+     * @param throwable the last {@link Throwable}, or {@code null} if there are no previous errors
+     */
+    protected void executeWithRetries(CompletableFuture<HttpResponse> future,
+                                      Iterator<SidecarInstance> iterator,
+                                      RequestContext context,
+                                      int attempt,
+                                      Throwable throwable)
+    {
+        if (iterator.hasNext())
+        {
+            executeWithRetries(future, iterator, iterator.next(), context, attempt);
+        }
+        else
+        {
+            // exhausted retries on all available hosts
+            future.completeExceptionally(new RetriesExhaustedException(attempt, context.request(), throwable));
+        }
+    }
+
+    /**
+     * Streams the request from the {@code context} to the {@code streamConsumer}. It iterates over the
+     * {@link SidecarInstance}s until the response satisfies the {@code retryPolicy}.
+     *
+     * @param future         a future for the {@link HttpResponse}
+     * @param streamConsumer the object that consumes the stream
+     * @param iterator       the iterator of Sidecar instances
+     * @param context        the request context
+     * @param attempt        the number of attempts for this request
+     * @param throwable      the last {@link Throwable}, or {@code null} if there are no previous errors
+     */
+    private void streamWithRetries(CompletableFuture<HttpResponse> future,
+                                   StreamConsumer streamConsumer,
+                                   Iterator<SidecarInstance> iterator,
+                                   RequestContext context,
+                                   int attempt,
+                                   Throwable throwable)
+    {
+        if (iterator.hasNext())
+        {
+            streamWithRetries(future, streamConsumer, iterator, iterator.next(), context, attempt);
+        }
+        else
+        {
+            // exhausted retries on all available hosts
+            streamConsumer.onError(new RetriesExhaustedException(attempt, context.request(), throwable));
+        }
+    }
+
+    /**
+     * Executes the {@code request} from the {@code context} on the provided {@code sidecarInstance}, and applies the
+     * retry policy after complete.
+     *
+     * @param future          a future for the {@link HttpResponse}
+     * @param iterator        the iterator of instances
+     * @param sidecarInstance the Sidecar instance where the request will be performed
+     * @param context         the request context
+     * @param attempt         the number of attempts for this request
+     */
+    protected void executeWithRetries(CompletableFuture<HttpResponse> future,
+                                      Iterator<SidecarInstance> iterator,
+                                      SidecarInstance sidecarInstance,
+                                      RequestContext context,
+                                      int attempt)
+    {
+        logger.debug("Request from instance={}, request={}, attempt={}",
+                     sidecarInstance, context.request(), attempt);
+
+        // execute the http request and process the response with the retry policy
+        try
+        {
+            httpClient.execute(sidecarInstance, context)
+                      .whenComplete((HttpResponse response, Throwable throwable) ->
+                                    applyRetryPolicy(future,
+                                                     iterator,
+                                                     sidecarInstance,
+                                                     context,
+                                                     attempt,
+                                                     response,
+                                                     throwable));
+        }
+        catch (Throwable throwable)
+        {
+            logger.error("Unexpected error while executing the request. instance={}, request={}, attempt={}",
+                         sidecarInstance, context.request(), attempt);
+            future.completeExceptionally(throwable);
+        }
+    }
+
+    /**
+     * Streams the request from the {@code context} to the {@code streamConsumer} from the provided
+     * {@code sidecarInstance}, and applies the retry policy after inspecting the response headers.
+     *
+     * @param future          a future for the {@link HttpResponse}
+     * @param streamConsumer  the object that consumes the stream
+     * @param iterator        the iterator of instances
+     * @param sidecarInstance the Sidecar instance where the request will be performed
+     * @param context         the request context
+     * @param attempt         the number of attempts for this request
+     */
+    private void streamWithRetries(CompletableFuture<HttpResponse> future,
+                                   StreamConsumer streamConsumer,
+                                   Iterator<SidecarInstance> iterator,
+                                   SidecarInstance sidecarInstance,
+                                   RequestContext context,
+                                   int attempt)
+    {
+        logger.debug("Streaming from instance={}, request={}, attempt={}",
+                     sidecarInstance, context.request(), attempt);
+
+        try
+        {
+            httpClient.stream(sidecarInstance, context, streamConsumer)
+                      .whenComplete((HttpResponse response, Throwable throwable) ->
+                                    applyRetryPolicy(future,
+                                                     streamConsumer,
+                                                     iterator,
+                                                     sidecarInstance,
+                                                     context,
+                                                     attempt,
+                                                     response,
+                                                     throwable));
+        }
+        catch (Throwable throwable)
+        {
+            logger.error("Unexpected error while streaming. instance={}, request={}, attempt={}",
+                         sidecarInstance, context.request(), attempt);
+            future.completeExceptionally(throwable);
+        }
+    }
+
+    /**
+     * Applies the {@code retryPolicy} to the response. The request will be retried based on the policy.
+     *
+     * @param future          the future for the {@link HttpResponse}
+     * @param iterator        the iterator of instances
+     * @param sidecarInstance the Sidecar instance where the request was performed
+     * @param context         the request context
+     * @param attempt         the number of attempts for this request
+     * @param response        the {@link HttpResponse} received from the server
+     * @param throwable       the error encountered during the request, or null if no error was encountered
+     */
+    private void applyRetryPolicy(CompletableFuture<HttpResponse> future,
+                                  Iterator<SidecarInstance> iterator,
+                                  SidecarInstance sidecarInstance,
+                                  RequestContext context,
+                                  final int attempt,
+                                  HttpResponse response,
+                                  Throwable throwable)
+    {
+        boolean retryOnNewHost = iterator.hasNext();
+        // check status code and apply retry policy on invalid status code
+        Request request = context.request();
+        context.retryPolicy()
+               .onResponse(future, request, response, throwable, attempt, retryOnNewHost, (nextAttempt, delay) -> {
+            String statusCode = response != null ? String.valueOf(response.statusCode()) : "<Not Available>";
+            if (iterator.hasNext())
+            {
+                if (response == null || response.statusCode() != HttpResponseStatus.ACCEPTED.code())
+                {
+                    logger.warn("Retrying request on next instance after {}ms. Failed on instance={}, " +
+                                "attempt={}, statusCode={}, request={}", delay, sidecarInstance, attempt, statusCode,
+                                request, throwable);
+                }
+                schedule(delay, () -> executeWithRetries(future, iterator, context, nextAttempt, throwable));
+            }
+            else
+            {
+                if (response == null || response.statusCode() != HttpResponseStatus.ACCEPTED.code())
+                {
+                    logger.warn("Retrying request on same instance after {}ms. Failed on instance={}, " +
+                                "attempt={}, statusCode={}, request={}", delay, sidecarInstance, attempt, statusCode,
+                                request, throwable);
+                }
+                schedule(delay, () -> executeWithRetries(future, iterator, sidecarInstance, context, nextAttempt));
+            }
+        });
+    }
+
+    /**
+     * Applies the {@code retryPolicy} to the stream. The stream will be retried based on the policy.
+     *
+     * @param future          the future for the {@link HttpResponse}
+     * @param consumer        the object that consumes the stream
+     * @param iterator        the iterator of instances
+     * @param sidecarInstance the Sidecar instance where the request was performed
+     * @param context         the request context
+     * @param attempt         the number of attempts for this request
+     * @param response        the {@link HttpResponse} received from the server
+     * @param throwable       the error encountered during the request, or null if no error was encountered
+     */
+    private void applyRetryPolicy(CompletableFuture<HttpResponse> future,
+                                  StreamConsumer consumer,
+                                  Iterator<SidecarInstance> iterator,
+                                  SidecarInstance sidecarInstance,
+                                  RequestContext context,
+                                  final int attempt,
+                                  HttpResponse response,
+                                  Throwable throwable)
+    {
+        boolean retryOnNewHost = iterator.hasNext();
+        // check status code and apply retry policy on invalid status code
+        Request request = context.request();
+        context.retryPolicy()
+               .onResponse(future, request, response, throwable, attempt, retryOnNewHost, (nextAttempt, delay) -> {
+            String statusCode = response != null ? String.valueOf(response.statusCode()) : "<Not Available>";
+            if (iterator.hasNext())
+            {
+                if (response == null || response.statusCode() != HttpResponseStatus.ACCEPTED.code())
+                {
+                    logger.warn("Retrying stream on next instance after {}ms. Failed on instance={}, " +
+                                "attempt={}, statusCode={}", delay, sidecarInstance, attempt, statusCode, throwable);
+                }
+
+                schedule(delay, () -> streamWithRetries(future, consumer, iterator, context, nextAttempt, throwable));
+            }
+            else
+            {
+                if (response == null || response.statusCode() != HttpResponseStatus.ACCEPTED.code())
+                {
+                    logger.warn("Retrying stream on same instance after {}ms. Failed on instance={}, " +
+                                "attempt={}, statusCode={}", delay, sidecarInstance, attempt, statusCode, throwable);
+                }
+
+                schedule(delay, () ->
+                                streamWithRetries(future, consumer, iterator, sidecarInstance, context, nextAttempt));
+            }
+        });
+    }
+
+    /**
+     * Processes the {@code response} result and sets the future as a completed future or as a completed exceptionally
+     * future when an error occurred during processing.
+     *
+     * @param future    the future for the request
+     * @param request   the request
+     * @param response  the {@link HttpResponse} received from the server
+     * @param throwable the error encountered during the request, or null if no error was encountered
+     * @param <T>       the type expected by the requester
+     */
+    @SuppressWarnings("unchecked")
+    private <T> void processResponse(CompletableFuture<T> future,
+                                     Request request,
+                                     HttpResponse response,
+                                     Throwable throwable)
+    {
+        if (throwable != null)
+        {
+            logger.error("Failed to process request={}, response={}", request, response, throwable);
+            future.completeExceptionally(throwable);
+            return;
+        }
+
+        try
+        {
+            if (request instanceof DecodableRequest)
+            {
+                DecodableRequest<T> decodableRequest = (DecodableRequest<T>) request;
+                future.complete(decodableRequest.decode(response.raw()));
+            }
+            else
+            {
+                future.complete((T) response.contentAsString());
+            }
+        }
+        catch (IOException e)
+        {
+            future.completeExceptionally(e);
+        }
+    }
+
+    /**
+     * Schedule the {@code runnable} after {@code delayMillis} milliseconds.
+     *
+     * @param delayMillis the delay before retrying in milliseconds
+     * @param runnable    the code to execute
+     */
+    protected void schedule(long delayMillis, Runnable runnable)
+    {
+        if (delayMillis > 0)
+        {
+            singleThreadExecutorService.schedule(runnable, delayMillis, TimeUnit.MILLISECONDS);
+        }
+        runnable.run();
+    }
+}
diff --git a/client/src/main/java/org/apache/cassandra/sidecar/client/SidecarClient.java b/client/src/main/java/org/apache/cassandra/sidecar/client/SidecarClient.java
new file mode 100644
index 0000000..450dc5f
--- /dev/null
+++ b/client/src/main/java/org/apache/cassandra/sidecar/client/SidecarClient.java
@@ -0,0 +1,401 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.cassandra.sidecar.client;
+
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import io.netty.handler.codec.http.HttpResponseStatus;
+import org.apache.cassandra.sidecar.client.request.ImportSSTableRequest;
+import org.apache.cassandra.sidecar.client.retry.IgnoreConflictRetryPolicy;
+import org.apache.cassandra.sidecar.client.retry.RetryPolicy;
+import org.apache.cassandra.sidecar.client.retry.RunnableOnStatusCodeRetryPolicy;
+import org.apache.cassandra.sidecar.client.selection.InstanceSelectionPolicy;
+import org.apache.cassandra.sidecar.client.selection.RandomInstanceSelectionPolicy;
+import org.apache.cassandra.sidecar.common.NodeSettings;
+import org.apache.cassandra.sidecar.common.data.GossipInfoResponse;
+import org.apache.cassandra.sidecar.common.data.ListSnapshotFilesResponse;
+import org.apache.cassandra.sidecar.common.data.RingResponse;
+import org.apache.cassandra.sidecar.common.data.SSTableImportResponse;
+import org.apache.cassandra.sidecar.common.data.SchemaResponse;
+import org.apache.cassandra.sidecar.common.data.TimeSkewResponse;
+import org.apache.cassandra.sidecar.common.utils.HttpRange;
+
+/**
+ * The SidecarClient class to perform requests
+ */
+public class SidecarClient implements AutoCloseable
+{
+    private static final Logger LOGGER = LoggerFactory.getLogger(SidecarClient.class);
+
+    protected RequestExecutor executor;
+    protected final RetryPolicy defaultRetryPolicy;
+    protected final RetryPolicy ignoreConflictRetryPolicy;
+    protected RequestContext.Builder baseBuilder;
+
+    public SidecarClient(SidecarInstancesProvider instancesProvider,
+                         RequestExecutor requestExecutor,
+                         SidecarConfig sidecarConfig,
+                         RetryPolicy defaultRetryPolicy)
+    {
+        this.defaultRetryPolicy = defaultRetryPolicy;
+        ignoreConflictRetryPolicy = new IgnoreConflictRetryPolicy(sidecarConfig.maxRetries(),
+                                                                  sidecarConfig.retryDelayMillis(),
+                                                                  sidecarConfig.maxRetryDelayMillis());
+        baseBuilder = new RequestContext.Builder()
+                      .instanceSelectionPolicy(new RandomInstanceSelectionPolicy(instancesProvider))
+                      .retryPolicy(defaultRetryPolicy);
+        executor = requestExecutor;
+    }
+
+    /**
+     * Executes the full schema request using the default retry policy and configured selection policy
+     *
+     * @return a completable future of the full schema response
+     */
+    public CompletableFuture<SchemaResponse> fullSchema()
+    {
+        return executor.executeRequestAsync(requestBuilder().schemaRequest().build());
+    }
+
+    /**
+     * Executes the schema request for the {@code keyspace} using the default retry policy and configured selection
+     * policy
+     *
+     * @param keyspace the keyspace in Cassandra
+     * @return a completable future of the schema response for the provided {@code keyspace}
+     */
+    public CompletableFuture<SchemaResponse> schema(String keyspace)
+    {
+        return executor.executeRequestAsync(requestBuilder().schemaRequest(keyspace).build());
+    }
+
+    /**
+     * Executes the ring request for the {@code keyspace} using the default retry policy and configured selection
+     * policy
+     *
+     * @param keyspace the keyspace in Cassandra
+     * @return a completable future of the ring response for the provided {@code keyspace}
+     */
+    public CompletableFuture<RingResponse> ring(String keyspace)
+    {
+        return executor.executeRequestAsync(requestBuilder().ringRequest(keyspace).build());
+    }
+
+    /**
+     * Executes the node settings request using the default retry policy and configured selection policy
+     *
+     * @return a completable future of the node settings
+     */
+    public CompletableFuture<NodeSettings> nodeSettings()
+    {
+        return executor.executeRequestAsync(requestBuilder().nodeSettingsRequest().build());
+    }
+
+    /**
+     * Executes the node settings request using the default retry policy and provided {@code instance}
+     *
+     * @param instance the instance where the request will be executed
+     * @return a completable future of the node settings
+     */
+    public CompletableFuture<NodeSettings> nodeSettings(SidecarInstance instance)
+    {
+        return executor.executeRequestAsync(requestBuilder().singleInstanceSelectionPolicy(instance)
+                                                            .nodeSettingsRequest()
+                                                            .build());
+    }
+
+    /**
+     * Executes the gossip info request using the default retry policy and configured selection policy
+     *
+     * @return a completable future of the gossip info
+     */
+    public CompletableFuture<GossipInfoResponse> gossipInfo()
+    {
+        return executor.executeRequestAsync(requestBuilder().gossipInfoRequest().build());
+    }
+
+    /**
+     * Executes the time skew request using the default retry policy and configured selection policy
+     *
+     * @return a completable future of the time skew
+     */
+    public CompletableFuture<TimeSkewResponse> timeSkew()
+    {
+        return executor.executeRequestAsync(requestBuilder().timeSkewRequest().build());
+    }
+
+    /**
+     * Executes the time skew request using the default retry policy and uses random instance selection policy
+     * with the provided instances
+     *
+     * @param instances the list of Sidecar instances to try for this request
+     * @return a completable future of the time skew
+     */
+    public CompletableFuture<TimeSkewResponse> timeSkew(List<? extends SidecarInstance> instances)
+    {
+        SidecarInstancesProvider instancesProvider = new SimpleSidecarInstancesProvider(instances);
+        InstanceSelectionPolicy instanceSelectionPolicy = new RandomInstanceSelectionPolicy(instancesProvider);
+        return executor.executeRequestAsync(requestBuilder()
+                                            .instanceSelectionPolicy(instanceSelectionPolicy)
+                                            .timeSkewRequest()
+                                            .build());
+    }
+
+    /**
+     * Executes the list snapshot files request including secondary index files using the default retry policy and
+     * provided {@code instance}
+     *
+     * @param instance     the instance where the request will be executed
+     * @param keyspace     the keyspace in Cassandra
+     * @param table        the table name in Cassandra
+     * @param snapshotName the name of the snapshot
+     * @return a completable future for the request
+     */
+    public CompletableFuture<ListSnapshotFilesResponse> listSnapshotFiles(SidecarInstance instance,
+                                                                          String keyspace,
+                                                                          String table,
+                                                                          String snapshotName)
+    {
+        return listSnapshotFiles(instance, keyspace, table, snapshotName, true);
+    }
+
+
+    /**
+     * Executes the list snapshot files request using the default retry policy and provided {@code instance}
+     *
+     * @param instance                   the instance where the request will be executed
+     * @param keyspace                   the keyspace in Cassandra
+     * @param table                      the table name in Cassandra
+     * @param snapshotName               the name of the snapshot
+     * @param includeSecondaryIndexFiles whether to include secondary index files
+     * @return a completable future for the request
+     */
+    public CompletableFuture<ListSnapshotFilesResponse> listSnapshotFiles(SidecarInstance instance,
+                                                                          String keyspace,
+                                                                          String table,
+                                                                          String snapshotName,
+                                                                          boolean includeSecondaryIndexFiles)
+    {
+        return executor.executeRequestAsync(requestBuilder().singleInstanceSelectionPolicy(instance)
+                                                            .listSnapshotFilesRequest(keyspace,
+                                                                                      table,
+                                                                                      snapshotName,
+                                                                                      includeSecondaryIndexFiles)
+                                                            .build());
+    }
+
+    /**
+     * Executes the clear snapshot request using the default retry policy and provided {@code instance}
+     *
+     * @param instance     the instance where the request will be executed
+     * @param keyspace     the keyspace in Cassandra
+     * @param table        the table name in Cassandra
+     * @param snapshotName the name of the snapshot
+     * @return a completable future for the request
+     */
+    public CompletableFuture<Void> clearSnapshot(SidecarInstance instance,
+                                                 String keyspace,
+                                                 String table,
+                                                 String snapshotName)
+    {
+        return executor.executeRequestAsync(requestBuilder().singleInstanceSelectionPolicy(instance)
+                                                            .clearSnapshotRequest(keyspace, table, snapshotName)
+                                                            .build());
+    }
+
+    /**
+     * Executes the create snapshot request using the default retry policy and provided {@code instance}
+     *
+     * @param instance     the instance where the request will be executed
+     * @param keyspace     the keyspace in Cassandra
+     * @param table        the table name in Cassandra
+     * @param snapshotName the name of the snapshot
+     * @return a completable future for the request
+     */
+    public CompletableFuture<Void> createSnapshot(SidecarInstance instance,
+                                                  String keyspace,
+                                                  String table,
+                                                  String snapshotName)
+    {
+        return executor.executeRequestAsync(requestBuilder().retryPolicy(ignoreConflictRetryPolicy)
+                                                            .singleInstanceSelectionPolicy(instance)
+                                                            .createSnapshotRequest(keyspace, table, snapshotName)
+                                                            .build());
+    }
+
+    /**
+     * Streams the specified {@code range} of an SSTable {@code componentName} for the given {@code keyspace},
+     * {@code table} from an existing {@code snapshotName}, the stream is consumed by the
+     * {@link StreamConsumer consumer}.
+     *
+     * @param instance       the instance where the request will be executed
+     * @param keyspace       the keyspace in Cassandra
+     * @param table          the table name in Cassandra
+     * @param snapshotName   the name of the snapshot
+     * @param componentName  the name of the SSTable component
+     * @param range          the HTTP range for the request
+     * @param streamConsumer the object that consumes the stream
+     */
+    public void streamSSTableComponent(SidecarInstance instance,
+                                       String keyspace,
+                                       String table,
+                                       String snapshotName,
+                                       String componentName,
+                                       HttpRange range,
+                                       StreamConsumer streamConsumer)
+    {
+        executor.streamRequest(requestBuilder()
+                               .singleInstanceSelectionPolicy(instance)
+                               .ssTableComponentRequest(keyspace, table, snapshotName, componentName, range)
+                               .build(), streamConsumer);
+    }
+
+    /**
+     * Uploads the SSTable to the provided {@code instance} using the default retry policy.
+     *
+     * @param instance      the instance where the request will be executed
+     * @param keyspace      the keyspace in Cassandra
+     * @param table         the table name in Cassandra
+     * @param uploadId      the unique identifier for the upload
+     * @param componentName the name of the SSTable component
+     * @param checksum      hash value to check integrity of SSTable component uploaded
+     * @param filename      the path to the file to be uploaded
+     * @return a completable future for the request
+     */
+    public CompletableFuture<Void> uploadSSTableRequest(SidecarInstance instance,
+                                                        String keyspace,
+                                                        String table,
+                                                        String uploadId,
+                                                        String componentName,
+                                                        String checksum,
+                                                        String filename)
+    {
+        return executor.executeRequestAsync(requestBuilder().singleInstanceSelectionPolicy(instance)
+                                                            .uploadSSTableRequest(keyspace,
+                                                                                  table,
+                                                                                  uploadId,
+                                                                                  componentName,
+                                                                                  checksum,
+                                                                                  filename)
+                                                            .build());
+    }
+
+    /**
+     * Executes the import SSTable request using the default retry policy and provided {@code instance}
+     *
+     * @param instance the instance where the request will be executed
+     * @param keyspace the keyspace in Cassandra
+     * @param table    the table name in Cassandra
+     * @param uploadId the unique identifier for the upload
+     * @param options  additional options for the import process
+     * @return a completable future for the request
+     */
+    public CompletableFuture<SSTableImportResponse> importSSTableRequest(SidecarInstance instance,
+                                                                         String keyspace,
+                                                                         String table,
+                                                                         String uploadId,
+                                                                         ImportSSTableRequest.ImportOptions options)
+    {
+        Runnable customLog = () ->
+                             LOGGER.info("Request to {} ACCEPTED but not yet complete - " +
+                                         "will retry until success/failure. uploadId={}", instance, uploadId);
+        RetryPolicy retryPolicy = new RunnableOnStatusCodeRetryPolicy(customLog,
+                                                                      defaultRetryPolicy,
+                                                                      HttpResponseStatus.ACCEPTED.code(),
+                                                                      10);
+        return executor.executeRequestAsync(requestBuilder().singleInstanceSelectionPolicy(instance)
+                                                            .retryPolicy(retryPolicy)
+                                                            .importSSTableRequest(keyspace, table, uploadId, options)
+                                                            .build());
+    }
+
+    /**
+     * Executes the clear upload session request using the default retry policy and provided {@code instance}
+     *
+     * @param instance the instance where the request will be executed
+     * @param uploadId the unique identifier for the upload
+     * @return a completable future for the request
+     */
+    public CompletableFuture<Void> cleanUploadSession(SidecarInstance instance, String uploadId)
+    {
+        return executor.executeRequestAsync(requestBuilder().singleInstanceSelectionPolicy(instance)
+                                                            .cleanSSTableUploadSessionRequest(uploadId)
+                                                            .build());
+    }
+
+    /**
+     * Returns a copy of the request builder with the default parameters configured for the client.
+     *
+     * <p>The request builder can be used to create the request, containing default values as depicted in the example
+     * below:
+     *
+     * <pre>
+     * RequestContext requestContext = client.requestBuilder()
+     *                                       .request(new new NodeSettingsRequest())
+     *                                       .retryPolicy(new NoRetryPolicy())
+     *                                       .build();
+     * </pre>
+     *
+     * <p>The example above will create a request to retrieve the node settings from a random Sidecar instance
+     * in the cluster. It will use the {@code NoRetryPolicy} policy. A custom retry policy can encapsulate the
+     * desired behavior of the client when dealing with specific status codes.
+     *
+     * @return a copy of the builder to prevent threads modifying the state of the builder
+     */
+    public RequestContext.Builder requestBuilder()
+    {
+        return baseBuilder.copy();
+    }
+
+    /**
+     * @return the default {@link RetryPolicy} configured for the client
+     */
+    public RetryPolicy defaultRetryPolicy()
+    {
+        return defaultRetryPolicy;
+    }
+
+    /**
+     * Returns a future with the expected instance of type {@code <T>} after executing the {@code request} and
+     * processing it.
+     *
+     * @param context the request context
+     * @param <T>     the expected type for the instance
+     * @return a future with the expected instance of type {@code <T>} after executing the {@code request} and
+     * processing it
+     */
+    public <T> CompletableFuture<T> executeRequestAsync(RequestContext context)
+    {
+        return executor.executeRequestAsync(context);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public void close() throws Exception
+    {
+        executor.close();
+    }
+}
diff --git a/client/src/main/java/org/apache/cassandra/sidecar/client/SidecarConfig.java b/client/src/main/java/org/apache/cassandra/sidecar/client/SidecarConfig.java
new file mode 100644
index 0000000..6bcc387
--- /dev/null
+++ b/client/src/main/java/org/apache/cassandra/sidecar/client/SidecarConfig.java
@@ -0,0 +1,121 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.cassandra.sidecar.client;
+
+/**
+ * Encapsulates configurations for the {@link SidecarClient}
+ */
+public class SidecarConfig
+{
+    private static final int DEFAULT_MAX_RETRIES = 3;
+    private static final long DEFAULT_RETRY_DELAY_MILLIS = 500L;
+    private static final long DEFAULT_MAX_RETRY_DELAY_MILLIS = 60_000L;
+
+    private final int maxRetries;
+    private final long retryDelayMillis;
+    private final long maxRetryDelayMillis;
+
+    private SidecarConfig(Builder builder)
+    {
+        maxRetries = builder.maxRetries;
+        retryDelayMillis = builder.retryDelayMillis;
+        maxRetryDelayMillis = builder.maxRetryDelayMillis;
+    }
+
+    /**
+     * @return the maximum number of times to retry a request
+     */
+    public int maxRetries()
+    {
+        return maxRetries;
+    }
+
+    /**
+     * @return the initial amount of time to wait before retrying a failed request
+     */
+    public long retryDelayMillis()
+    {
+        return retryDelayMillis;
+    }
+
+    /**
+     * @return the maximum amount of time to wait before retrying a failed request
+     */
+    public long maxRetryDelayMillis()
+    {
+        return maxRetryDelayMillis;
+    }
+
+    /**
+     * {@code SidecarConfig} builder static inner class.
+     */
+    public static final class Builder
+    {
+        private int maxRetries = DEFAULT_MAX_RETRIES;
+        private long retryDelayMillis = DEFAULT_RETRY_DELAY_MILLIS;
+        private long maxRetryDelayMillis = DEFAULT_MAX_RETRY_DELAY_MILLIS;
+
+        /**
+         * Sets the {@code maxRetries} and returns a reference to this Builder enabling method chaining.
+         *
+         * @param maxRetries the {@code maxRetries} to set
+         * @return a reference to this Builder
+         */
+        public Builder maxRetries(int maxRetries)
+        {
+            this.maxRetries = maxRetries;
+            return this;
+        }
+
+        /**
+         * Sets the {@code retryDelayMillis} and returns a reference to this Builder enabling method chaining.
+         *
+         * @param retryDelayMillis the {@code retryDelayMillis} to set
+         * @return a reference to this Builder
+         */
+        public Builder retryDelayMillis(long retryDelayMillis)
+        {
+            this.retryDelayMillis = retryDelayMillis;
+            return this;
+        }
+
+        /**
+         * Sets the {@code maxRetryDelayMillis} and returns a reference to this Builder enabling method chaining.
+         *
+         * @param maxRetryDelayMillis the {@code maxRetryDelayMillis} to set
+         * @return a reference to this Builder
+         */
+        public Builder maxRetryDelayMillis(long maxRetryDelayMillis)
+        {
+            this.maxRetryDelayMillis = maxRetryDelayMillis;
+            return this;
+        }
+
+        /**
+         * Returns a {@code SidecarConfig} built from the parameters previously set.
+         *
+         * @return a {@code SidecarConfig} built with parameters of this {@code SidecarConfig.Builder}
+         */
+        public SidecarConfig build()
+        {
+            return new SidecarConfig(this);
+        }
+    }
+}
diff --git a/src/main/java/org/apache/cassandra/sidecar/exceptions/RangeException.java b/client/src/main/java/org/apache/cassandra/sidecar/client/SidecarInstance.java
similarity index 68%
copy from src/main/java/org/apache/cassandra/sidecar/exceptions/RangeException.java
copy to client/src/main/java/org/apache/cassandra/sidecar/client/SidecarInstance.java
index 3dc783c..1d2d148 100644
--- a/src/main/java/org/apache/cassandra/sidecar/exceptions/RangeException.java
+++ b/client/src/main/java/org/apache/cassandra/sidecar/client/SidecarInstance.java
@@ -16,15 +16,20 @@
  * limitations under the License.
  */
 
-package org.apache.cassandra.sidecar.exceptions;
+package org.apache.cassandra.sidecar.client;
 
 /**
- * Custom exception
+ * Holds information about the Cassandra Sidecar Instance host and port
  */
-public class RangeException extends RuntimeException
+public interface SidecarInstance
 {
-    public RangeException(String msg)
-    {
-        super(msg);
-    }
+    /**
+     * @return the port where the Cassandra Sidecar instance is listening
+     */
+    int port();
+
+    /**
+     * @return the hostname where the Cassandra Sidecar instance is running
+     */
+    String hostname();
 }
diff --git a/client/src/main/java/org/apache/cassandra/sidecar/client/SidecarInstanceImpl.java b/client/src/main/java/org/apache/cassandra/sidecar/client/SidecarInstanceImpl.java
new file mode 100644
index 0000000..751218b
--- /dev/null
+++ b/client/src/main/java/org/apache/cassandra/sidecar/client/SidecarInstanceImpl.java
@@ -0,0 +1,104 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.sidecar.client;
+
+import java.util.Objects;
+
+/**
+ * A simple implementation of the {@link SidecarInstance} interface
+ */
+public class SidecarInstanceImpl implements SidecarInstance
+{
+    protected int port;
+    protected String hostname;
+
+    /**
+     * Constructs a new Sidecar instance with the given {@code port} and {@code hostname}
+     *
+     * @param hostname the host name where Sidecar is running
+     * @param port     the port where Sidecar is running
+     */
+    public SidecarInstanceImpl(String hostname, int port)
+    {
+        if (port < 1 || port > 65535)
+        {
+            throw new IllegalArgumentException(String.format("Invalid port number for the Sidecar service: %d",
+                                                             port));
+        }
+        this.port = port;
+        this.hostname = Objects.requireNonNull(hostname, "The Sidecar hostname must be non-null");
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public int port()
+    {
+        return port;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public String hostname()
+    {
+        return hostname;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public boolean equals(Object o)
+    {
+        if (this == o)
+        {
+            return true;
+        }
+        if (o == null || getClass() != o.getClass())
+        {
+            return false;
+        }
+        SidecarInstanceImpl that = (SidecarInstanceImpl) o;
+        return port == that.port && Objects.equals(hostname, that.hostname);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public int hashCode()
+    {
+        return Objects.hash(port, hostname);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public String toString()
+    {
+        return "SidecarInstanceImpl{" +
+               "port=" + port +
+               ", hostname='" + hostname + '\'' +
+               '}';
+    }
+}
diff --git a/src/main/java/org/apache/cassandra/sidecar/exceptions/RangeException.java b/client/src/main/java/org/apache/cassandra/sidecar/client/SidecarInstancesProvider.java
similarity index 65%
copy from src/main/java/org/apache/cassandra/sidecar/exceptions/RangeException.java
copy to client/src/main/java/org/apache/cassandra/sidecar/client/SidecarInstancesProvider.java
index 3dc783c..313b019 100644
--- a/src/main/java/org/apache/cassandra/sidecar/exceptions/RangeException.java
+++ b/client/src/main/java/org/apache/cassandra/sidecar/client/SidecarInstancesProvider.java
@@ -16,15 +16,18 @@
  * limitations under the License.
  */
 
-package org.apache.cassandra.sidecar.exceptions;
+package org.apache.cassandra.sidecar.client;
+
+import java.util.List;
 
 /**
- * Custom exception
+ * A class that provides the list of {@link SidecarInstance}s. This class allows for statically or dynamically
+ * providing a list of instances. It is meant to support expansions and shrink of Cassandra clusters.
  */
-public class RangeException extends RuntimeException
+public interface SidecarInstancesProvider
 {
-    public RangeException(String msg)
-    {
-        super(msg);
-    }
+    /**
+     * @return the list of {@link SidecarInstance}s
+     */
+    List<SidecarInstance> instances();
 }
diff --git a/client/src/main/java/org/apache/cassandra/sidecar/client/SimpleSidecarInstancesProvider.java b/client/src/main/java/org/apache/cassandra/sidecar/client/SimpleSidecarInstancesProvider.java
new file mode 100644
index 0000000..7077004
--- /dev/null
+++ b/client/src/main/java/org/apache/cassandra/sidecar/client/SimpleSidecarInstancesProvider.java
@@ -0,0 +1,53 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.sidecar.client;
+
+
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * A {@link SidecarInstancesProvider} that returns Sidecar instances from a list of instances.
+ */
+public class SimpleSidecarInstancesProvider implements SidecarInstancesProvider
+{
+    private final List<SidecarInstance> sidecarInstances;
+
+    /**
+     * Constructs this object with the provided list of Sidecar instances. Creates an unmodifiable copy of the list
+     * and keeps a reference to the copy internally.
+     *
+     * @param sidecarInstances the list of Sidecar instances
+     */
+    public SimpleSidecarInstancesProvider(List<? extends SidecarInstance> sidecarInstances)
+    {
+        if (sidecarInstances == null || sidecarInstances.isEmpty())
+        {
+            String message = "The instances parameter must be non-null and must contain at least one element";
+            throw new IllegalArgumentException(message);
+        }
+        this.sidecarInstances = Collections.unmodifiableList(sidecarInstances);
+    }
+
+    @Override
+    public List<SidecarInstance> instances()
+    {
+        return sidecarInstances;
+    }
+}
diff --git a/client/src/main/java/org/apache/cassandra/sidecar/client/StreamBuffer.java b/client/src/main/java/org/apache/cassandra/sidecar/client/StreamBuffer.java
new file mode 100644
index 0000000..d5e7e9c
--- /dev/null
+++ b/client/src/main/java/org/apache/cassandra/sidecar/client/StreamBuffer.java
@@ -0,0 +1,123 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.cassandra.sidecar.client;
+
+import java.nio.ByteBuffer;
+
+/**
+ * A generic wrapper around bytes to allow for on/off-heap byte arrays.
+ */
+public interface StreamBuffer
+{
+    /**
+     * Copies bytes from this {@link StreamBuffer} into the {@link ByteBuffer destination}
+     *
+     * @param sourceOffset the offset within the {@link StreamBuffer} to be read; must be non-negative and
+     *                     larger than the buffer length
+     * @param destination  a {@link ByteBuffer} where the data will be copied
+     * @param length       the number of bytes to be copied from the {@link StreamBuffer}; must be non-negative and
+     *                     larger than the {@code buffer.length - sourceOffset}
+     */
+    void copyBytes(int sourceOffset, ByteBuffer destination, int length);
+
+    /**
+     * Copies bytes from this {@link StreamBuffer} into the byte array {@code destination}.
+     *
+     * @param sourceOffset     the offset within the {@link StreamBuffer} to be read; must be non-negative and
+     *                         larger than the buffer length
+     * @param destination      the target byte array where the source bytes will be copied
+     * @param destinationIndex the offset in the {@code destination} where to start writing data
+     * @param length           the number of bytes to be copied from the {@link StreamBuffer}; must be non-negative and
+     *                         larger than the {@code buffer.length - sourceOffset}
+     */
+    void copyBytes(int sourceOffset, byte[] destination, int destinationIndex, int length);
+
+    /**
+     * Returns a byte from the given {@code index}.
+     *
+     * @param index the index in the array; must be non-negative and within the bounds of the {@link StreamBuffer}
+     * @return a byte from the given {@code index}
+     */
+    byte getByte(int index);
+
+    /**
+     * @return the number of readable bytes from this {@link StreamBuffer}
+     */
+    int readableBytes();
+
+    /**
+     * Release the underlying buffer
+     */
+    void release();
+
+    /**
+     * Wraps a byte array into a {@link ByteArrayWrapper}
+     *
+     * @param bytes the underlying byte array
+     * @return the object wrapping byte array
+     */
+    static ByteArrayWrapper wrap(byte[] bytes)
+    {
+        return new ByteArrayWrapper(bytes);
+    }
+
+    /**
+     * A {@link StreamBuffer} implementation that wraps a byte array
+     */
+    class ByteArrayWrapper implements StreamBuffer
+    {
+        private final byte[] bytes;
+
+        private ByteArrayWrapper(byte[] bytes)
+        {
+            this.bytes = bytes;
+        }
+
+        @Override
+        public void copyBytes(int sourceOffset, ByteBuffer destination, int length)
+        {
+            destination.put(bytes, sourceOffset, length);
+            destination.flip();
+        }
+
+        @Override
+        public void copyBytes(int sourceOffset, byte[] destination, int destinationIndex, int length)
+        {
+            System.arraycopy(bytes, sourceOffset, destination, destinationIndex, length);
+        }
+
+        @Override
+        public byte getByte(int index)
+        {
+            return bytes[index];
+        }
+
+        @Override
+        public int readableBytes()
+        {
+            return bytes.length;
+        }
+
+        @Override
+        public void release()
+        {
+        }
+    }
+}
diff --git a/src/main/java/org/apache/cassandra/sidecar/cluster/InstancesConfig.java b/client/src/main/java/org/apache/cassandra/sidecar/client/StreamConsumer.java
similarity index 54%
copy from src/main/java/org/apache/cassandra/sidecar/cluster/InstancesConfig.java
copy to client/src/main/java/org/apache/cassandra/sidecar/client/StreamConsumer.java
index ad16849..5f4698b 100644
--- a/src/main/java/org/apache/cassandra/sidecar/cluster/InstancesConfig.java
+++ b/client/src/main/java/org/apache/cassandra/sidecar/client/StreamConsumer.java
@@ -16,33 +16,31 @@
  * limitations under the License.
  */
 
-package org.apache.cassandra.sidecar.cluster;
+package org.apache.cassandra.sidecar.client;
 
-import java.util.List;
-
-import org.apache.cassandra.sidecar.cluster.instance.InstanceMetadata;
 
 /**
- * Maintains metadata of instances maintained by Sidecar.
+ * Represents a consumer for a stream, when using streaming for the Sidecar {@link HttpClient}.
  */
-public interface InstancesConfig
+public interface StreamConsumer
 {
     /**
-     * returns metadata of instances owned by the sidecar
+     * Called when the {@link StreamBuffer} is ready to be consumed. This method can be called multiple times as
+     * data arrives and the {@link StreamBuffer} can be consumed.
+     *
+     * @param buffer the StreamBuffer that wraps the received data
      */
-    List<InstanceMetadata> instances();
+    void onRead(final StreamBuffer buffer);
 
     /**
-     * Lookup instance metadata by id.
-     * @param id instance's id
-     * @return instance meta information
+     * Called when the stream has completed
      */
-    InstanceMetadata instanceFromId(final int id);
+    void onComplete();
 
     /**
-     * Lookup instance metadata by host name.
-     * @param host host address of instance
-     * @return instance meta information
+     * Called when the stream has failed with the encountered {@link Throwable throwable}.
+     *
+     * @param throwable throwable
      */
-    InstanceMetadata instanceFromHost(final String host);
+    void onError(final Throwable throwable);
 }
diff --git a/common/src/main/java/org/apache/cassandra/sidecar/common/ICassandraFactory.java b/client/src/main/java/org/apache/cassandra/sidecar/client/exception/ResourceNotFoundException.java
similarity index 59%
copy from common/src/main/java/org/apache/cassandra/sidecar/common/ICassandraFactory.java
copy to client/src/main/java/org/apache/cassandra/sidecar/client/exception/ResourceNotFoundException.java
index 84c43e8..fb67b84 100644
--- a/common/src/main/java/org/apache/cassandra/sidecar/common/ICassandraFactory.java
+++ b/client/src/main/java/org/apache/cassandra/sidecar/client/exception/ResourceNotFoundException.java
@@ -16,19 +16,22 @@
  * limitations under the License.
  */
 
-package org.apache.cassandra.sidecar.common;
+package org.apache.cassandra.sidecar.client.exception;
+
+import org.apache.cassandra.sidecar.client.request.Request;
 
 /**
- * A factory is used here to create instances of an Adapter.  We
+ * An exception raised when the HTTP resource is Not Found (A 404 Status Code response).
  */
-public interface ICassandraFactory
+public class ResourceNotFoundException extends RuntimeException
 {
     /**
-     * Creates a new {@link ICassandraAdapter} with the provided {@link CQLSession} and {@link JmxClient}
+     * Constructs a new exception for a request where the URI is not found on the remote server
      *
-     * @param session the session to the Cassandra database
-     * @param client  the JMX client to connect to the Cassandra database
-     * @return a {@link ICassandraAdapter}
+     * @param request the HTTP request
      */
-    ICassandraAdapter create(CQLSession session, JmxClient client);
+    public ResourceNotFoundException(Request request)
+    {
+        super(String.format("The resource for the request '%s' does not exist", request.requestURI()));
+    }
 }
diff --git a/client/src/main/java/org/apache/cassandra/sidecar/client/exception/RetriesExhaustedException.java b/client/src/main/java/org/apache/cassandra/sidecar/client/exception/RetriesExhaustedException.java
new file mode 100644
index 0000000..890dd00
--- /dev/null
+++ b/client/src/main/java/org/apache/cassandra/sidecar/client/exception/RetriesExhaustedException.java
@@ -0,0 +1,51 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.sidecar.client.exception;
+
+import org.apache.cassandra.sidecar.client.request.Request;
+
+/**
+ * Represents an exception raised when the number of retries for a given request are exhausted
+ */
+public class RetriesExhaustedException extends RuntimeException
+{
+    /**
+     * Constructs an exception with the number of {@code attempts} performed for the request.
+     *
+     * @param attempts the number of attempts performed for the request
+     * @param request  the HTTP request
+     */
+    public RetriesExhaustedException(int attempts, Request request)
+    {
+        this(attempts, request, null);
+    }
+
+    /**
+     * Constructs an exception with the number of {@code attempts} performed for the request.
+     *
+     * @param attempts  the number of attempts performed for the request
+     * @param request   the HTTP request
+     * @param throwable the underlying exception
+     */
+    public RetriesExhaustedException(int attempts, Request request, Throwable throwable)
+    {
+        super(String.format("Unable to complete request '%s' after %d attempt%s",
+                            request.requestURI(), attempts, attempts == 1 ? "" : "s"), throwable);
+    }
+}
diff --git a/client/src/main/java/org/apache/cassandra/sidecar/client/exception/UnexpectedStatusCodeException.java b/client/src/main/java/org/apache/cassandra/sidecar/client/exception/UnexpectedStatusCodeException.java
new file mode 100644
index 0000000..b767fbf
--- /dev/null
+++ b/client/src/main/java/org/apache/cassandra/sidecar/client/exception/UnexpectedStatusCodeException.java
@@ -0,0 +1,53 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.sidecar.client.exception;
+
+/**
+ * Represents an exception raised when an unexpected status code is encountered
+ */
+public class UnexpectedStatusCodeException extends RuntimeException
+{
+    /**
+     * Constructs a new {@link UnexpectedStatusCodeException} with the given {@code statusCode} and {@code body}
+     *
+     * @param statusCode the response status code
+     * @param body       the body of the response
+     */
+    public UnexpectedStatusCodeException(int statusCode, String body)
+    {
+        this(message(statusCode, body), null);
+    }
+
+    /**
+     * Constructs a new {@link UnexpectedStatusCodeException} with the specified detail message and cause.
+     *
+     * @param message the detail message
+     * @param cause   the cause
+     */
+    public UnexpectedStatusCodeException(String message, Throwable cause)
+    {
+        super(message, cause);
+    }
+
+    private static String message(int statusCode, String body)
+    {
+        String message = "Unexpected HTTP status code " + statusCode;
+        return body == null ? message : message + "\n\n" + body;
+    }
+}
diff --git a/cassandra-integration-tests/src/testFixtures/java/org/apache/cassandra/sidecar/common/testing/TestVersion.java b/client/src/main/java/org/apache/cassandra/sidecar/client/request/CleanSSTableUploadSessionRequest.java
similarity index 55%
copy from cassandra-integration-tests/src/testFixtures/java/org/apache/cassandra/sidecar/common/testing/TestVersion.java
copy to client/src/main/java/org/apache/cassandra/sidecar/client/request/CleanSSTableUploadSessionRequest.java
index 013c004..2957573 100644
--- a/cassandra-integration-tests/src/testFixtures/java/org/apache/cassandra/sidecar/common/testing/TestVersion.java
+++ b/client/src/main/java/org/apache/cassandra/sidecar/client/request/CleanSSTableUploadSessionRequest.java
@@ -16,38 +16,29 @@
  * limitations under the License.
  */
 
-package org.apache.cassandra.sidecar.common.testing;
+package org.apache.cassandra.sidecar.client.request;
 
-import org.apache.cassandra.sidecar.common.ICassandraFactory;
+import io.netty.handler.codec.http.HttpMethod;
+import org.apache.cassandra.sidecar.common.ApiEndpointsV1;
 
 /**
- * Works with {@link TestVersionSupplier}
+ * Represents a request to clean up the SSTable upload session
  */
-public class TestVersion
+public class CleanSSTableUploadSessionRequest extends Request
 {
-    private final String version;
-    private final ICassandraFactory factory;
-    private final String image;
-
-    public TestVersion(String version, ICassandraFactory factory, String image)
-    {
-        this.version = version;
-        this.factory = factory;
-        this.image = image;
-    }
-
-    public String getVersion()
-    {
-        return version;
-    }
-
-    public ICassandraFactory getFactory()
+    /**
+     * Constructs a request with the provided {@code requestURI}
+     *
+     * @param uploadId the unique identifier for the upload
+     */
+    public CleanSSTableUploadSessionRequest(String uploadId)
     {
-        return factory;
+        super(ApiEndpointsV1.SSTABLE_CLEANUP_ROUTE.replaceAll(ApiEndpointsV1.UPLOAD_ID_PATH_PARAM, uploadId));
     }
 
-    public String getImage()
+    @Override
+    public HttpMethod method()
     {
-        return image;
+        return HttpMethod.DELETE;
     }
 }
diff --git a/common/src/main/java/org/apache/cassandra/sidecar/common/data/SchemaRequest.java b/client/src/main/java/org/apache/cassandra/sidecar/client/request/ClearSnapshotRequest.java
similarity index 55%
copy from common/src/main/java/org/apache/cassandra/sidecar/common/data/SchemaRequest.java
copy to client/src/main/java/org/apache/cassandra/sidecar/client/request/ClearSnapshotRequest.java
index 1f136b9..32cfd26 100644
--- a/common/src/main/java/org/apache/cassandra/sidecar/common/data/SchemaRequest.java
+++ b/client/src/main/java/org/apache/cassandra/sidecar/client/request/ClearSnapshotRequest.java
@@ -15,34 +15,35 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.cassandra.sidecar.common.data;
 
-import org.jetbrains.annotations.Nullable;
+package org.apache.cassandra.sidecar.client.request;
+
+import io.netty.handler.codec.http.HttpMethod;
 
 /**
- * Holder class for the {@link org.apache.cassandra.sidecar.routes.SchemaHandler}
- * request parameters
+ * Represents a request to clear a snapshot
  */
-public class SchemaRequest extends QualifiedTableName
+public class ClearSnapshotRequest extends SnapshotRequest<Void>
 {
     /**
-     * Constructs a {@link SchemaRequest} with the {@link org.jetbrains.annotations.Nullable} {@code keyspace}.
+     * Constructs a new request to clear a snapshot with name {@code snapshotName} for the given {@code keyspace}
+     * and {@code table}.
      *
-     * @param keyspace the keyspace in Cassandra
+     * @param keyspace     the keyspace in Cassandra
+     * @param table        the table name in Cassandra
+     * @param snapshotName the name of the snapshot
      */
-    public SchemaRequest(@Nullable String keyspace)
+    public ClearSnapshotRequest(String keyspace, String table, String snapshotName)
     {
-        super(keyspace, null, false);
+        super(keyspace, table, snapshotName);
     }
 
     /**
      * {@inheritDoc}
      */
-    public String toString()
+    @Override
+    public HttpMethod method()
     {
-        return "SchemaRequest{" +
-               "keyspace='" + keyspace() + '\'' +
-               ", tableName='" + tableName() + '\'' +
-               '}';
+        return HttpMethod.DELETE;
     }
 }
diff --git a/common/src/main/java/org/apache/cassandra/sidecar/common/data/SchemaRequest.java b/client/src/main/java/org/apache/cassandra/sidecar/client/request/CreateSnapshotRequest.java
similarity index 55%
copy from common/src/main/java/org/apache/cassandra/sidecar/common/data/SchemaRequest.java
copy to client/src/main/java/org/apache/cassandra/sidecar/client/request/CreateSnapshotRequest.java
index 1f136b9..4da2766 100644
--- a/common/src/main/java/org/apache/cassandra/sidecar/common/data/SchemaRequest.java
+++ b/client/src/main/java/org/apache/cassandra/sidecar/client/request/CreateSnapshotRequest.java
@@ -15,34 +15,35 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.cassandra.sidecar.common.data;
 
-import org.jetbrains.annotations.Nullable;
+package org.apache.cassandra.sidecar.client.request;
+
+import io.netty.handler.codec.http.HttpMethod;
 
 /**
- * Holder class for the {@link org.apache.cassandra.sidecar.routes.SchemaHandler}
- * request parameters
+ * Represents a request to create a snapshot
  */
-public class SchemaRequest extends QualifiedTableName
+public class CreateSnapshotRequest extends SnapshotRequest<Void>
 {
     /**
-     * Constructs a {@link SchemaRequest} with the {@link org.jetbrains.annotations.Nullable} {@code keyspace}.
+     * Constructs a new request to create a snapshot with name {@code snapshotName} for the given {@code keyspace}
+     * and {@code table}.
      *
-     * @param keyspace the keyspace in Cassandra
+     * @param keyspace     the keyspace in Cassandra
+     * @param table        the table name in Cassandra
+     * @param snapshotName the name of the snapshot
      */
-    public SchemaRequest(@Nullable String keyspace)
+    public CreateSnapshotRequest(String keyspace, String table, String snapshotName)
     {
-        super(keyspace, null, false);
+        super(keyspace, table, snapshotName);
     }
 
     /**
      * {@inheritDoc}
      */
-    public String toString()
+    @Override
+    public HttpMethod method()
     {
-        return "SchemaRequest{" +
-               "keyspace='" + keyspace() + '\'' +
-               ", tableName='" + tableName() + '\'' +
-               '}';
+        return HttpMethod.PUT;
     }
 }
diff --git a/client/src/main/java/org/apache/cassandra/sidecar/client/request/DecodableRequest.java b/client/src/main/java/org/apache/cassandra/sidecar/client/request/DecodableRequest.java
new file mode 100644
index 0000000..90e93bd
--- /dev/null
+++ b/client/src/main/java/org/apache/cassandra/sidecar/client/request/DecodableRequest.java
@@ -0,0 +1,74 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.sidecar.client.request;
+
+import java.io.IOException;
+import java.lang.reflect.ParameterizedType;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+import com.fasterxml.jackson.databind.JavaType;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+/**
+ * A request that can decode to the type {@code <T>}
+ *
+ * @param <T> the type to decode
+ */
+public abstract class DecodableRequest<T> extends Request
+{
+    static final ObjectMapper MAPPER = new ObjectMapper();
+    private final JavaType javaType = MAPPER.constructType(((ParameterizedType) this.getClass().getGenericSuperclass())
+                                                           .getActualTypeArguments()[0]);
+
+    /**
+     * Constructs a decodable request with the provided {@code requestURI}
+     *
+     * @param requestURI the URI of the request
+     */
+    protected DecodableRequest(String requestURI)
+    {
+        super(requestURI);
+    }
+
+    /**
+     * Decodes the provided {@code bytes} to an instance of the type {@code <T>}
+     *
+     * @param bytes the raw bytes of the response
+     * @return the decoded instance for the given {@code bytes}
+     * @throws IOException when the decoder is unable to decode successfully
+     */
+    public T decode(byte[] bytes) throws IOException
+    {
+        if (bytes == null)
+        {
+            return null;
+        }
+        return MAPPER.readValue(bytes, javaType);
+    }
+
+    @Override
+    public Map<String, String> headers()
+    {
+        Map<String, String> headers = new HashMap<>(super.headers());
+        headers.put("content-type", "application/json");
+        return Collections.unmodifiableMap(headers);
+    }
+}
diff --git a/common/src/main/java/org/apache/cassandra/sidecar/mocks/V40.java b/client/src/main/java/org/apache/cassandra/sidecar/client/request/GossipInfoRequest.java
similarity index 56%
copy from common/src/main/java/org/apache/cassandra/sidecar/mocks/V40.java
copy to client/src/main/java/org/apache/cassandra/sidecar/client/request/GossipInfoRequest.java
index 9b2398e..17d7cda 100644
--- a/common/src/main/java/org/apache/cassandra/sidecar/mocks/V40.java
+++ b/client/src/main/java/org/apache/cassandra/sidecar/client/request/GossipInfoRequest.java
@@ -16,23 +16,31 @@
  * limitations under the License.
  */
 
-package org.apache.cassandra.sidecar.mocks;
+package org.apache.cassandra.sidecar.client.request;
 
-import org.apache.cassandra.sidecar.common.CQLSession;
-import org.apache.cassandra.sidecar.common.ICassandraAdapter;
-import org.apache.cassandra.sidecar.common.ICassandraFactory;
-import org.apache.cassandra.sidecar.common.JmxClient;
-import org.apache.cassandra.sidecar.common.MinimumVersion;
+import io.netty.handler.codec.http.HttpMethod;
+import org.apache.cassandra.sidecar.common.ApiEndpointsV1;
+import org.apache.cassandra.sidecar.common.data.GossipInfoResponse;
 
 /**
- * Placeholder for 4.0
+ * Represents a request to retrieve the Cassandra gossip information
  */
-@MinimumVersion("4.0.0")
-public class V40 implements ICassandraFactory
+public class GossipInfoRequest extends DecodableRequest<GossipInfoResponse>
 {
+    /**
+     * Constructs a request to retrieve the Cassandra gossip information
+     */
+    public GossipInfoRequest()
+    {
+        super(ApiEndpointsV1.GOSSIP_INFO_ROUTE);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
     @Override
-    public ICassandraAdapter create(CQLSession session, JmxClient jmxClient)
+    public HttpMethod method()
     {
-        return null;
+        return HttpMethod.GET;
     }
 }
diff --git a/client/src/main/java/org/apache/cassandra/sidecar/client/request/ImportSSTableRequest.java b/client/src/main/java/org/apache/cassandra/sidecar/client/request/ImportSSTableRequest.java
new file mode 100644
index 0000000..42e4495
--- /dev/null
+++ b/client/src/main/java/org/apache/cassandra/sidecar/client/request/ImportSSTableRequest.java
@@ -0,0 +1,211 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.sidecar.client.request;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import io.netty.handler.codec.http.HttpMethod;
+import org.apache.cassandra.sidecar.common.ApiEndpointsV1;
+import org.apache.cassandra.sidecar.common.data.SSTableImportResponse;
+
+/**
+ * Represents a request to import SSTable components previously uploaded for an upload identifier
+ */
+public class ImportSSTableRequest extends DecodableRequest<SSTableImportResponse>
+{
+    /**
+     * Constructs a decodable request with the provided {@code requestURI}
+     *
+     * @param keyspace      the keyspace in Cassandra
+     * @param tableName     the table name in Cassandra
+     * @param uploadId      an identifier for the upload
+     * @param importOptions additional options for the import process
+     */
+    public ImportSSTableRequest(String keyspace, String tableName, String uploadId, ImportOptions importOptions)
+    {
+        super(requestURI(keyspace, tableName, uploadId, importOptions));
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public HttpMethod method()
+    {
+        return HttpMethod.PUT;
+    }
+
+    /**
+     * Options for the SSTable import
+     */
+    public static class ImportOptions
+    {
+        private Boolean resetLevel;
+        private Boolean clearRepaired;
+        private Boolean verifySSTables;
+        private Boolean verifyTokens;
+        private Boolean invalidateCaches;
+        private Boolean extendedVerify;
+        private Boolean copyData;
+
+        public ImportOptions()
+        {
+        }
+
+        /**
+         * Sets the {@code resetLevel} and returns a reference to this Builder enabling method chaining.
+         *
+         * @param resetLevel the {@code resetLevel} to set
+         * @return a reference to this Builder
+         */
+        public ImportOptions resetLevel(boolean resetLevel)
+        {
+            this.resetLevel = resetLevel;
+            return this;
+        }
+
+        /**
+         * Sets the {@code clearRepaired} and returns a reference to this ImportOptions enabling method chaining.
+         *
+         * @param clearRepaired the {@code clearRepaired} to set
+         * @return a reference to this ImportOptions
+         */
+        public ImportOptions clearRepaired(boolean clearRepaired)
+        {
+            this.clearRepaired = clearRepaired;
+            return this;
+        }
+
+        /**
+         * Sets the {@code verifySSTables} and returns a reference to this ImportOptions enabling method chaining.
+         *
+         * @param verifySSTables the {@code verifySSTables} to set
+         * @return a reference to this ImportOptions
+         */
+        public ImportOptions verifySSTables(boolean verifySSTables)
+        {
+            this.verifySSTables = verifySSTables;
+            return this;
+        }
+
+        /**
+         * Sets the {@code verifyTokens} and returns a reference to this ImportOptions enabling method chaining.
+         *
+         * @param verifyTokens the {@code verifyTokens} to set
+         * @return a reference to this ImportOptions
+         */
+        public ImportOptions verifyTokens(boolean verifyTokens)
+        {
+            this.verifyTokens = verifyTokens;
+            return this;
+        }
+
+        /**
+         * Sets the {@code invalidateCaches} and returns a reference to this ImportOptions enabling method chaining.
+         *
+         * @param invalidateCaches the {@code invalidateCaches} to set
+         * @return a reference to this ImportOptions
+         */
+        public ImportOptions invalidateCaches(boolean invalidateCaches)
+        {
+            this.invalidateCaches = invalidateCaches;
+            return this;
+        }
+
+        /**
+         * Sets the {@code extendedVerify} and returns a reference to this ImportOptions enabling method chaining.
+         *
+         * @param extendedVerify the {@code extendedVerify} to set
+         * @return a reference to this ImportOptions
+         */
+        public ImportOptions extendedVerify(boolean extendedVerify)
+        {
+            this.extendedVerify = extendedVerify;
+            return this;
+        }
+
+        /**
+         * Sets the {@code copyData} and returns a reference to this ImportOptions enabling method chaining.
+         *
+         * @param copyData the {@code copyData} to set
+         * @return a reference to this ImportOptions
+         */
+        public ImportOptions copyData(boolean copyData)
+        {
+            this.copyData = copyData;
+            return this;
+        }
+    }
+
+    static String requestURI(String keyspace, String tableName, String uploadId, ImportOptions importOptions)
+    {
+        String requestUri = ApiEndpointsV1.SSTABLE_IMPORT_ROUTE
+                            .replaceAll(ApiEndpointsV1.UPLOAD_ID_PATH_PARAM, uploadId)
+                            .replaceAll(ApiEndpointsV1.KEYSPACE_PATH_PARAM, keyspace)
+                            .replaceAll(ApiEndpointsV1.TABLE_PATH_PARAM, tableName);
+
+        List<String> options = selectedOptions(importOptions);
+
+        if (options.isEmpty())
+        {
+            return requestUri;
+        }
+
+        return requestUri + options.stream()
+                                   .collect(Collectors.joining("&", "?", ""));
+    }
+
+    private static List<String> selectedOptions(ImportOptions importOptions)
+    {
+        List<String> options = new ArrayList<>();
+
+        if (importOptions.resetLevel != null)
+        {
+            options.add("resetLevel=" + importOptions.resetLevel);
+        }
+        if (importOptions.clearRepaired != null)
+        {
+            options.add("clearRepaired=" + importOptions.clearRepaired);
+        }
+        if (importOptions.verifySSTables != null)
+        {
+            options.add("verifySSTables=" + importOptions.verifySSTables);
+        }
+        if (importOptions.verifyTokens != null)
+        {
+            options.add("verifyTokens=" + importOptions.verifyTokens);
+        }
+        if (importOptions.invalidateCaches != null)
+        {
+            options.add("invalidateCaches=" + importOptions.invalidateCaches);
+        }
+        if (importOptions.extendedVerify != null)
+        {
+            options.add("extendedVerify=" + importOptions.extendedVerify);
+        }
+        if (importOptions.copyData != null)
+        {
+            options.add("copyData=" + importOptions.copyData);
+        }
+
+        return options;
+    }
+}
diff --git a/client/src/main/java/org/apache/cassandra/sidecar/client/request/ListSnapshotFilesRequest.java b/client/src/main/java/org/apache/cassandra/sidecar/client/request/ListSnapshotFilesRequest.java
new file mode 100644
index 0000000..2a05e17
--- /dev/null
+++ b/client/src/main/java/org/apache/cassandra/sidecar/client/request/ListSnapshotFilesRequest.java
@@ -0,0 +1,54 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.sidecar.client.request;
+
+import io.netty.handler.codec.http.HttpMethod;
+import org.apache.cassandra.sidecar.common.data.ListSnapshotFilesResponse;
+
+/**
+ * Represents a request to list the files for a Cassandra snapshot
+ */
+public class ListSnapshotFilesRequest extends SnapshotRequest<ListSnapshotFilesResponse>
+{
+    /**
+     * Constructs a request to list the Cassandra snapshot for the given {@code snapshotName} in the {@code keyspace}
+     * and {@code table}, specifying whether secondary index files should be included as part of the response.
+     *
+     * @param keyspace                   the keyspace in Cassandra
+     * @param table                      the table name in Cassandra
+     * @param snapshotName               the name of the snapshot
+     * @param includeSecondaryIndexFiles whether to include secondary index files
+     */
+    public ListSnapshotFilesRequest(String keyspace,
+                                    String table,
+                                    String snapshotName,
+                                    boolean includeSecondaryIndexFiles)
+    {
+        super(keyspace, table, snapshotName, includeSecondaryIndexFiles);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public HttpMethod method()
+    {
+        return HttpMethod.GET;
+    }
+}
diff --git a/common/src/main/java/org/apache/cassandra/sidecar/mocks/V30.java b/client/src/main/java/org/apache/cassandra/sidecar/client/request/NodeSettingsRequest.java
similarity index 58%
copy from common/src/main/java/org/apache/cassandra/sidecar/mocks/V30.java
copy to client/src/main/java/org/apache/cassandra/sidecar/client/request/NodeSettingsRequest.java
index 7dcf7ef..0a27480 100644
--- a/common/src/main/java/org/apache/cassandra/sidecar/mocks/V30.java
+++ b/client/src/main/java/org/apache/cassandra/sidecar/client/request/NodeSettingsRequest.java
@@ -16,23 +16,31 @@
  * limitations under the License.
  */
 
-package org.apache.cassandra.sidecar.mocks;
+package org.apache.cassandra.sidecar.client.request;
 
-import org.apache.cassandra.sidecar.common.CQLSession;
-import org.apache.cassandra.sidecar.common.ICassandraAdapter;
-import org.apache.cassandra.sidecar.common.ICassandraFactory;
-import org.apache.cassandra.sidecar.common.JmxClient;
-import org.apache.cassandra.sidecar.common.MinimumVersion;
+import io.netty.handler.codec.http.HttpMethod;
+import org.apache.cassandra.sidecar.common.ApiEndpointsV1;
+import org.apache.cassandra.sidecar.common.NodeSettings;
 
 /**
- * Placeholder for 3.0
+ * Represents a request to retrieve the node settings
  */
-@MinimumVersion("3.0.0")
-public class V30 implements ICassandraFactory
+public class NodeSettingsRequest extends DecodableRequest<NodeSettings>
 {
+    /**
+     * Constructs a request to retrieve the node settings
+     */
+    public NodeSettingsRequest()
+    {
+        super(ApiEndpointsV1.NODE_SETTINGS_ROUTE);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
     @Override
-    public ICassandraAdapter create(CQLSession session, JmxClient jmxClient)
+    public HttpMethod method()
     {
-        return null;
+        return HttpMethod.GET;
     }
 }
diff --git a/client/src/main/java/org/apache/cassandra/sidecar/client/request/Request.java b/client/src/main/java/org/apache/cassandra/sidecar/client/request/Request.java
new file mode 100644
index 0000000..c7cb480
--- /dev/null
+++ b/client/src/main/java/org/apache/cassandra/sidecar/client/request/Request.java
@@ -0,0 +1,92 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.sidecar.client.request;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+import io.netty.handler.codec.http.HttpHeaderNames;
+import io.netty.handler.codec.http.HttpMethod;
+import org.apache.cassandra.sidecar.common.utils.HttpRange;
+
+/**
+ * An abstract class representing a request for Sidecar
+ */
+public abstract class Request
+{
+    protected final String requestURI;
+
+    /**
+     * Constructs a Sidecar request with the given {@code requestURI}. Defaults to {@code ssl} enabled.
+     *
+     * @param requestURI the URI of the request
+     */
+    protected Request(String requestURI)
+    {
+        this.requestURI = requestURI;
+    }
+
+    /**
+     * @return a map of headers for the request
+     */
+    public Map<String, String> headers()
+    {
+        HttpRange range = range();
+        if (range == null)
+        {
+            return Collections.emptyMap();
+        }
+        Map<String, String> headers = new HashMap<>();
+        headers.put(HttpHeaderNames.RANGE.toString(), range.toString());
+        return Collections.unmodifiableMap(headers);
+    }
+
+    /**
+     * @return the range for the HTTP request
+     */
+    protected HttpRange range()
+    {
+        return null;
+    }
+
+    /**
+     * @return the HTTP method to be used for the request
+     */
+    public abstract HttpMethod method();
+
+    /**
+     * @return the URI for the request
+     */
+    public String requestURI()
+    {
+        return requestURI;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public String toString()
+    {
+        return getClass().getSimpleName() + "{" +
+               "requestURI='" + requestURI + '\'' +
+               '}';
+    }
+}
diff --git a/common/src/main/java/org/apache/cassandra/sidecar/common/data/SchemaRequest.java b/client/src/main/java/org/apache/cassandra/sidecar/client/request/RingRequest.java
similarity index 52%
copy from common/src/main/java/org/apache/cassandra/sidecar/common/data/SchemaRequest.java
copy to client/src/main/java/org/apache/cassandra/sidecar/client/request/RingRequest.java
index 1f136b9..5c835ef 100644
--- a/common/src/main/java/org/apache/cassandra/sidecar/common/data/SchemaRequest.java
+++ b/client/src/main/java/org/apache/cassandra/sidecar/client/request/RingRequest.java
@@ -15,34 +15,42 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.cassandra.sidecar.common.data;
 
-import org.jetbrains.annotations.Nullable;
+package org.apache.cassandra.sidecar.client.request;
+
+import io.netty.handler.codec.http.HttpMethod;
+import org.apache.cassandra.sidecar.common.ApiEndpointsV1;
+import org.apache.cassandra.sidecar.common.data.RingResponse;
 
 /**
- * Holder class for the {@link org.apache.cassandra.sidecar.routes.SchemaHandler}
- * request parameters
+ * Represents a request to retrieve the Cassandra ring information
  */
-public class SchemaRequest extends QualifiedTableName
+public class RingRequest extends DecodableRequest<RingResponse>
 {
     /**
-     * Constructs a {@link SchemaRequest} with the {@link org.jetbrains.annotations.Nullable} {@code keyspace}.
+     * Constructs a request to retrieve the Cassandra ring information
+     */
+    public RingRequest()
+    {
+        super(ApiEndpointsV1.RING_ROUTE);
+    }
+
+    /**
+     * Constructs a request to retrieve the Cassandra ring information for the given {@code keyspace}
      *
      * @param keyspace the keyspace in Cassandra
      */
-    public SchemaRequest(@Nullable String keyspace)
+    public RingRequest(String keyspace)
     {
-        super(keyspace, null, false);
+        super(ApiEndpointsV1.RING_ROUTE_PER_KEYSPACE.replaceAll(ApiEndpointsV1.KEYSPACE_PATH_PARAM, keyspace));
     }
 
     /**
      * {@inheritDoc}
      */
-    public String toString()
+    @Override
+    public HttpMethod method()
     {
-        return "SchemaRequest{" +
-               "keyspace='" + keyspace() + '\'' +
-               ", tableName='" + tableName() + '\'' +
-               '}';
+        return HttpMethod.GET;
     }
 }
diff --git a/client/src/main/java/org/apache/cassandra/sidecar/client/request/SSTableComponentRequest.java b/client/src/main/java/org/apache/cassandra/sidecar/client/request/SSTableComponentRequest.java
new file mode 100644
index 0000000..61d2045
--- /dev/null
+++ b/client/src/main/java/org/apache/cassandra/sidecar/client/request/SSTableComponentRequest.java
@@ -0,0 +1,90 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.cassandra.sidecar.client.request;
+
+import io.netty.handler.codec.http.HttpMethod;
+import org.apache.cassandra.sidecar.common.ApiEndpointsV1;
+import org.apache.cassandra.sidecar.common.utils.HttpRange;
+
+/**
+ * Represents a request to stream an SSTable component
+ */
+public class SSTableComponentRequest extends Request
+{
+    private final HttpRange range;
+
+    /**
+     * Constructs a Sidecar request with the given {@code requestURI}. Defaults to {@code ssl} enabled.
+     *
+     * @param keyspace      the keyspace in Cassandra
+     * @param tableName     the table name in Cassandra
+     * @param snapshotName  the name of the snapshot
+     * @param componentName the name of the SSTable component
+     * @param range         the HTTP range for the request
+     */
+    public SSTableComponentRequest(String keyspace,
+                                   String tableName,
+                                   String snapshotName,
+                                   String componentName,
+                                   HttpRange range)
+    {
+        super(requestURI(keyspace, tableName, snapshotName, componentName));
+        this.range = range;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    protected HttpRange range()
+    {
+        return range;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public HttpMethod method()
+    {
+        return HttpMethod.GET;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public String toString()
+    {
+        return "SSTableComponentRequest{" +
+               "range=" + range +
+               ", requestURI='" + requestURI + '\'' +
+               '}';
+    }
+
+    static String requestURI(String keyspace, String table, String snapshot, String component)
+    {
+        return ApiEndpointsV1.COMPONENTS_ROUTE
+               .replaceAll(ApiEndpointsV1.KEYSPACE_PATH_PARAM, keyspace)
+               .replaceAll(ApiEndpointsV1.TABLE_PATH_PARAM, table)
+               .replaceAll(ApiEndpointsV1.SNAPSHOT_PATH_PARAM, snapshot)
+               .replaceAll(ApiEndpointsV1.COMPONENT_PATH_PARAM, component);
+    }
+}
diff --git a/common/src/main/java/org/apache/cassandra/sidecar/common/data/SchemaRequest.java b/client/src/main/java/org/apache/cassandra/sidecar/client/request/SchemaRequest.java
similarity index 53%
copy from common/src/main/java/org/apache/cassandra/sidecar/common/data/SchemaRequest.java
copy to client/src/main/java/org/apache/cassandra/sidecar/client/request/SchemaRequest.java
index 1f136b9..0941302 100644
--- a/common/src/main/java/org/apache/cassandra/sidecar/common/data/SchemaRequest.java
+++ b/client/src/main/java/org/apache/cassandra/sidecar/client/request/SchemaRequest.java
@@ -15,34 +15,42 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.cassandra.sidecar.common.data;
 
-import org.jetbrains.annotations.Nullable;
+package org.apache.cassandra.sidecar.client.request;
+
+import io.netty.handler.codec.http.HttpMethod;
+import org.apache.cassandra.sidecar.common.ApiEndpointsV1;
+import org.apache.cassandra.sidecar.common.data.SchemaResponse;
 
 /**
- * Holder class for the {@link org.apache.cassandra.sidecar.routes.SchemaHandler}
- * request parameters
+ * Represents a request to retrieve the schema
  */
-public class SchemaRequest extends QualifiedTableName
+public class SchemaRequest extends DecodableRequest<SchemaResponse>
 {
     /**
-     * Constructs a {@link SchemaRequest} with the {@link org.jetbrains.annotations.Nullable} {@code keyspace}.
+     * Constructs a request to retrieve the full Cassandra schema
+     */
+    public SchemaRequest()
+    {
+        super(ApiEndpointsV1.ALL_KEYSPACES_SCHEMA_ROUTE);
+    }
+
+    /**
+     * Constructs a request to retrieve the schema for the given {@code keyspace}
      *
      * @param keyspace the keyspace in Cassandra
      */
-    public SchemaRequest(@Nullable String keyspace)
+    public SchemaRequest(String keyspace)
     {
-        super(keyspace, null, false);
+        super(ApiEndpointsV1.KEYSPACE_SCHEMA_ROUTE.replaceAll(ApiEndpointsV1.KEYSPACE_PATH_PARAM, keyspace));
     }
 
     /**
      * {@inheritDoc}
      */
-    public String toString()
+    @Override
+    public HttpMethod method()
     {
-        return "SchemaRequest{" +
-               "keyspace='" + keyspace() + '\'' +
-               ", tableName='" + tableName() + '\'' +
-               '}';
+        return HttpMethod.GET;
     }
 }
diff --git a/client/src/main/java/org/apache/cassandra/sidecar/client/request/SnapshotRequest.java b/client/src/main/java/org/apache/cassandra/sidecar/client/request/SnapshotRequest.java
new file mode 100644
index 0000000..6e2d174
--- /dev/null
+++ b/client/src/main/java/org/apache/cassandra/sidecar/client/request/SnapshotRequest.java
@@ -0,0 +1,54 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.sidecar.client.request;
+
+import org.apache.cassandra.sidecar.common.ApiEndpointsV1;
+
+/**
+ * An abstract class that access the {@link ApiEndpointsV1#SNAPSHOTS_ROUTE}
+ *
+ * @param <T> the type of the decodable request
+ */
+abstract class SnapshotRequest<T> extends DecodableRequest<T>
+{
+    SnapshotRequest(String keyspace, String table, String snapshotName)
+    {
+        super(requestURI(keyspace, table, snapshotName, false));
+    }
+
+    SnapshotRequest(String keyspace, String table, String snapshotName, boolean includeSecondaryIndexFiles)
+    {
+        super(requestURI(keyspace, table, snapshotName, includeSecondaryIndexFiles));
+    }
+
+    static String requestURI(String keyspace, String tableName, String snapshotName, boolean includeSecondaryIndexFiles)
+    {
+        String requestUri = ApiEndpointsV1.SNAPSHOTS_ROUTE
+                            .replaceAll(ApiEndpointsV1.KEYSPACE_PATH_PARAM, keyspace)
+                            .replaceAll(ApiEndpointsV1.TABLE_PATH_PARAM, tableName)
+                            .replaceAll(ApiEndpointsV1.SNAPSHOT_PATH_PARAM, snapshotName);
+
+        if (!includeSecondaryIndexFiles)
+        {
+            return requestUri;
+        }
+
+        return requestUri + "?includeSecondaryIndexFiles=true";
+    }
+}
diff --git a/common/src/main/java/org/apache/cassandra/sidecar/common/data/SchemaRequest.java b/client/src/main/java/org/apache/cassandra/sidecar/client/request/TimeSkewRequest.java
similarity index 56%
copy from common/src/main/java/org/apache/cassandra/sidecar/common/data/SchemaRequest.java
copy to client/src/main/java/org/apache/cassandra/sidecar/client/request/TimeSkewRequest.java
index 1f136b9..586c845 100644
--- a/common/src/main/java/org/apache/cassandra/sidecar/common/data/SchemaRequest.java
+++ b/client/src/main/java/org/apache/cassandra/sidecar/client/request/TimeSkewRequest.java
@@ -15,34 +15,32 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.cassandra.sidecar.common.data;
 
-import org.jetbrains.annotations.Nullable;
+package org.apache.cassandra.sidecar.client.request;
+
+import io.netty.handler.codec.http.HttpMethod;
+import org.apache.cassandra.sidecar.common.ApiEndpointsV1;
+import org.apache.cassandra.sidecar.common.data.TimeSkewResponse;
 
 /**
- * Holder class for the {@link org.apache.cassandra.sidecar.routes.SchemaHandler}
- * request parameters
+ * Represents a request to retrieve information from the time skew endpoint
  */
-public class SchemaRequest extends QualifiedTableName
+public class TimeSkewRequest extends DecodableRequest<TimeSkewResponse>
 {
     /**
-     * Constructs a {@link SchemaRequest} with the {@link org.jetbrains.annotations.Nullable} {@code keyspace}.
-     *
-     * @param keyspace the keyspace in Cassandra
+     * Constructs a new request to retrieve information from the time skew endpoint
      */
-    public SchemaRequest(@Nullable String keyspace)
+    public TimeSkewRequest()
     {
-        super(keyspace, null, false);
+        super(ApiEndpointsV1.TIME_SKEW_ROUTE);
     }
 
     /**
      * {@inheritDoc}
      */
-    public String toString()
+    @Override
+    public HttpMethod method()
     {
-        return "SchemaRequest{" +
-               "keyspace='" + keyspace() + '\'' +
-               ", tableName='" + tableName() + '\'' +
-               '}';
+        return HttpMethod.GET;
     }
 }
diff --git a/client/src/main/java/org/apache/cassandra/sidecar/client/request/UploadSSTableRequest.java b/client/src/main/java/org/apache/cassandra/sidecar/client/request/UploadSSTableRequest.java
new file mode 100644
index 0000000..ea0fe98
--- /dev/null
+++ b/client/src/main/java/org/apache/cassandra/sidecar/client/request/UploadSSTableRequest.java
@@ -0,0 +1,101 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.sidecar.client.request;
+
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Objects;
+
+import io.netty.handler.codec.http.HttpHeaderNames;
+import io.netty.handler.codec.http.HttpMethod;
+import org.apache.cassandra.sidecar.common.ApiEndpointsV1;
+
+/**
+ * Represents a request to upload an SSTable component
+ */
+public class UploadSSTableRequest extends Request implements UploadableRequest
+{
+    private final String expectedChecksum;
+    private final String filename;
+
+    /**
+     * Constructs a request with the provided {@code requestURI}
+     *
+     * @param keyspace  the keyspace in Cassandra
+     * @param table     the table name in Cassandra
+     * @param uploadId  an identifier for the upload
+     * @param component SSTable component being uploaded
+     * @param checksum  hash value to check integrity of SSTable component uploaded
+     * @param filename  the path to the file to be uploaded
+     */
+    public UploadSSTableRequest(String keyspace, String table, String uploadId, String component,
+                                String checksum, String filename)
+    {
+        super(requestURI(keyspace, table, uploadId, component));
+        this.expectedChecksum = checksum;
+        this.filename = Objects.requireNonNull(filename, "the filename is must be non-null");
+
+        if (!Files.exists(Paths.get(filename)))
+        {
+            throw new IllegalArgumentException("File '" + filename + "' does not exist");
+        }
+    }
+
+    @Override
+    public String filename()
+    {
+        return filename;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public Map<String, String> headers()
+    {
+        if (expectedChecksum == null)
+        {
+            return super.headers();
+        }
+        Map<String, String> headers = new HashMap<>(super.headers());
+        headers.put(HttpHeaderNames.CONTENT_MD5.toString(), expectedChecksum);
+        return Collections.unmodifiableMap(headers);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public HttpMethod method()
+    {
+        return HttpMethod.PUT;
+    }
+
+    static String requestURI(String keyspace, String tableName, String uploadId, String component)
+    {
+        return ApiEndpointsV1.SSTABLE_UPLOAD_ROUTE
+               .replaceAll(ApiEndpointsV1.UPLOAD_ID_PATH_PARAM, uploadId)
+               .replaceAll(ApiEndpointsV1.KEYSPACE_PATH_PARAM, keyspace)
+               .replaceAll(ApiEndpointsV1.TABLE_PATH_PARAM, tableName)
+               .replaceAll(ApiEndpointsV1.COMPONENT_PATH_PARAM, component);
+    }
+}
diff --git a/common/src/main/java/org/apache/cassandra/sidecar/common/MockCassandraFactory.java b/client/src/main/java/org/apache/cassandra/sidecar/client/request/UploadableRequest.java
similarity index 77%
copy from common/src/main/java/org/apache/cassandra/sidecar/common/MockCassandraFactory.java
copy to client/src/main/java/org/apache/cassandra/sidecar/client/request/UploadableRequest.java
index 5e6cd68..bea540b 100644
--- a/common/src/main/java/org/apache/cassandra/sidecar/common/MockCassandraFactory.java
+++ b/client/src/main/java/org/apache/cassandra/sidecar/client/request/UploadableRequest.java
@@ -16,16 +16,15 @@
  * limitations under the License.
  */
 
-package org.apache.cassandra.sidecar.common;
+package org.apache.cassandra.sidecar.client.request;
 
 /**
- *
+ * A request that will upload the provided file to the service
  */
-public class MockCassandraFactory implements ICassandraFactory
+public interface UploadableRequest
 {
-    @Override
-    public ICassandraAdapter create(CQLSession session, JmxClient jmxClient)
-    {
-        return null;
-    }
+    /**
+     * @return the path to the file to be uploaded
+     */
+    String filename();
 }
diff --git a/client/src/main/java/org/apache/cassandra/sidecar/client/retry/BasicRetryPolicy.java b/client/src/main/java/org/apache/cassandra/sidecar/client/retry/BasicRetryPolicy.java
new file mode 100644
index 0000000..6b8d3dd
--- /dev/null
+++ b/client/src/main/java/org/apache/cassandra/sidecar/client/retry/BasicRetryPolicy.java
@@ -0,0 +1,297 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.sidecar.client.retry;
+
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
+
+import io.netty.handler.codec.http.HttpResponseStatus;
+import io.netty.handler.codec.http.HttpStatusClass;
+import org.apache.cassandra.sidecar.client.HttpResponse;
+import org.apache.cassandra.sidecar.client.exception.ResourceNotFoundException;
+import org.apache.cassandra.sidecar.client.request.Request;
+
+/**
+ * A basic {@link RetryPolicy} supporting standard status codes
+ */
+public class BasicRetryPolicy extends RetryPolicy
+{
+    protected static final String RETRY_AFTER = "Retry-After";
+    public static final int RETRY_INDEFINITELY = -1;
+    protected final int maxRetries;
+    protected final long retryDelayMillis;
+
+    /**
+     * Constructs a basic retry policy with unlimited number of retries and no delay between retries.
+     */
+    public BasicRetryPolicy()
+    {
+        this(RETRY_INDEFINITELY, 0);
+    }
+
+    /**
+     * Constructs a basic retry policy with {@code maxRetries} number of retries and no delay between retries.
+     *
+     * @param maxRetries the maximum number of retries
+     */
+    public BasicRetryPolicy(int maxRetries)
+    {
+        this(maxRetries, 0);
+    }
+
+    /**
+     * Constructs a basic retry policy with {@code maxRetries} number of retries and {@code retryDelayMillis} delay
+     * between retries.
+     *
+     * @param maxRetries       the maximum number of retries
+     * @param retryDelayMillis the delay between retries in milliseconds
+     */
+    public BasicRetryPolicy(int maxRetries, long retryDelayMillis)
+    {
+        this.maxRetries = maxRetries;
+        this.retryDelayMillis = retryDelayMillis;
+    }
+
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public void onResponse(CompletableFuture<HttpResponse> responseFuture,
+                           Request request,
+                           HttpResponse response,
+                           Throwable throwable,
+                           int attempts,
+                           boolean canRetryOnADifferentHost,
+                           RetryAction retryAction)
+    {
+        // throwable can be a client connection error that prevents connecting to the remote host
+        if (throwable != null ||
+            response.statusCode() == HttpResponseStatus.INTERNAL_SERVER_ERROR.code())
+        {
+            if (canRetryOnADifferentHost)
+            {
+                retryImmediately(responseFuture, request, retryAction, attempts, throwable);
+            }
+            else
+            {
+                retry(responseFuture, request, retryAction, attempts, throwable);
+            }
+            return;
+        }
+
+        if (response.statusCode() == HttpResponseStatus.OK.code() ||
+            response.statusCode() == HttpResponseStatus.PARTIAL_CONTENT.code())
+        {
+            responseFuture.complete(response);
+            return;
+        }
+
+        if (response.statusCode() == HttpResponseStatus.NOT_FOUND.code())
+        {
+            if (canRetryOnADifferentHost)
+            {
+                retryImmediately(responseFuture, request, retryAction, attempts);
+            }
+            else
+            {
+                logger.error("Request resource not found. response={}, attempts={}", response, attempts);
+                responseFuture.completeExceptionally(new ResourceNotFoundException(request));
+            }
+            return;
+        }
+
+        if (response.statusCode() == HttpResponseStatus.NOT_IMPLEMENTED.code())
+        {
+            logger.error("Request to a not implemented endpoint. response={}, attempts={}", response, attempts);
+            responseFuture.completeExceptionally(unsupportedOperation(response));
+            return;
+        }
+
+        if (response.statusCode() == HttpResponseStatus.SERVICE_UNAVAILABLE.code())
+        {
+            if (canRetryOnADifferentHost)
+            {
+                retryImmediately(responseFuture, request, retryAction, attempts);
+            }
+            else
+            {
+                retry(responseFuture, request, retryAction, attempts,
+                      maybeParseRetryAfterOrDefault(response, attempts), null);
+            }
+            return;
+        }
+
+        if (response.statusCode() == HttpResponseStatus.ACCEPTED.code())
+        {
+            retryAction.retry(1, retryDelayMillis(1));
+            return;
+        }
+
+        // 4xx Client Errors - 5xx Server Errors
+        if (HttpStatusClass.CLIENT_ERROR.contains(response.statusCode()) ||
+            HttpStatusClass.SERVER_ERROR.contains(response.statusCode()))
+        {
+            if (canRetryOnADifferentHost)
+            {
+                retryImmediately(responseFuture, request, retryAction, attempts);
+            }
+            else
+            {
+                logger.error("Request exhausted. response={}, attempts={}", response, attempts);
+                responseFuture.completeExceptionally(retriesExhausted(attempts, request));
+            }
+            return;
+        }
+
+        logger.error("Request encountered an unexpected status code. response={}, attempts={}", response, attempts);
+        responseFuture.completeExceptionally(unexpectedStatusCode(response));
+    }
+
+    /**
+     * @return the maximum number of retries configured for this retry policy
+     */
+    protected int maxRetries()
+    {
+        return maxRetries;
+    }
+
+    /**
+     * Returns the number of milliseconds to wait before attempting the next request.
+     *
+     * @param attempts the number of attempts already performed for this request
+     * @return the number of milliseconds to wait before attempting the next request
+     */
+    protected long retryDelayMillis(int attempts)
+    {
+        return retryDelayMillis;
+    }
+
+    /**
+     * Retries the request with no delay
+     *
+     * @param future      a future for the {@link HttpResponse}
+     * @param request     the HTTP request
+     * @param retryAction the action that is called on retry
+     * @param attempts    the number of attempts for the request
+     */
+    protected void retryImmediately(CompletableFuture<HttpResponse> future,
+                                    Request request,
+                                    RetryAction retryAction,
+                                    int attempts)
+    {
+        retry(future, request, retryAction, attempts, 0L, null);
+    }
+
+    /**
+     * Retries the request with no delay
+     *
+     * @param future      a future for the {@link HttpResponse}
+     * @param request     the HTTP request
+     * @param retryAction the action that is called on retry
+     * @param attempts    the number of attempts for the request
+     * @param throwable   the underlying exception
+     */
+    protected void retryImmediately(CompletableFuture<HttpResponse> future,
+                                    Request request,
+                                    RetryAction retryAction,
+                                    int attempts,
+                                    Throwable throwable)
+    {
+        retry(future, request, retryAction, attempts, 0L, throwable);
+    }
+
+    /**
+     * Retries the request after waiting for the configured retryDelayMillis
+     *
+     * @param future      a future for the {@link HttpResponse}
+     * @param request     the HTTP request
+     * @param retryAction the action that is called on retry
+     * @param attempts    the number of attempts for the request
+     * @param throwable   the underlying exception
+     */
+    protected void retry(CompletableFuture<HttpResponse> future,
+                         Request request,
+                         RetryAction retryAction,
+                         int attempts,
+                         Throwable throwable)
+    {
+        retry(future, request, retryAction, attempts, retryDelayMillis(attempts), throwable);
+    }
+
+    /**
+     * Retries the request after waiting for {@code sleepTimeMillis}. If the retries have exceeded the maximum number
+     * of retries allowed, it completes exceptionally with a
+     * {@link org.apache.cassandra.sidecar.client.exception.RetriesExhaustedException}.
+     *
+     * @param future          a future for the {@link HttpResponse}
+     * @param request         the HTTP request
+     * @param retryAction     the action that is called on retry
+     * @param attempts        the number of attempts for the request
+     * @param sleepTimeMillis the amount of time to wait in milliseconds before attempting the request again
+     * @param throwable       the underlying error
+     */
+    protected void retry(CompletableFuture<HttpResponse> future,
+                         Request request,
+                         RetryAction retryAction,
+                         int attempts,
+                         long sleepTimeMillis,
+                         Throwable throwable)
+    {
+        int configuredMaxRetries = maxRetries();
+        if (configuredMaxRetries > RETRY_INDEFINITELY && attempts >= configuredMaxRetries)
+        {
+            future.completeExceptionally(retriesExhausted(attempts, request, throwable));
+        }
+        else
+        {
+            retryAction.retry(attempts + 1, sleepTimeMillis);
+        }
+    }
+
+    /**
+     * Tries to parse the {@code Retry-After} header from the response, and if successful, returns the number of
+     * milliseconds to wait specified by the header. If it fails to parse, returns the result of
+     * {@link #retryDelayMillis(int)}.
+     *
+     * @param response the HTTP response
+     * @param attempts the number of attempts for the request
+     * @return the delay to wait specified by the request if available, or the default specified by
+     * {@link #retryDelayMillis(int)}
+     */
+    protected long maybeParseRetryAfterOrDefault(HttpResponse response, int attempts)
+    {
+        List<String> retryAfter = response.headers().get(RETRY_AFTER);
+        if (retryAfter != null && !retryAfter.isEmpty())
+        {
+            try
+            {
+                // <delay-seconds> spec is in seconds - convert to millis
+                long seconds = Long.parseLong(retryAfter.get(0));
+                return TimeUnit.SECONDS.toMillis(seconds);
+            }
+            catch (NumberFormatException e)
+            {
+                logger.warn("Failed to parse header={}, value={}", RETRY_AFTER, retryAfter.get(0), e);
+            }
+        }
+        return retryDelayMillis(attempts);
+    }
+}
diff --git a/client/src/main/java/org/apache/cassandra/sidecar/client/retry/ExponentialBackoffRetryPolicy.java b/client/src/main/java/org/apache/cassandra/sidecar/client/retry/ExponentialBackoffRetryPolicy.java
new file mode 100644
index 0000000..7589ddd
--- /dev/null
+++ b/client/src/main/java/org/apache/cassandra/sidecar/client/retry/ExponentialBackoffRetryPolicy.java
@@ -0,0 +1,124 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.sidecar.client.retry;
+
+/**
+ * A retry policy that will perform an exponential backoff. The backoff period increases for each retry attempt.
+ *
+ * <p>Example: For a configured {@code maxRetries} of {@code 10}, {@code retryDelayMillis} of {@code 200}
+ * milliseconds, and {@code maxRetryDelayMillis} of {@code 20,000} milliseconds, the sequence is as follows:
+ *
+ * <pre>
+ *     request       backoff
+ *
+ *     1                200
+ *     2                400
+ *     3                800
+ *     4               1600
+ *     5               3200
+ *     6               6400
+ *     7              12800
+ *     8              20000
+ *     9              20000
+ *     10             20000
+ * </pre>
+ */
+public class ExponentialBackoffRetryPolicy extends BasicRetryPolicy
+{
+    private final long maxRetryDelayMillis;
+    private final long maxAttemptsBeforeOverflow;
+
+    /**
+     * Constructs an exponential backoff retry policy unlimited number of retries and no delay between retries.
+     */
+    public ExponentialBackoffRetryPolicy()
+    {
+        super();
+        this.maxRetryDelayMillis = 0;
+        this.maxAttemptsBeforeOverflow = computeMaxAttemptsBeforeOverflow();
+    }
+
+    /**
+     * Constructs an exponential backoff retry policy with {@code maxRetries} number of retries,
+     * {@code retryDelayMillis} delay between retries, and {@code maxRetryDelayMillis} maximum delay for the
+     * exponential backoff.
+     *
+     * @param maxRetries          the maximum number of retries
+     * @param retryDelayMillis    the delay between retries in milliseconds
+     * @param maxRetryDelayMillis the maximum retry delay in milliseconds
+     */
+    public ExponentialBackoffRetryPolicy(int maxRetries, long retryDelayMillis, long maxRetryDelayMillis)
+    {
+        super(maxRetries, retryDelayMillis);
+        this.maxRetryDelayMillis = maxRetryDelayMillis;
+        this.maxAttemptsBeforeOverflow = computeMaxAttemptsBeforeOverflow();
+    }
+
+    /**
+     * Returns the number of milliseconds to wait before attempting the next request. This value is upper-bounded
+     * by {@code maxRetryDelayMillis} if configured. The delay increases exponentially based on the number of
+     * attempts already performed.
+     *
+     * @param attempts the number of attempts already performed for this request
+     * @return the number of milliseconds to wait before attempting the next request
+     */
+    @Override
+    protected long retryDelayMillis(int attempts)
+    {
+        long retryDelay;
+        if (attempts >= maxAttemptsBeforeOverflow)
+        {
+            // the number of attempts in the method will overflow, so just use the Long.MAX_VALUE
+            retryDelay = Long.MAX_VALUE;
+        }
+        else
+        {
+            retryDelay = (long) Math.pow(2, attempts - 1)  * retryDelayMillis;
+        }
+        if (maxRetryDelayMillis > 0)
+        {
+            return Math.min(maxRetryDelayMillis, retryDelay);
+        }
+        return retryDelay;
+    }
+
+    /**
+     * Computes the maximum number of attempts possible before overflowing. The value is computed from:
+     *
+     * <pre>
+     *                             (   Long.MAX_VALUE  )
+     *     max_attempts = 1 + log2 ( ----------------- )
+     *                             (  retryDelayMills  )
+     * </pre>
+     *
+     * Which is deduced from the {@code retryDelay} calculation in the {@link #retryDelayMillis(int)} method:
+     *
+     * <pre>
+     *     retryDelay = 2^(attempts - 1) * retryDelayMillis
+     * </pre>
+     *
+     * Where the {@code retryDelay} is the maximum allowed value before overflowing, {@code Long.MAX_VALUE}.
+     *
+     * @return the maximum number of attempts before overflowing
+     */
+    private long computeMaxAttemptsBeforeOverflow()
+    {
+        return 1L + (long) (Math.log((double) Long.MAX_VALUE / (double) retryDelayMillis) / Math.log(2));
+    }
+}
diff --git a/client/src/main/java/org/apache/cassandra/sidecar/client/retry/IgnoreConflictRetryPolicy.java b/client/src/main/java/org/apache/cassandra/sidecar/client/retry/IgnoreConflictRetryPolicy.java
new file mode 100644
index 0000000..ec959d7
--- /dev/null
+++ b/client/src/main/java/org/apache/cassandra/sidecar/client/retry/IgnoreConflictRetryPolicy.java
@@ -0,0 +1,74 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.sidecar.client.retry;
+
+import java.util.concurrent.CompletableFuture;
+
+import io.netty.handler.codec.http.HttpResponseStatus;
+import org.apache.cassandra.sidecar.client.HttpResponse;
+import org.apache.cassandra.sidecar.client.request.Request;
+import org.jetbrains.annotations.VisibleForTesting;
+
+/**
+ * A retry policy that ignores status code {@link HttpResponseStatus#CONFLICT} ({@code 409}), and assumes success
+ * when that status code is returned by the server.
+ */
+public class IgnoreConflictRetryPolicy extends ExponentialBackoffRetryPolicy
+{
+    @VisibleForTesting
+    IgnoreConflictRetryPolicy()
+    {
+        super();
+    }
+
+    /**
+     * Constructs a new instance of this class
+     *
+     * @param maxRetries          the maximum number of retries
+     * @param retryDelayMillis    the delay between retries in milliseconds
+     * @param maxRetryDelayMillis the maximum retry delay in milliseconds
+     */
+    public IgnoreConflictRetryPolicy(int maxRetries, long retryDelayMillis, long maxRetryDelayMillis)
+    {
+        super(maxRetries, retryDelayMillis, maxRetryDelayMillis);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public void onResponse(CompletableFuture<HttpResponse> responseFuture,
+                           Request request,
+                           HttpResponse response,
+                           Throwable throwable,
+                           int attempts,
+                           boolean canRetryOnADifferentHost,
+                           RetryAction retryAction)
+    {
+        if (response != null && response.statusCode() == HttpResponseStatus.CONFLICT.code())
+        {
+            responseFuture.complete(response);
+        }
+        else
+        {
+            super.onResponse(responseFuture, request, response, throwable, attempts, canRetryOnADifferentHost,
+                             retryAction);
+        }
+    }
+}
diff --git a/client/src/main/java/org/apache/cassandra/sidecar/client/retry/NoRetryPolicy.java b/client/src/main/java/org/apache/cassandra/sidecar/client/retry/NoRetryPolicy.java
new file mode 100644
index 0000000..c5cde09
--- /dev/null
+++ b/client/src/main/java/org/apache/cassandra/sidecar/client/retry/NoRetryPolicy.java
@@ -0,0 +1,58 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.sidecar.client.retry;
+
+import java.util.concurrent.CompletableFuture;
+
+import io.netty.handler.codec.http.HttpResponseStatus;
+import org.apache.cassandra.sidecar.client.HttpResponse;
+import org.apache.cassandra.sidecar.client.request.Request;
+
+/**
+ * A retry policy that does not perform any retries, and it is only successful on status code
+ * {@link HttpResponseStatus#OK}
+ */
+public class NoRetryPolicy extends RetryPolicy
+{
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public void onResponse(CompletableFuture<HttpResponse> responseFuture,
+                           Request request,
+                           HttpResponse response,
+                           Throwable throwable,
+                           int attempts,
+                           boolean canRetryOnADifferentHost,
+                           RetryAction retryAction)
+    {
+        if (throwable != null)
+        {
+            responseFuture.completeExceptionally(retriesExhausted(attempts, request, throwable));
+        }
+        else if (response.statusCode() == HttpResponseStatus.OK.code())
+        {
+            responseFuture.complete(response);
+        }
+        else
+        {
+            responseFuture.completeExceptionally(retriesExhausted(attempts, request));
+        }
+    }
+}
diff --git a/src/main/java/org/apache/cassandra/sidecar/exceptions/RangeException.java b/client/src/main/java/org/apache/cassandra/sidecar/client/retry/RetryAction.java
similarity index 65%
copy from src/main/java/org/apache/cassandra/sidecar/exceptions/RangeException.java
copy to client/src/main/java/org/apache/cassandra/sidecar/client/retry/RetryAction.java
index 3dc783c..24b13f8 100644
--- a/src/main/java/org/apache/cassandra/sidecar/exceptions/RangeException.java
+++ b/client/src/main/java/org/apache/cassandra/sidecar/client/retry/RetryAction.java
@@ -16,15 +16,18 @@
  * limitations under the License.
  */
 
-package org.apache.cassandra.sidecar.exceptions;
+package org.apache.cassandra.sidecar.client.retry;
 
 /**
- * Custom exception
+ * An action called when a request is retried
  */
-public class RangeException extends RuntimeException
+public interface RetryAction
 {
-    public RangeException(String msg)
-    {
-        super(msg);
-    }
+    /**
+     * Notify that the request must be retried with {@code retryDelayMillis}.
+     *
+     * @param attempts         the number of attempts for this request
+     * @param retryDelayMillis the delay between retries in milliseconds
+     */
+    void retry(int attempts, long retryDelayMillis);
 }
diff --git a/client/src/main/java/org/apache/cassandra/sidecar/client/retry/RetryPolicy.java b/client/src/main/java/org/apache/cassandra/sidecar/client/retry/RetryPolicy.java
new file mode 100644
index 0000000..8d9ac91
--- /dev/null
+++ b/client/src/main/java/org/apache/cassandra/sidecar/client/retry/RetryPolicy.java
@@ -0,0 +1,104 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.sidecar.client.retry;
+
+import java.util.concurrent.CompletableFuture;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import org.apache.cassandra.sidecar.client.HttpResponse;
+import org.apache.cassandra.sidecar.client.exception.RetriesExhaustedException;
+import org.apache.cassandra.sidecar.client.exception.UnexpectedStatusCodeException;
+import org.apache.cassandra.sidecar.client.request.Request;
+
+/**
+ * An abstract class representing a retry policy
+ */
+public abstract class RetryPolicy
+{
+    protected final Logger logger = LoggerFactory.getLogger(this.getClass());
+
+    /**
+     * Method called when a {@code response} is received and a decision needs to be made about the response status.
+     *
+     * @param responseFuture           a future that returns the response, or an exception when the retry is
+     *                                 unsuccessful
+     * @param request                  the HTTP request
+     * @param response                 the {@link HttpResponse} received from the server
+     * @param throwable                the error encountered during the request, or null if no error was encountered
+     * @param attempts                 the number of attempts performed for the request
+     * @param canRetryOnADifferentHost whether this request can be retried on a different host or not
+     * @param retryAction              an action called when a request is retried
+     */
+    public abstract void onResponse(CompletableFuture<HttpResponse> responseFuture,
+                                    Request request,
+                                    HttpResponse response,
+                                    Throwable throwable,
+                                    int attempts,
+                                    boolean canRetryOnADifferentHost,
+                                    RetryAction retryAction);
+
+    /**
+     * Returns a {@link RetriesExhaustedException} with the number of {@code attempts} performed before the retries.
+     *
+     * @param attempts the number of attempts for the request
+     * @param request  the HTTP request
+     * @return a {@link RetriesExhaustedException} with the number of {@code attempts} performed before the retries
+     */
+    RetriesExhaustedException retriesExhausted(int attempts, Request request)
+    {
+        return new RetriesExhaustedException(attempts, request);
+    }
+
+    /**
+     * Returns a {@link RetriesExhaustedException} with the number of {@code attempts} performed before the retries.
+     *
+     * @param attempts  the number of attempts for the request
+     * @param request   the HTTP request
+     * @param throwable the underlying exception
+     * @return a {@link RetriesExhaustedException} with the number of {@code attempts} performed before the retries
+     */
+    RetriesExhaustedException retriesExhausted(int attempts, Request request, Throwable throwable)
+    {
+        return new RetriesExhaustedException(attempts, request, throwable);
+    }
+
+    /**
+     * Returns an {@link UnsupportedOperationException} with the provided {@code response}.
+     *
+     * @param response the response to use
+     * @return an {@link UnsupportedOperationException} with the provided {@code response}
+     */
+    UnsupportedOperationException unsupportedOperation(HttpResponse response)
+    {
+        return new UnsupportedOperationException(response.contentAsString());
+    }
+
+    /**
+     * Returns a {@link UnexpectedStatusCodeException} with the provided {@code response}.
+     *
+     * @param response the response to use
+     * @return a {@link UnexpectedStatusCodeException} with the provided {@code response}
+     */
+    UnexpectedStatusCodeException unexpectedStatusCode(HttpResponse response)
+    {
+        return new UnexpectedStatusCodeException(response.statusCode(), response.contentAsString());
+    }
+}
diff --git a/client/src/main/java/org/apache/cassandra/sidecar/client/retry/RunnableOnStatusCodeRetryPolicy.java b/client/src/main/java/org/apache/cassandra/sidecar/client/retry/RunnableOnStatusCodeRetryPolicy.java
new file mode 100644
index 0000000..325ee7c
--- /dev/null
+++ b/client/src/main/java/org/apache/cassandra/sidecar/client/retry/RunnableOnStatusCodeRetryPolicy.java
@@ -0,0 +1,77 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.sidecar.client.retry;
+
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.apache.cassandra.sidecar.client.HttpResponse;
+import org.apache.cassandra.sidecar.client.request.Request;
+
+/**
+ * A delegate retry policy that executes the Runnable every configured number of requests retries on the
+ * configured status code.
+ */
+public class RunnableOnStatusCodeRetryPolicy extends RetryPolicy
+{
+    private final RetryPolicy delegate;
+    private final AtomicInteger recordedAttempts = new AtomicInteger(0);
+    private final int statusCode;
+    private final int numberOfEntriesToSkip;
+    private final Runnable runnable;
+
+    public RunnableOnStatusCodeRetryPolicy(Runnable runnable, RetryPolicy delegate, int statusCode)
+    {
+        this(runnable, delegate, statusCode, 10);
+    }
+
+    public RunnableOnStatusCodeRetryPolicy(Runnable runnable,
+                                           RetryPolicy delegate,
+                                           int statusCode,
+                                           int numberOfEntriesToSkip)
+    {
+        this.runnable = runnable;
+        this.delegate = delegate;
+        this.statusCode = statusCode;
+        this.numberOfEntriesToSkip = numberOfEntriesToSkip;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public void onResponse(CompletableFuture<HttpResponse> responseFuture,
+                           Request request,
+                           HttpResponse response,
+                           Throwable throwable,
+                           int attempts,
+                           boolean canRetryOnADifferentHost,
+                           RetryAction retryAction)
+    {
+        if (response != null
+            && response.statusCode() == statusCode
+            && recordedAttempts.getAndIncrement() % numberOfEntriesToSkip == 0)
+        {
+            runnable.run();
+        }
+
+        delegate.onResponse(responseFuture, request, response, throwable, attempts, canRetryOnADifferentHost,
+                            retryAction);
+    }
+}
diff --git a/common/src/main/java/org/apache/cassandra/sidecar/common/MockCassandraFactory.java b/client/src/main/java/org/apache/cassandra/sidecar/client/selection/InstanceSelectionPolicy.java
similarity index 77%
copy from common/src/main/java/org/apache/cassandra/sidecar/common/MockCassandraFactory.java
copy to client/src/main/java/org/apache/cassandra/sidecar/client/selection/InstanceSelectionPolicy.java
index 5e6cd68..d1d33bb 100644
--- a/common/src/main/java/org/apache/cassandra/sidecar/common/MockCassandraFactory.java
+++ b/client/src/main/java/org/apache/cassandra/sidecar/client/selection/InstanceSelectionPolicy.java
@@ -16,16 +16,13 @@
  * limitations under the License.
  */
 
-package org.apache.cassandra.sidecar.common;
+package org.apache.cassandra.sidecar.client.selection;
+
+import org.apache.cassandra.sidecar.client.SidecarInstance;
 
 /**
- *
+ * Defines the selection policy for an instance
  */
-public class MockCassandraFactory implements ICassandraFactory
+public interface InstanceSelectionPolicy extends Iterable<SidecarInstance>
 {
-    @Override
-    public ICassandraAdapter create(CQLSession session, JmxClient jmxClient)
-    {
-        return null;
-    }
 }
diff --git a/client/src/main/java/org/apache/cassandra/sidecar/client/selection/OrderedInstanceSelectionPolicy.java b/client/src/main/java/org/apache/cassandra/sidecar/client/selection/OrderedInstanceSelectionPolicy.java
new file mode 100644
index 0000000..62df635
--- /dev/null
+++ b/client/src/main/java/org/apache/cassandra/sidecar/client/selection/OrderedInstanceSelectionPolicy.java
@@ -0,0 +1,55 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.sidecar.client.selection;
+
+import java.util.Iterator;
+
+import org.apache.cassandra.sidecar.client.SidecarInstance;
+import org.apache.cassandra.sidecar.client.SidecarInstancesProvider;
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * A selection policy for multiple Cassandra Sidecar instances where the iterator returns in the order specified
+ * by the underlying {@link Iterator} for the {@link SidecarInstancesProvider#instances()}.
+ */
+public class OrderedInstanceSelectionPolicy implements InstanceSelectionPolicy
+{
+    protected final SidecarInstancesProvider instancesProvider;
+
+    /**
+     * Constructs a new {@link OrderedInstanceSelectionPolicy} with the provided list of instances
+     *
+     * @param instancesProvider the class that provides the sidecar instances
+     */
+    public OrderedInstanceSelectionPolicy(SidecarInstancesProvider instancesProvider)
+    {
+        this.instancesProvider = instancesProvider;
+    }
+
+    /**
+     * Returns an iterator of {@link SidecarInstance instances}.
+     *
+     * @return an iterator of {@link SidecarInstance instances}
+     */
+    @NotNull
+    public Iterator<SidecarInstance> iterator()
+    {
+        return instancesProvider.instances().iterator();
+    }
+}
diff --git a/client/src/main/java/org/apache/cassandra/sidecar/client/selection/RandomInstanceSelectionPolicy.java b/client/src/main/java/org/apache/cassandra/sidecar/client/selection/RandomInstanceSelectionPolicy.java
new file mode 100644
index 0000000..9864df0
--- /dev/null
+++ b/client/src/main/java/org/apache/cassandra/sidecar/client/selection/RandomInstanceSelectionPolicy.java
@@ -0,0 +1,61 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.sidecar.client.selection;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+import java.util.concurrent.ThreadLocalRandom;
+
+import org.apache.cassandra.sidecar.client.SidecarInstance;
+import org.apache.cassandra.sidecar.client.SidecarInstancesProvider;
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * An instance selection policy that returns instances in random order, and does not necessarily return the instances
+ * in the provided order from the underlying {@link Iterator} for the {@link SidecarInstancesProvider#instances()}.
+ */
+public class RandomInstanceSelectionPolicy extends OrderedInstanceSelectionPolicy
+{
+    /**
+     * Constructs a new {@link RandomInstanceSelectionPolicy} with the provided list of instances
+     *
+     * @param instancesProvider the class that provides the sidecar instances
+     */
+    public RandomInstanceSelectionPolicy(SidecarInstancesProvider instancesProvider)
+    {
+        super(instancesProvider);
+    }
+
+    /**
+     * Returns an iterator of {@link SidecarInstance instances} in random order. This is a non-deterministic operation
+     * because the underlying list will be shuffled every time this iterator is called.
+     *
+     * @return an iterator of {@link SidecarInstance instances} in random order
+     */
+    @Override
+    @NotNull
+    public Iterator<SidecarInstance> iterator()
+    {
+        List<SidecarInstance> instances = new ArrayList<>(instancesProvider.instances());
+        Collections.shuffle(instances, ThreadLocalRandom.current());
+        return instances.iterator();
+    }
+}
diff --git a/client/src/main/java/org/apache/cassandra/sidecar/client/selection/SingleInstanceSelectionPolicy.java b/client/src/main/java/org/apache/cassandra/sidecar/client/selection/SingleInstanceSelectionPolicy.java
new file mode 100644
index 0000000..ecf0fc4
--- /dev/null
+++ b/client/src/main/java/org/apache/cassandra/sidecar/client/selection/SingleInstanceSelectionPolicy.java
@@ -0,0 +1,73 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.sidecar.client.selection;
+
+import java.util.Iterator;
+import java.util.NoSuchElementException;
+
+import org.apache.cassandra.sidecar.client.SidecarInstance;
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * A selection policy for a single Cassandra Sidecar instance
+ */
+public class SingleInstanceSelectionPolicy implements InstanceSelectionPolicy
+{
+    private final SidecarInstance sidecarInstance;
+
+    /**
+     * Constructs a {@link SingleInstanceSelectionPolicy} with the provided {@code sidecarInstance}.
+     *
+     * @param sidecarInstance the sidecar instance
+     */
+    public SingleInstanceSelectionPolicy(SidecarInstance sidecarInstance)
+    {
+        this.sidecarInstance = sidecarInstance;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    @NotNull
+    public Iterator<SidecarInstance> iterator()
+    {
+        return new Iterator<SidecarInstance>()
+        {
+            boolean hasNext = true;
+
+            @Override
+            public boolean hasNext()
+            {
+                return hasNext;
+            }
+
+            @Override
+            public SidecarInstance next()
+            {
+                if (!hasNext)
+                {
+                    throw new NoSuchElementException();
+                }
+                hasNext = false;
+                return sidecarInstance;
+            }
+        };
+    }
+}
diff --git a/client/src/test/java/org/apache/cassandra/sidecar/client/HttpClientConfigTest.java b/client/src/test/java/org/apache/cassandra/sidecar/client/HttpClientConfigTest.java
new file mode 100644
index 0000000..5a35e34
--- /dev/null
+++ b/client/src/test/java/org/apache/cassandra/sidecar/client/HttpClientConfigTest.java
@@ -0,0 +1,154 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.sidecar.client;
+
+import java.io.InputStream;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+
+/**
+ * Unit tests for {@link HttpClientConfig}
+ */
+class HttpClientConfigTest
+{
+    @Test
+    void testBuilderDefaults()
+    {
+        HttpClientConfig config = new HttpClientConfig.Builder<>().build();
+        assertThat(config.timeoutMillis()).isEqualTo(30_000);
+        assertThat(config.ssl()).isEqualTo(true);
+        assertThat(config.maxPoolSize()).isEqualTo(20);
+        assertThat(config.userAgent()).isEqualTo("sidecar-client/1.0.0");
+        assertThat(config.idleTimeoutMillis()).isEqualTo(0);
+        assertThat(config.maxChunkSize()).isEqualTo(6291456);
+        assertThat(config.receiveBufferSize()).isEqualTo(-1);
+        assertThat(config.sendReadBufferSize()).isEqualTo(8192);
+        assertThat(config.trustStoreInputStream()).isNull();
+        assertThat(config.trustStorePassword()).isNull();
+        assertThat(config.trustStoreType()).isEqualTo("JKS");
+        assertThat(config.keyStoreInputStream()).isNull();
+        assertThat(config.keyStorePassword()).isNull();
+        assertThat(config.keyStoreType()).isEqualTo("PKCS12");
+    }
+
+    @Test
+    void testBuilderTimeout()
+    {
+        HttpClientConfig config = new HttpClientConfig.Builder<>().timeoutMillis(5_000).build();
+        assertThat(config.timeoutMillis()).isEqualTo(5_000);
+    }
+
+    @Test
+    void testBuilderSsl()
+    {
+        HttpClientConfig config = new HttpClientConfig.Builder<>().ssl(true).build();
+        assertThat(config.ssl()).isTrue();
+        config = new HttpClientConfig.Builder<>().ssl(false).build();
+        assertThat(config.ssl()).isFalse();
+    }
+
+    @Test
+    void testMaxPoolSize()
+    {
+        HttpClientConfig config = new HttpClientConfig.Builder<>().maxPoolSize(120).build();
+        assertThat(config.maxPoolSize()).isEqualTo(120);
+    }
+
+    @Test
+    void testUserAgent()
+    {
+        HttpClientConfig config = new HttpClientConfig.Builder<>().userAgent("test-user-agent/5.0.0").build();
+        assertThat(config.userAgent()).isEqualTo("test-user-agent/5.0.0");
+    }
+
+    @Test
+    void testIdleTimeoutMillis()
+    {
+        HttpClientConfig config = new HttpClientConfig.Builder<>().idleTimeoutMillis(5_555).build();
+        assertThat(config.idleTimeoutMillis()).isEqualTo(5_555);
+    }
+
+    @Test
+    void testMaxChunkSize()
+    {
+        HttpClientConfig config = new HttpClientConfig.Builder<>().maxChunkSize(7_250).build();
+        assertThat(config.maxChunkSize()).isEqualTo(7_250);
+    }
+
+    @Test
+    void testReceiveBufferSize()
+    {
+        HttpClientConfig config = new HttpClientConfig.Builder<>().receiveBufferSize(250).build();
+        assertThat(config.receiveBufferSize()).isEqualTo(250);
+    }
+
+    @Test
+    void testReadBufferSize()
+    {
+        HttpClientConfig config = new HttpClientConfig.Builder<>().readBufferSize(188).build();
+        assertThat(config.sendReadBufferSize()).isEqualTo(188);
+    }
+
+    @Test
+    void testTrustStoreInputStream()
+    {
+        InputStream mockInputStream = mock(InputStream.class);
+        HttpClientConfig config = new HttpClientConfig.Builder<>().trustStoreInputStream(mockInputStream).build();
+        assertThat(config.trustStoreInputStream()).isEqualTo(mockInputStream);
+    }
+
+    @Test
+    void testTrustStorePassword()
+    {
+        HttpClientConfig config = new HttpClientConfig.Builder<>().trustStorePassword("do-not-use-password").build();
+        assertThat(config.trustStorePassword()).isEqualTo("do-not-use-password");
+    }
+
+    @Test
+    void testTrustStoreType()
+    {
+        HttpClientConfig config = new HttpClientConfig.Builder<>().trustStoreType("CUSTOM_TYPE").build();
+        assertThat(config.trustStoreType()).isEqualTo("CUSTOM_TYPE");
+    }
+
+    @Test
+    void testKeystoreInputStream()
+    {
+        InputStream mockInputStream = mock(InputStream.class);
+        HttpClientConfig config = new HttpClientConfig.Builder<>().keyStoreInputStream(mockInputStream).build();
+        assertThat(config.keyStoreInputStream()).isEqualTo(mockInputStream);
+    }
+
+    @Test
+    void testKeystorePassword()
+    {
+        HttpClientConfig config = new HttpClientConfig.Builder<>().keyStorePassword("do-not-use-password").build();
+        assertThat(config.keyStorePassword()).isEqualTo("do-not-use-password");
+    }
+
+    @Test
+    void testKeystoreType()
+    {
+        HttpClientConfig config = new HttpClientConfig.Builder<>().keyStoreType("CUSTOM_TYPE").build();
+        assertThat(config.keyStoreType()).isEqualTo("CUSTOM_TYPE");
+    }
+}
diff --git a/client/src/test/java/org/apache/cassandra/sidecar/client/SidecarConfigTest.java b/client/src/test/java/org/apache/cassandra/sidecar/client/SidecarConfigTest.java
new file mode 100644
index 0000000..c1762f4
--- /dev/null
+++ b/client/src/test/java/org/apache/cassandra/sidecar/client/SidecarConfigTest.java
@@ -0,0 +1,72 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.cassandra.sidecar.client;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Unit tests for {@link SidecarConfig}
+ */
+class SidecarConfigTest
+{
+    @Test
+    void testDefaults()
+    {
+        SidecarConfig config = new SidecarConfig.Builder().build();
+        assertThat(config.maxRetries()).isEqualTo(3);
+        assertThat(config.retryDelayMillis()).isEqualTo(500L);
+        assertThat(config.maxRetryDelayMillis()).isEqualTo(60_000L);
+    }
+
+    @Test
+    void testMaxRetries()
+    {
+        SidecarConfig config = new SidecarConfig.Builder().maxRetries(10).build();
+        assertThat(config.maxRetries()).isEqualTo(10);
+    }
+
+    @Test
+    void testRetryDelayMillis()
+    {
+        SidecarConfig config = new SidecarConfig.Builder().retryDelayMillis(100).build();
+        assertThat(config.retryDelayMillis()).isEqualTo(100L);
+    }
+
+    @Test
+    void testMaxRetryDelayMillis()
+    {
+        SidecarConfig config = new SidecarConfig.Builder().maxRetryDelayMillis(5_100).build();
+        assertThat(config.maxRetryDelayMillis()).isEqualTo(5_100L);
+    }
+
+    @Test
+    void testAllOptions()
+    {
+        SidecarConfig config = new SidecarConfig.Builder().maxRetries(10)
+                                                          .retryDelayMillis(100)
+                                                          .maxRetryDelayMillis(5_100)
+                                                          .build();
+        assertThat(config.maxRetries()).isEqualTo(10);
+        assertThat(config.retryDelayMillis()).isEqualTo(100L);
+        assertThat(config.maxRetryDelayMillis()).isEqualTo(5_100L);
+    }
+}
diff --git a/common/src/main/java/org/apache/cassandra/sidecar/common/MockCassandraFactory.java b/client/src/test/java/org/apache/cassandra/sidecar/client/SidecarInstanceImplTest.java
similarity index 74%
copy from common/src/main/java/org/apache/cassandra/sidecar/common/MockCassandraFactory.java
copy to client/src/test/java/org/apache/cassandra/sidecar/client/SidecarInstanceImplTest.java
index 5e6cd68..8307eba 100644
--- a/common/src/main/java/org/apache/cassandra/sidecar/common/MockCassandraFactory.java
+++ b/client/src/test/java/org/apache/cassandra/sidecar/client/SidecarInstanceImplTest.java
@@ -16,16 +16,16 @@
  * limitations under the License.
  */
 
-package org.apache.cassandra.sidecar.common;
+package org.apache.cassandra.sidecar.client;
 
 /**
- *
+ * Unit tests for the {@link SidecarInstanceImpl} class
  */
-public class MockCassandraFactory implements ICassandraFactory
+class SidecarInstanceImplTest extends SidecarInstanceTest
 {
     @Override
-    public ICassandraAdapter create(CQLSession session, JmxClient jmxClient)
+    protected SidecarInstance newInstance(String hostname, int port)
     {
-        return null;
+        return new SidecarInstanceImpl(hostname, port);
     }
 }
diff --git a/client/src/test/java/org/apache/cassandra/sidecar/client/SidecarInstanceTest.java b/client/src/test/java/org/apache/cassandra/sidecar/client/SidecarInstanceTest.java
new file mode 100644
index 0000000..012dfe3
--- /dev/null
+++ b/client/src/test/java/org/apache/cassandra/sidecar/client/SidecarInstanceTest.java
@@ -0,0 +1,91 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.sidecar.client;
+
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.assertj.core.api.Assertions.assertThatNullPointerException;
+
+/**
+ * Abstract class that provides a set of base unit tests for the {@link SidecarInstance} interface.
+ */
+public abstract class SidecarInstanceTest
+{
+    protected abstract SidecarInstance newInstance(String hostname, int port);
+
+    @ParameterizedTest
+    @ValueSource(ints = { -1, 0, 65536, 100_000 })
+    void failsWithInvalidPortNumber(int port)
+    {
+        assertThatExceptionOfType(IllegalArgumentException.class)
+        .isThrownBy(() -> newInstance(null, port))
+        .withMessageContaining("Invalid port number for the Sidecar service: " + port);
+    }
+
+    @Test
+    void failsWithNullHostname()
+    {
+        assertThatNullPointerException()
+        .isThrownBy(() -> newInstance(null, 8080))
+        .withMessageContaining("The Sidecar hostname must be non-null");
+    }
+
+    @Test
+    void testConstructorWithValidParameters()
+    {
+        SidecarInstance instance1 = newInstance("localhost", 8080);
+        assertThat(instance1).isNotNull();
+        assertThat(instance1.port()).isEqualTo(8080);
+        assertThat(instance1.hostname()).isEqualTo("localhost");
+
+        SidecarInstance instance2 = newInstance("127.0.0.1", 1234);
+        assertThat(instance2).isNotNull();
+        assertThat(instance2.port()).isEqualTo(1234);
+        assertThat(instance2.hostname()).isEqualTo("127.0.0.1");
+    }
+
+    @Test
+    void testEquality()
+    {
+        SidecarInstance instance1 = newInstance("localhost", 8080);
+        SidecarInstance instance2 = newInstance("localhost", 8080);
+        SidecarInstance instance3 = instance1;
+
+        assertThat(instance1).isEqualTo(instance3);
+        assertThat(instance1).isNotEqualTo(null);
+        assertThat(instance1).isNotEqualTo("localhost:8080");
+        assertThat(instance1).isEqualTo(instance2);
+    }
+
+    @Test
+    void testHashCode()
+    {
+        SidecarInstance instance1 = newInstance("localhost", 8080);
+        SidecarInstance instance2 = newInstance("localhost", 8080);
+        SidecarInstance instance3 = newInstance("localhost", 80);
+
+        assertThat(instance1.hashCode()).isEqualTo(instance2.hashCode());
+        assertThat(instance1.hashCode()).isNotEqualTo(instance3.hashCode());
+    }
+}
diff --git a/client/src/test/java/org/apache/cassandra/sidecar/client/SimpleSidecarInstancesProviderTest.java b/client/src/test/java/org/apache/cassandra/sidecar/client/SimpleSidecarInstancesProviderTest.java
new file mode 100644
index 0000000..684da96
--- /dev/null
+++ b/client/src/test/java/org/apache/cassandra/sidecar/client/SimpleSidecarInstancesProviderTest.java
@@ -0,0 +1,67 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.sidecar.client;
+
+import java.util.Collections;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+
+/**
+ * Test for the {@link SimpleSidecarInstancesProvider} class
+ */
+class SimpleSidecarInstancesProviderTest
+{
+    @Test
+    void testConstructionFailsWhenInstancesAreNull()
+    {
+        assertThatIllegalArgumentException()
+        .isThrownBy(() -> new SimpleSidecarInstancesProvider(null))
+        .withMessage("The instances parameter must be non-null and must contain at least one element");
+    }
+
+    @Test
+    void testConstructionFailsWhenInstancesAreEmpty()
+    {
+        assertThatIllegalArgumentException()
+        .isThrownBy(() -> new SimpleSidecarInstancesProvider(Collections.emptyList()))
+        .withMessage("The instances parameter must be non-null and must contain at least one element");
+    }
+
+    @Test
+    void testInstancesAreCopiedDuringConstruction()
+    {
+        List<SidecarInstanceImpl> instances = Collections.singletonList(new SidecarInstanceImpl("localhost", 9043));
+        SidecarInstancesProvider instancesProvider = new SimpleSidecarInstancesProvider(instances);
+        assertThat(instancesProvider.instances()).isNotSameAs(instances);
+    }
+
+    @Test
+    void testProviderInstancesAreImmutable()
+    {
+        List<SidecarInstanceImpl> instances = Collections.singletonList(new SidecarInstanceImpl("localhost", 9043));
+        SidecarInstancesProvider instancesProvider = new SimpleSidecarInstancesProvider(instances);
+        assertThatExceptionOfType(UnsupportedOperationException.class)
+        .isThrownBy(() -> instancesProvider.instances().add(new SidecarInstanceImpl("bad", 80)));
+    }
+}
diff --git a/client/src/test/java/org/apache/cassandra/sidecar/client/StreamBufferTest.java b/client/src/test/java/org/apache/cassandra/sidecar/client/StreamBufferTest.java
new file mode 100644
index 0000000..253fd2d
--- /dev/null
+++ b/client/src/test/java/org/apache/cassandra/sidecar/client/StreamBufferTest.java
@@ -0,0 +1,101 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.cassandra.sidecar.client;
+
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Unit tests for {@link StreamBuffer}
+ */
+class StreamBufferTest
+{
+    static byte[] source;
+    static StreamBuffer.ByteArrayWrapper streamBuffer;
+
+    @BeforeAll
+    static void setup()
+    {
+        source = new byte[1024];
+        source[5] = 'a';
+        streamBuffer = StreamBuffer.wrap(source);
+    }
+
+    @Test
+    void testStreamBuffer()
+    {
+        assertThat(streamBuffer).isNotNull();
+        assertThat(streamBuffer.readableBytes()).isEqualTo(1024);
+        assertThat(streamBuffer.getByte(5)).isEqualTo((byte) 'a');
+        streamBuffer.release(); // do nothing
+    }
+
+    @Test
+    void testCopyToByteArray()
+    {
+        byte[] dst = new byte[1];
+        streamBuffer.copyBytes(5, dst, 0, 1);
+        assertThat(dst[0]).isEqualTo((byte) 'a');
+    }
+
+    @Test
+    void testFullCopyBytesToByteBuffer()
+    {
+        StreamBuffer streamBuffer = StreamBuffer.wrap("Hello World".getBytes(StandardCharsets.UTF_8));
+        ByteBuffer destination = ByteBuffer.allocate(20);
+        streamBuffer.copyBytes(0, destination, 11);
+        assertThat(destination.hasArray()).isTrue();
+        assertThat(destination.position()).isEqualTo(0);
+        assertThat(destination.limit()).isEqualTo(11);
+        byte[] dst = new byte[destination.limit()];
+        destination.get(dst, 0, dst.length);
+        assertThat(new String(dst, StandardCharsets.UTF_8)).isEqualTo("Hello World");
+    }
+
+    @Test
+    void testPartialCopyBytesToByteBuffer()
+    {
+        StreamBuffer streamBuffer = StreamBuffer.wrap("Hello World".getBytes(StandardCharsets.UTF_8));
+        ByteBuffer destination = ByteBuffer.allocate(20);
+        streamBuffer.copyBytes(0, destination, 5);
+        assertThat(destination.hasArray()).isTrue();
+        assertThat(destination.position()).isEqualTo(0);
+        assertThat(destination.limit()).isEqualTo(5);
+        byte[] dst = new byte[destination.limit()];
+        destination.get(dst, 0, dst.length);
+        assertThat(new String(dst, StandardCharsets.UTF_8)).isEqualTo("Hello");
+    }
+
+    @Test
+    void testGetByte()
+    {
+        String inputString = "take me, one at a time";
+        StreamBuffer streamBuffer = StreamBuffer.wrap(inputString.getBytes(StandardCharsets.UTF_8));
+        for (int i = 0; i < inputString.length(); i++)
+        {
+            assertThat(streamBuffer.getByte(i)).isEqualTo((byte) inputString.charAt(i));
+        }
+    }
+}
diff --git a/client/src/test/java/org/apache/cassandra/sidecar/client/retry/BasicRetryPolicyTest.java b/client/src/test/java/org/apache/cassandra/sidecar/client/retry/BasicRetryPolicyTest.java
new file mode 100644
index 0000000..591e364
--- /dev/null
+++ b/client/src/test/java/org/apache/cassandra/sidecar/client/retry/BasicRetryPolicyTest.java
@@ -0,0 +1,321 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.sidecar.client.retry;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.IntStream;
+import java.util.stream.Stream;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import io.netty.handler.codec.http.HttpResponseStatus;
+import org.apache.cassandra.sidecar.client.HttpResponse;
+import org.apache.cassandra.sidecar.client.exception.ResourceNotFoundException;
+import org.apache.cassandra.sidecar.client.exception.RetriesExhaustedException;
+import org.apache.cassandra.sidecar.client.exception.UnexpectedStatusCodeException;
+import org.apache.cassandra.sidecar.client.request.Request;
+
+import static io.netty.handler.codec.http.HttpResponseStatus.ACCEPTED;
+import static io.netty.handler.codec.http.HttpResponseStatus.INTERNAL_SERVER_ERROR;
+import static io.netty.handler.codec.http.HttpResponseStatus.NOT_ACCEPTABLE;
+import static io.netty.handler.codec.http.HttpResponseStatus.NOT_FOUND;
+import static io.netty.handler.codec.http.HttpResponseStatus.NOT_IMPLEMENTED;
+import static io.netty.handler.codec.http.HttpResponseStatus.OK;
+import static io.netty.handler.codec.http.HttpResponseStatus.SERVICE_UNAVAILABLE;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.assertj.core.api.Fail.fail;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * Unit tests for the {@link BasicRetryPolicy}
+ */
+class BasicRetryPolicyTest
+{
+    HttpResponse mockResponse;
+    Request mockRequest;
+    RetryPolicy defaultBasicRetryPolicy;
+    Map<String, List<String>> headersMap;
+
+    @BeforeEach
+    void setup()
+    {
+        defaultBasicRetryPolicy = new BasicRetryPolicy();
+        headersMap = new HashMap<>();
+        mockResponse = mock(HttpResponse.class);
+        mockRequest = mock(Request.class);
+        when(mockResponse.headers()).thenReturn(headersMap);
+        when(mockRequest.requestURI()).thenReturn("/api/uri");
+    }
+
+    @ParameterizedTest(name = "{index} => canRetryOnADifferentHost={0}")
+    @ValueSource(booleans = { true, false })
+    void testRetriesWhenThrowableIsProvided(boolean canRetryOnADifferentHost)
+    {
+        IllegalArgumentException throwable = new IllegalArgumentException("Connection Refused");
+        testWithRetries(mockRequest, null, throwable, 3, 100, canRetryOnADifferentHost);
+    }
+
+    @Test
+    void testRetriesOnInternalServerErrorStatusCode()
+    {
+        when(mockResponse.statusCode()).thenReturn(INTERNAL_SERVER_ERROR.code());
+        testWithRetries(mockRequest, mockResponse, null, 5, 200, false);
+        testWithRetries(mockRequest, mockResponse, null, 5, 200, true);
+    }
+
+    @Test
+    void testCompletesWithOKStatusCode() throws ExecutionException, InterruptedException
+    {
+        when(mockResponse.statusCode()).thenReturn(OK.code());
+
+        CompletableFuture<HttpResponse> future = new CompletableFuture<>();
+        defaultBasicRetryPolicy.onResponse(future, mockRequest, mockResponse, null, 1, false,
+                                           (attempts, retryDelayMillis) -> fail("Should never retry"));
+        future.join();
+        assertThat(future.isDone()).isTrue();
+        assertThat(future.get()).isSameAs(mockResponse);
+    }
+
+    @ParameterizedTest(name = "{index} => canRetryOnADifferentHost={0}")
+    @ValueSource(booleans = { true, false })
+    void testCompletesWithNotFoundStatusCode(boolean canRetryOnADifferentHost)
+    throws ExecutionException, InterruptedException
+    {
+        when(mockResponse.statusCode()).thenReturn(NOT_FOUND.code());
+
+        CompletableFuture<HttpResponse> future = new CompletableFuture<>();
+        if (canRetryOnADifferentHost)
+        {
+            testWithRetries(mockRequest, mockResponse, null, 3, 50, true);
+        }
+        else
+        {
+            defaultBasicRetryPolicy.onResponse(future, mockRequest, mockResponse, null, 1, canRetryOnADifferentHost,
+                                               (attempts, retryDelayMillis) -> fail("Should never retry"));
+            assertThatExceptionOfType(CompletionException.class)
+            .isThrownBy(future::join)
+            .withCauseInstanceOf(ResourceNotFoundException.class);
+        }
+    }
+
+    @ParameterizedTest(name = "{index} => canRetryOnADifferentHost={0}")
+    @ValueSource(booleans = { true, false })
+    void testRetriesWithNotImplementedStatusCode(boolean canRetryOnADifferentHost)
+    {
+        when(mockResponse.statusCode()).thenReturn(NOT_IMPLEMENTED.code());
+
+        CompletableFuture<HttpResponse> future = new CompletableFuture<>();
+        defaultBasicRetryPolicy.onResponse(future, mockRequest, mockResponse, null, 1, canRetryOnADifferentHost,
+                                           (attempts, retryDelayMillis) -> fail("Should never retry"));
+
+        assertThatExceptionOfType(CompletionException.class)
+        .isThrownBy(future::join)
+        .withCauseInstanceOf(UnsupportedOperationException.class);
+        assertThat(future.isCompletedExceptionally()).isTrue();
+    }
+
+    @Test
+    void testRetriesWithNotAcceptableStatusCode()
+    {
+        when(mockResponse.statusCode()).thenReturn(NOT_ACCEPTABLE.code());
+        testWithRetries(mockRequest, mockResponse, null, 8, 50, true);
+    }
+
+    @ParameterizedTest(name = "{index} => canRetryOnADifferentHost={0}")
+    @ValueSource(booleans = { true, false })
+    void testRetriesWithServiceUnavailableStatusCode(boolean canRetryOnADifferentHost)
+    {
+        when(mockResponse.statusCode()).thenReturn(SERVICE_UNAVAILABLE.code());
+        testWithRetries(mockRequest, mockResponse, null, 5, 300, canRetryOnADifferentHost);
+    }
+
+    @Test
+    void testRetriesWithServiceUnavailableStatusCodeWithRetryAfterHeader()
+    {
+        when(mockResponse.statusCode()).thenReturn(SERVICE_UNAVAILABLE.code());
+        headersMap.put("Retry-After", Collections.singletonList("5")); // 5 seconds -> 5,000 millis
+        testWithRetries(mockRequest, mockResponse, null, 5, 1000, 5000, false);
+        testWithRetries(mockRequest, mockResponse, null, 5, 1000, 0, true);
+    }
+
+    @Test
+    void testRetriesWithServiceUnavailableStatusCodeWithInvalidRetryAfterHeader()
+    {
+        when(mockResponse.statusCode()).thenReturn(SERVICE_UNAVAILABLE.code());
+        headersMap.put("Retry-After", Collections.singletonList("one-thousand-seconds"));
+        testWithRetries(mockRequest, mockResponse, null, 5, 200, 200, false);
+        testWithRetries(mockRequest, mockResponse, null, 5, 200, 0, true);
+    }
+
+    @Test
+    void testRetriesWithAcceptedStatusCode() throws ExecutionException, InterruptedException
+    {
+        when(mockResponse.statusCode()).thenReturn(ACCEPTED.code());
+        RetryPolicy retryPolicy = new BasicRetryPolicy(1);
+        CompletableFuture<HttpResponse> future = new CompletableFuture<>();
+        AtomicInteger retryActionCalls = new AtomicInteger(0);
+        int maxRetries = 50;
+        for (int currentAttempt = 1; currentAttempt <= maxRetries; currentAttempt++)
+        {
+            retryPolicy.onResponse(future, mockRequest, mockResponse, null, currentAttempt, true,
+                                   (attempts, retryDelayMillis) -> {
+                                       retryActionCalls.incrementAndGet();
+                                       assertThat(attempts).isEqualTo(1);
+                                       assertThat(retryDelayMillis).isEqualTo(0);
+                                   });
+        }
+
+        when(mockResponse.statusCode()).thenReturn(OK.code());
+
+        retryPolicy.onResponse(future, mockRequest, mockResponse, null, 1, false,
+                               (attempts, retryDelayMillis) -> fail("Should not retry"));
+        future.join();
+        assertThat(future.isDone()).isTrue();
+        assertThat(future.get()).isSameAs(mockResponse);
+        assertThat(retryActionCalls.get()).isEqualTo(maxRetries);
+    }
+
+    private static Stream<Arguments> clientStatusCodeArguments()
+    {
+        return IntStream.range(400, 500)
+                        .filter(statusCode -> statusCode != HttpResponseStatus.NOT_FOUND.code())
+                        .boxed()
+                        .map(Arguments::of);
+    }
+
+    @ParameterizedTest(name = "{index} => statusCode={0}")
+    @MethodSource("clientStatusCodeArguments")
+    void testRetriesWithClientError(int statusCode)
+    {
+        when(mockResponse.statusCode()).thenReturn(statusCode);
+        testWithRetries(mockRequest, mockResponse, null, 2, 200, 200, false, false);
+        testWithRetries(mockRequest, mockResponse, null, 2, 200, 0, true, false);
+    }
+
+    private static Stream<Arguments> serverStatusCodeArguments()
+    {
+        return IntStream.range(500, 600)
+                        .filter(statusCode -> statusCode != INTERNAL_SERVER_ERROR.code()
+                                              && statusCode != NOT_IMPLEMENTED.code()
+                                              && statusCode != SERVICE_UNAVAILABLE.code())
+                        .boxed()
+                        .map(Arguments::of);
+    }
+
+    @ParameterizedTest(name = "{index} => statusCode={0}")
+    @MethodSource("serverStatusCodeArguments")
+    void testRetriesWithServerError(int statusCode)
+    {
+        when(mockResponse.statusCode()).thenReturn(statusCode);
+        testWithRetries(mockRequest, mockResponse, null, 3, 200, 200, false, false);
+        testWithRetries(mockRequest, mockResponse, null, 3, 200, 0, true, false);
+    }
+
+    @Test
+    void testFailsWithUnexpectedStatusCode()
+    {
+        when(mockResponse.statusCode()).thenReturn(700); // 700 is an invalid status code
+
+        CompletableFuture<HttpResponse> future = new CompletableFuture<>();
+        defaultBasicRetryPolicy.onResponse(future, mockRequest, mockResponse, null, 1, false,
+                                           (attempts, retryDelayMillis) -> fail("Should never retry"));
+        assertThatExceptionOfType(CompletionException.class)
+        .isThrownBy(future::join)
+        .withCauseInstanceOf(UnexpectedStatusCodeException.class)
+        .withMessageContaining("Unexpected HTTP status code 700");
+        assertThat(future.isCompletedExceptionally()).isTrue();
+    }
+
+    private static void testWithRetries(Request request, HttpResponse response, Throwable throwable,
+                                        int configuredMaxRetries, int configuredRetryDelayMillis,
+                                        boolean canRetryOnADifferentHost)
+    {
+        testWithRetries(request, response, throwable, configuredMaxRetries, configuredRetryDelayMillis,
+                        canRetryOnADifferentHost ? 0 : configuredRetryDelayMillis, canRetryOnADifferentHost);
+    }
+
+    private static void testWithRetries(Request request, HttpResponse response, Throwable throwable,
+                                        int configuredMaxRetries, int configuredRetryDelayMillis,
+                                        int expectedRetryDelayMillis, boolean canRetryOnADifferentHost)
+    {
+        testWithRetries(request, response, throwable, configuredMaxRetries, configuredRetryDelayMillis,
+                        expectedRetryDelayMillis, canRetryOnADifferentHost, true);
+    }
+
+    private static void testWithRetries(Request request, HttpResponse response, Throwable throwable,
+                                        int configuredMaxRetries, int configuredRetryDelayMillis,
+                                        int expectedRetryDelayMillis, boolean canRetryOnADifferentHost,
+                                        boolean expectedToRetryOnSameHost)
+    {
+        RetryPolicy retryPolicy = new BasicRetryPolicy(configuredMaxRetries, configuredRetryDelayMillis);
+        CompletableFuture<HttpResponse> future = new CompletableFuture<>();
+        for (int currentAttempt = 1; currentAttempt <= configuredMaxRetries; currentAttempt++)
+        {
+            // retries on error
+            int expectedNextAttempt = currentAttempt + 1;
+            retryPolicy.onResponse(future, request, response, throwable, currentAttempt, canRetryOnADifferentHost,
+                                   (attempts, retryDelayMillis) -> {
+                                       if (expectedNextAttempt > configuredMaxRetries)
+                                       {
+                                           fail("Should never retry");
+                                       }
+                                       else
+                                       {
+                                           assertThat(attempts).isEqualTo(expectedNextAttempt);
+                                           assertThat(retryDelayMillis).isEqualTo(expectedRetryDelayMillis);
+                                       }
+                                   });
+
+            if (!canRetryOnADifferentHost && !expectedToRetryOnSameHost)
+            {
+                break;
+            }
+        }
+
+        String description;
+        if (expectedToRetryOnSameHost || canRetryOnADifferentHost)
+        {
+            description = "Unable to complete request '/api/uri' after " + configuredMaxRetries + " attempts";
+        }
+        else
+        {
+            description = "Unable to complete request '/api/uri' after 1 attempt";
+        }
+
+        assertThatExceptionOfType(CompletionException.class)
+        .isThrownBy(future::join)
+        .withCauseInstanceOf(RetriesExhaustedException.class)
+        .withMessageContaining(description);
+        assertThat(future.isCompletedExceptionally()).isTrue();
+    }
+}
diff --git a/client/src/test/java/org/apache/cassandra/sidecar/client/retry/ExponentialBackoffRetryPolicyTest.java b/client/src/test/java/org/apache/cassandra/sidecar/client/retry/ExponentialBackoffRetryPolicyTest.java
new file mode 100644
index 0000000..12e4bff
--- /dev/null
+++ b/client/src/test/java/org/apache/cassandra/sidecar/client/retry/ExponentialBackoffRetryPolicyTest.java
@@ -0,0 +1,144 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.sidecar.client.retry;
+
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.atomic.AtomicLong;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import org.apache.cassandra.sidecar.client.HttpResponse;
+import org.apache.cassandra.sidecar.client.exception.RetriesExhaustedException;
+import org.apache.cassandra.sidecar.client.request.Request;
+
+import static io.netty.handler.codec.http.HttpResponseStatus.INTERNAL_SERVER_ERROR;
+import static io.netty.handler.codec.http.HttpResponseStatus.OK;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.assertj.core.api.Fail.fail;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class ExponentialBackoffRetryPolicyTest
+{
+    Request mockRequest;
+    HttpResponse mockResponse;
+
+    @BeforeEach
+    public void setup()
+    {
+        mockRequest = mock(Request.class);
+        mockResponse = mock(HttpResponse.class);
+        when(mockRequest.requestURI()).thenReturn("/api/uri");
+    }
+
+    @Test
+    void testOverflows()
+    {
+        long delay = new ExponentialBackoffRetryPolicy(1, 1, 0)
+                     .retryDelayMillis(64); // 2 ^ (64 - 1) + 1 overflows
+        assertThat(delay).as("Should not overflow when the exponential grows larger")
+                         .isEqualTo(Long.MAX_VALUE);
+
+        delay = new ExponentialBackoffRetryPolicy(1, 2, 0)
+                .retryDelayMillis(63); // 2 * 2 ^ (63 - 1) + 1 overflows
+        assertThat(delay).isEqualTo(Long.MAX_VALUE);
+
+        delay = new ExponentialBackoffRetryPolicy(1, 200, 0)
+                .retryDelayMillis(56);
+        assertThat(delay).as("If the number of retries is greater than or equals to 56, the value will overflow")
+                         .isEqualTo(Long.MAX_VALUE);
+    }
+
+    @Test
+    void testCompletesWithOKStatusCode() throws ExecutionException, InterruptedException
+    {
+        when(mockResponse.statusCode()).thenReturn(OK.code());
+
+        CompletableFuture<HttpResponse> future = new CompletableFuture<>();
+        new ExponentialBackoffRetryPolicy().onResponse(future, mockRequest, mockResponse, null, 1, false,
+                                                       (attempts, retryDelayMillis) -> fail("Should never retry"));
+        future.join();
+        assertThat(future.isDone()).isTrue();
+        assertThat(future.get()).isSameAs(mockResponse);
+    }
+
+    @ParameterizedTest(name = "{index} => canRetryOnADifferentHost={0}")
+    @ValueSource(booleans = { true, false })
+    void testRetriesWhenThrowableIsProvided(boolean canRetryOnADifferentHost)
+    {
+        IllegalArgumentException throwable = new IllegalArgumentException("Connection Refused");
+        testWithRetries(mockRequest, null, throwable, 10, 100,
+                        canRetryOnADifferentHost ? 0 : 100, 1_000, canRetryOnADifferentHost);
+    }
+
+    @Test
+    void testExponentialBackoffOnServerError()
+    {
+        when(mockResponse.statusCode()).thenReturn(INTERNAL_SERVER_ERROR.code());
+        testWithRetries(mockRequest, mockResponse, null, 5, 200, 200, 10_000, false);
+        // should not exceed the configuredMaxRetryDelayMillis
+        testWithRetries(mockRequest, mockResponse, null, 30, 200, 200, 10_000, false);
+        testWithRetries(mockRequest, mockResponse, null, 10, 150, 0, 1_000, true);
+        // no maximum configured for max retry delay millis
+        testWithRetries(mockRequest, mockResponse, null, 30, 200, 200, -1, false);
+    }
+
+    private static void testWithRetries(Request request, HttpResponse response, Throwable throwable,
+                                        int configuredMaxRetries, int configuredRetryDelayMillis,
+                                        int originalExpectedRetryDelayMillis,
+                                        int configuredMaxRetryDelayMillis, boolean canRetryOnADifferentHost)
+    {
+        RetryPolicy retryPolicy = new ExponentialBackoffRetryPolicy(configuredMaxRetries, configuredRetryDelayMillis,
+                                                                    configuredMaxRetryDelayMillis);
+        CompletableFuture<HttpResponse> future = new CompletableFuture<>();
+        AtomicLong expectedRetryDelayMillis = new AtomicLong(originalExpectedRetryDelayMillis);
+        for (int currentAttempt = 1; currentAttempt <= configuredMaxRetries; currentAttempt++)
+        {
+            // retries on error
+            int expectedNextAttempt = currentAttempt + 1;
+            retryPolicy.onResponse(future, request, response, throwable, currentAttempt, canRetryOnADifferentHost,
+                                   (attempts, retryDelayMillis) -> {
+                                       if (expectedNextAttempt > configuredMaxRetries)
+                                       {
+                                           fail("Should never retry");
+                                       }
+                                       else
+                                       {
+                                           assertThat(attempts).isEqualTo(expectedNextAttempt);
+                                           assertThat(retryDelayMillis).isEqualTo(expectedRetryDelayMillis.get());
+                                       }
+                                   });
+            expectedRetryDelayMillis.updateAndGet(current -> configuredMaxRetryDelayMillis > 0
+                                                             ? Math.min(configuredMaxRetryDelayMillis, current * 2)
+                                                             : current * 2);
+        }
+
+        assertThatExceptionOfType(CompletionException.class)
+        .isThrownBy(future::join)
+        .withCauseInstanceOf(RetriesExhaustedException.class)
+        .withMessageContaining("Unable to complete request '/api/uri' after " + configuredMaxRetries + " attempts");
+        assertThat(future.isCompletedExceptionally()).isTrue();
+    }
+}
diff --git a/client/src/test/java/org/apache/cassandra/sidecar/client/retry/IgnoreConflictRetryPolicyTest.java b/client/src/test/java/org/apache/cassandra/sidecar/client/retry/IgnoreConflictRetryPolicyTest.java
new file mode 100644
index 0000000..50623a5
--- /dev/null
+++ b/client/src/test/java/org/apache/cassandra/sidecar/client/retry/IgnoreConflictRetryPolicyTest.java
@@ -0,0 +1,112 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.sidecar.client.retry;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
+import java.util.concurrent.ExecutionException;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import org.apache.cassandra.sidecar.client.HttpResponse;
+import org.apache.cassandra.sidecar.client.exception.RetriesExhaustedException;
+import org.apache.cassandra.sidecar.client.request.Request;
+
+import static io.netty.handler.codec.http.HttpResponseStatus.CONFLICT;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.assertj.core.api.Fail.fail;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * Unit tests for {@link IgnoreConflictRetryPolicy}
+ */
+class IgnoreConflictRetryPolicyTest
+{
+    Request mockRequest;
+    HttpResponse mockResponse;
+    Map<String, List<String>> headersMap;
+
+    @BeforeEach
+    void setup()
+    {
+        headersMap = new HashMap<>();
+        mockRequest = mock(Request.class);
+        mockResponse = mock(HttpResponse.class);
+        when(mockResponse.headers()).thenReturn(headersMap);
+    }
+
+    @ParameterizedTest(name = "{index} => canRetryOnADifferentHost={0}")
+    @ValueSource(booleans = { true, false })
+    void testRetryPolicyWhenResponseIsNull(boolean canRetryOnADifferentHost)
+    {
+        int maxRetries = 2;
+
+        CompletableFuture<HttpResponse> future = new CompletableFuture<>();
+        IgnoreConflictRetryPolicy retryPolicy = new IgnoreConflictRetryPolicy(maxRetries, 100, 100);
+
+        for (int currentAttempt = 1; currentAttempt <= maxRetries; currentAttempt++)
+        {
+            int expectedNextAttempt = currentAttempt + 1;
+            retryPolicy.onResponse(future, mockRequest, null, new RuntimeException(), currentAttempt,
+                                   canRetryOnADifferentHost, (attempts, retryDelayMillis) -> {
+                if (expectedNextAttempt > maxRetries)
+                {
+                    fail("Should not retry");
+                }
+                else
+                {
+                    assertThat(attempts).isEqualTo(expectedNextAttempt);
+                    if (canRetryOnADifferentHost)
+                    {
+                        assertThat(retryDelayMillis).isEqualTo(0);
+                    }
+                    else
+                    {
+                        assertThat(retryDelayMillis).isEqualTo(100);
+                    }
+                }
+            });
+        }
+        assertThatExceptionOfType(CompletionException.class)
+        .isThrownBy(future::join)
+        .withCauseInstanceOf(RetriesExhaustedException.class);
+        assertThat(future.isCompletedExceptionally()).isTrue();
+    }
+
+    @Test
+    void testCompletesWithConflictStatusCode() throws ExecutionException, InterruptedException
+    {
+        when(mockResponse.statusCode()).thenReturn(CONFLICT.code());
+
+        CompletableFuture<HttpResponse> future = new CompletableFuture<>();
+        new IgnoreConflictRetryPolicy().onResponse(future, mockRequest, mockResponse, null, 1, false,
+                                                   (attempts, retryDelayMillis) -> fail("Should never retry"));
+        future.join();
+        assertThat(future.isDone()).isTrue();
+        assertThat(future.get()).isSameAs(mockResponse);
+    }
+}
diff --git a/client/src/test/java/org/apache/cassandra/sidecar/client/retry/NoRetryPolicyTest.java b/client/src/test/java/org/apache/cassandra/sidecar/client/retry/NoRetryPolicyTest.java
new file mode 100644
index 0000000..45e17b5
--- /dev/null
+++ b/client/src/test/java/org/apache/cassandra/sidecar/client/retry/NoRetryPolicyTest.java
@@ -0,0 +1,102 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.sidecar.client.retry;
+
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
+import java.util.concurrent.ExecutionException;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import org.apache.cassandra.sidecar.client.HttpResponse;
+import org.apache.cassandra.sidecar.client.exception.RetriesExhaustedException;
+import org.apache.cassandra.sidecar.client.request.Request;
+
+import static io.netty.handler.codec.http.HttpResponseStatus.INTERNAL_SERVER_ERROR;
+import static io.netty.handler.codec.http.HttpResponseStatus.OK;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.assertj.core.api.Fail.fail;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * Unit tests for the {@link NoRetryPolicy} class
+ */
+class NoRetryPolicyTest
+{
+    Request mockRequest;
+    HttpResponse mockResponse;
+    RetryPolicy retryPolicy;
+
+    @BeforeEach
+    public void setup()
+    {
+        mockRequest = mock(Request.class);
+        mockResponse = mock(HttpResponse.class);
+        retryPolicy = new NoRetryPolicy();
+        when(mockRequest.requestURI()).thenReturn("/api/uri");
+    }
+
+    @Test
+    void testStatusCode200() throws ExecutionException, InterruptedException
+    {
+        when(mockResponse.statusCode()).thenReturn(OK.code());
+
+        CompletableFuture<HttpResponse> future = new CompletableFuture<>();
+        retryPolicy.onResponse(future, mockRequest, mockResponse, null, 1, false,
+                               (attempts, retryDelayMillis) -> fail("Should never retry"));
+        future.join();
+        assertThat(future.isDone()).isTrue();
+        assertThat(future.get()).isSameAs(mockResponse);
+    }
+
+    @Test
+    void testFailsWhenThrowableIsProvided()
+    {
+        when(mockResponse.statusCode()).thenReturn(OK.code()); // status code is irrelevant
+
+        CompletableFuture<HttpResponse> future = new CompletableFuture<>();
+        retryPolicy.onResponse(future, mockRequest, mockResponse, new IllegalArgumentException("this fails"), 1, false,
+                               (attempts, retryDelayMillis) -> fail("Should never retry"));
+
+        assertThatExceptionOfType(CompletionException.class)
+        .isThrownBy(future::join)
+        .withCauseInstanceOf(RetriesExhaustedException.class)
+        .withMessageContaining("Unable to complete request '/api/uri' after 1 attempt");
+        assertThat(future.isCompletedExceptionally()).isTrue();
+    }
+
+    @Test
+    void testFailsWithNon200StatusCode()
+    {
+        when(mockResponse.statusCode()).thenReturn(INTERNAL_SERVER_ERROR.code());
+
+        CompletableFuture<HttpResponse> future = new CompletableFuture<>();
+        retryPolicy.onResponse(future, mockRequest, mockResponse, null, 1, false,
+                               (attempts, retryDelayMillis) -> fail("Should never retry"));
+
+        assertThatExceptionOfType(CompletionException.class)
+        .isThrownBy(future::join)
+        .withCauseInstanceOf(RetriesExhaustedException.class)
+        .withMessageContaining("Unable to complete request '/api/uri' after 1 attempt");
+        assertThat(future.isCompletedExceptionally()).isTrue();
+    }
+}
diff --git a/client/src/test/java/org/apache/cassandra/sidecar/client/retry/RunnableOnStatusCodeRetryPolicyTest.java b/client/src/test/java/org/apache/cassandra/sidecar/client/retry/RunnableOnStatusCodeRetryPolicyTest.java
new file mode 100644
index 0000000..cfab6ae
--- /dev/null
+++ b/client/src/test/java/org/apache/cassandra/sidecar/client/retry/RunnableOnStatusCodeRetryPolicyTest.java
@@ -0,0 +1,97 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.sidecar.client.retry;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import io.netty.handler.codec.http.HttpResponseStatus;
+import org.apache.cassandra.sidecar.client.HttpResponse;
+import org.apache.cassandra.sidecar.client.request.Request;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Fail.fail;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * Unit tests for the {@link RunnableOnStatusCodeRetryPolicy} class
+ */
+class RunnableOnStatusCodeRetryPolicyTest
+{
+    Request mockRequest;
+    HttpResponse mockResponse;
+    Map<String, List<String>> headersMap;
+
+    @BeforeEach
+    void setup()
+    {
+        headersMap = new HashMap<>();
+        mockRequest = mock(Request.class);
+        mockResponse = mock(HttpResponse.class);
+        when(mockResponse.headers()).thenReturn(headersMap);
+    }
+
+    @Test
+    void testExecutesEveryTwoTimes()
+    {
+        when(mockResponse.statusCode()).thenReturn(HttpResponseStatus.OK.code());
+        AtomicInteger count = new AtomicInteger();
+        Runnable incrementEveryTwoTimes = count::incrementAndGet;
+
+        RetryPolicy retryPolicy = new RunnableOnStatusCodeRetryPolicy(incrementEveryTwoTimes,
+                                                                      new BasicRetryPolicy(),
+                                                                      HttpResponseStatus.OK.code(),
+                                                                      2);
+
+        CompletableFuture<HttpResponse> future = new CompletableFuture<>();
+        for (int i = 0; i < 19; i++)
+        {
+            retryPolicy.onResponse(future, mockRequest, mockResponse, null, 1, false,
+                                   (attempts, retryDelayMillis) -> fail("Should never retry"));
+        }
+        assertThat(count.get()).isEqualTo(10);
+    }
+
+    @Test
+    void testExecutesEveryTenTimes()
+    {
+        when(mockResponse.statusCode()).thenReturn(HttpResponseStatus.OK.code());
+        AtomicInteger count = new AtomicInteger();
+        Runnable incrementEveryTenTimes = count::incrementAndGet;
+
+        RetryPolicy retryPolicy = new RunnableOnStatusCodeRetryPolicy(incrementEveryTenTimes,
+                                                                      new BasicRetryPolicy(),
+                                                                      HttpResponseStatus.OK.code());
+
+        CompletableFuture<HttpResponse> future = new CompletableFuture<>();
+        for (int i = 0; i < 20; i++)
+        {
+            retryPolicy.onResponse(future, mockRequest, mockResponse, null, 1, false,
+                                   (attempts, retryDelayMillis) -> fail("Should never retry"));
+        }
+        assertThat(count.get()).isEqualTo(2);
+    }
+}
diff --git a/client/src/test/java/org/apache/cassandra/sidecar/client/selection/OrderedInstanceSelectionPolicyTest.java b/client/src/test/java/org/apache/cassandra/sidecar/client/selection/OrderedInstanceSelectionPolicyTest.java
new file mode 100644
index 0000000..88d377a
--- /dev/null
+++ b/client/src/test/java/org/apache/cassandra/sidecar/client/selection/OrderedInstanceSelectionPolicyTest.java
@@ -0,0 +1,79 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.sidecar.client.selection;
+
+import java.util.Arrays;
+import java.util.Iterator;
+import java.util.List;
+import java.util.NoSuchElementException;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import org.apache.cassandra.sidecar.client.SidecarInstance;
+import org.apache.cassandra.sidecar.client.SidecarInstancesProvider;
+import org.apache.cassandra.sidecar.client.SimpleSidecarInstancesProvider;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.mockito.Mockito.mock;
+
+/**
+ * Unit tests for the {@link OrderedInstanceSelectionPolicy}
+ */
+class OrderedInstanceSelectionPolicyTest
+{
+    List<SidecarInstance> mockInstanceList;
+    SidecarInstance mockInstance1;
+    SidecarInstance mockInstance2;
+    SidecarInstance mockInstance3;
+    SidecarInstance mockInstance4;
+
+    @BeforeEach
+    void setup()
+    {
+        mockInstance1 = mock(SidecarInstance.class);
+        mockInstance2 = mock(SidecarInstance.class);
+        mockInstance3 = mock(SidecarInstance.class);
+        mockInstance4 = mock(SidecarInstance.class);
+        mockInstanceList = Arrays.asList(mockInstance1, mockInstance2, mockInstance3, mockInstance4);
+    }
+
+    @Test
+    public void testIterator()
+    {
+
+        SidecarInstancesProvider provider = new SimpleSidecarInstancesProvider(mockInstanceList);
+        InstanceSelectionPolicy instanceSelectionPolicy = new OrderedInstanceSelectionPolicy(provider);
+        Iterator<SidecarInstance> iterator = instanceSelectionPolicy.iterator();
+
+        assertThat(iterator.hasNext()).isTrue().as("Expected to be true");
+        assertThat(iterator.hasNext()).isTrue().as("Test idempotency of hasNext by running it again");
+        assertThat(iterator.next()).isSameAs(mockInstance1);
+        assertThat(iterator.hasNext()).isTrue();
+        assertThat(iterator.next()).isSameAs(mockInstance2);
+        assertThat(iterator.hasNext()).isTrue();
+        assertThat(iterator.next()).isSameAs(mockInstance3);
+        assertThat(iterator.hasNext()).isTrue();
+        assertThat(iterator.next()).isSameAs(mockInstance4);
+        assertThat(iterator.hasNext()).isFalse().as("Expected to be false");
+        assertThat(iterator.hasNext()).isFalse().as("Test idempotency of hasNext by running it again");
+        assertThatExceptionOfType(NoSuchElementException.class).isThrownBy(iterator::next);
+    }
+}
diff --git a/client/src/test/java/org/apache/cassandra/sidecar/client/selection/RandomInstanceSelectionPolicyTest.java b/client/src/test/java/org/apache/cassandra/sidecar/client/selection/RandomInstanceSelectionPolicyTest.java
new file mode 100644
index 0000000..20374d0
--- /dev/null
+++ b/client/src/test/java/org/apache/cassandra/sidecar/client/selection/RandomInstanceSelectionPolicyTest.java
@@ -0,0 +1,72 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.sidecar.client.selection;
+
+import java.util.Arrays;
+import java.util.Iterator;
+import java.util.List;
+import java.util.NoSuchElementException;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import org.apache.cassandra.sidecar.client.SidecarInstance;
+import org.apache.cassandra.sidecar.client.SidecarInstancesProvider;
+import org.apache.cassandra.sidecar.client.SimpleSidecarInstancesProvider;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.mockito.Mockito.mock;
+
+/**
+ * Unit tests for the {@link RandomInstanceSelectionPolicy}
+ */
+class RandomInstanceSelectionPolicyTest
+{
+    List<SidecarInstance> mockInstanceList;
+
+    @BeforeEach
+    void setup()
+    {
+        mockInstanceList = Arrays.asList(mock(SidecarInstance.class), mock(SidecarInstance.class),
+                                         mock(SidecarInstance.class), mock(SidecarInstance.class)
+        );
+    }
+
+    @Test
+    public void testIterator()
+    {
+        SidecarInstancesProvider provider = new SimpleSidecarInstancesProvider(mockInstanceList);
+        InstanceSelectionPolicy instanceSelectionPolicy = new RandomInstanceSelectionPolicy(provider);
+        Iterator<SidecarInstance> iterator = instanceSelectionPolicy.iterator();
+
+        assertThat(iterator.hasNext()).isTrue().as("Expected to be true");
+        assertThat(iterator.hasNext()).isTrue().as("Test idempotency of hasNext by running it again");
+        assertThat(iterator.next()).isIn(mockInstanceList);
+        assertThat(iterator.hasNext()).isTrue();
+        assertThat(iterator.next()).isIn(mockInstanceList);
+        assertThat(iterator.hasNext()).isTrue();
+        assertThat(iterator.next()).isIn(mockInstanceList);
+        assertThat(iterator.hasNext()).isTrue();
+        assertThat(iterator.next()).isIn(mockInstanceList);
+        assertThat(iterator.hasNext()).isFalse().as("Expected to be false");
+        assertThat(iterator.hasNext()).isFalse().as("Test idempotency of hasNext by running it again");;
+        assertThatExceptionOfType(NoSuchElementException.class).isThrownBy(iterator::next);
+    }
+}
diff --git a/client/src/test/java/org/apache/cassandra/sidecar/client/selection/SingleInstanceSelectionPolicyTest.java b/client/src/test/java/org/apache/cassandra/sidecar/client/selection/SingleInstanceSelectionPolicyTest.java
new file mode 100644
index 0000000..19fd528
--- /dev/null
+++ b/client/src/test/java/org/apache/cassandra/sidecar/client/selection/SingleInstanceSelectionPolicyTest.java
@@ -0,0 +1,56 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.sidecar.client.selection;
+
+import java.util.Iterator;
+import java.util.NoSuchElementException;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import org.apache.cassandra.sidecar.client.SidecarInstance;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.mockito.Mockito.mock;
+
+class SingleInstanceSelectionPolicyTest
+{
+    SidecarInstance mockSidecarInstance;
+
+    @BeforeEach
+    void setup()
+    {
+        mockSidecarInstance = mock(SidecarInstance.class);
+    }
+
+    @Test
+    void testIterator()
+    {
+        InstanceSelectionPolicy instanceSelectionPolicy = new SingleInstanceSelectionPolicy(mockSidecarInstance);
+        Iterator<SidecarInstance> iterator = instanceSelectionPolicy.iterator();
+
+        assertThat(iterator.hasNext()).isTrue().as("Expected to be true");
+        assertThat(iterator.hasNext()).isTrue().as("Test idempotency of hasNext by running it again");
+        assertThat(iterator.next()).isSameAs(mockSidecarInstance);
+        assertThat(iterator.hasNext()).isFalse().as("Expected to be false");
+        assertThat(iterator.hasNext()).isFalse().as("Test idempotency of hasNext by running it again");
+        assertThatExceptionOfType(NoSuchElementException.class).isThrownBy(iterator::next);
+    }
+}
diff --git a/client/src/testFixtures/java/org/apache/cassandra/sidecar/client/SidecarClientTest.java b/client/src/testFixtures/java/org/apache/cassandra/sidecar/client/SidecarClientTest.java
new file mode 100644
index 0000000..77bc69a
--- /dev/null
+++ b/client/src/testFixtures/java/org/apache/cassandra/sidecar/client/SidecarClientTest.java
@@ -0,0 +1,957 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.sidecar.client;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.stream.Collectors;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import io.netty.handler.codec.http.HttpHeaderNames;
+import io.netty.handler.codec.http.HttpHeaderValues;
+import okhttp3.mockwebserver.MockResponse;
+import okhttp3.mockwebserver.MockWebServer;
+import okhttp3.mockwebserver.RecordedRequest;
+import okhttp3.mockwebserver.SocketPolicy;
+import okio.Buffer;
+import okio.Okio;
+import org.apache.cassandra.sidecar.client.exception.RetriesExhaustedException;
+import org.apache.cassandra.sidecar.client.request.ImportSSTableRequest;
+import org.apache.cassandra.sidecar.client.request.NodeSettingsRequest;
+import org.apache.cassandra.sidecar.client.request.Request;
+import org.apache.cassandra.sidecar.client.request.RequestExecutorTest;
+import org.apache.cassandra.sidecar.client.retry.RetryAction;
+import org.apache.cassandra.sidecar.client.retry.RetryPolicy;
+import org.apache.cassandra.sidecar.common.ApiEndpointsV1;
+import org.apache.cassandra.sidecar.common.NodeSettings;
+import org.apache.cassandra.sidecar.common.data.GossipInfoResponse;
+import org.apache.cassandra.sidecar.common.data.ListSnapshotFilesResponse;
+import org.apache.cassandra.sidecar.common.data.RingEntry;
+import org.apache.cassandra.sidecar.common.data.RingResponse;
+import org.apache.cassandra.sidecar.common.data.SSTableImportResponse;
+import org.apache.cassandra.sidecar.common.data.SchemaResponse;
+import org.apache.cassandra.sidecar.common.data.TimeSkewResponse;
+import org.apache.cassandra.sidecar.common.utils.HttpRange;
+
+import static io.netty.handler.codec.http.HttpResponseStatus.ACCEPTED;
+import static io.netty.handler.codec.http.HttpResponseStatus.INTERNAL_SERVER_ERROR;
+import static io.netty.handler.codec.http.HttpResponseStatus.OK;
+import static io.netty.handler.codec.http.HttpResponseStatus.PARTIAL_CONTENT;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+import static org.assertj.core.api.Assertions.fail;
+
+abstract class SidecarClientTest
+{
+    SidecarClient client;
+    List<MockWebServer> servers;
+    List<SidecarInstanceImpl> instances;
+
+    @BeforeEach
+    void setup()
+    {
+        servers = new ArrayList<>();
+        for (int i = 0; i < 4; i++)
+        {
+            servers.add(new MockWebServer());
+        }
+
+        instances = servers.stream()
+                           .map(RequestExecutorTest::newSidecarInstance)
+                           .collect(Collectors.toList());
+        client = initialize(instances);
+    }
+
+    protected abstract SidecarClient initialize(List<SidecarInstanceImpl> instances);
+
+    @AfterEach
+    void tearDown() throws Exception
+    {
+        for (MockWebServer server : servers)
+        {
+            server.shutdown();
+        }
+        client.close();
+    }
+
+    @Test
+    void testFullSchema() throws Exception
+    {
+        String fullSchemaAsString = "{\"schema\":\"CREATE KEYSPACE sample_ks.sample_table ...\"}";
+        MockResponse response = new MockResponse().setResponseCode(OK.code()).setBody(fullSchemaAsString);
+        enqueue(response);
+
+        SchemaResponse result = client.fullSchema().get(30, TimeUnit.SECONDS);
+        assertThat(result).isNotNull();
+        assertThat(result.keyspace()).isNull();
+        assertThat(result.schema()).isEqualTo("CREATE KEYSPACE sample_ks.sample_table ...");
+
+        validateResponseServed(ApiEndpointsV1.ALL_KEYSPACES_SCHEMA_ROUTE);
+    }
+
+    @Test
+    void testSchema() throws Exception
+    {
+        String schemaAsString = "{\"keyspace\":\"cycling\",\"schema\":\"CREATE KEYSPACE sample_ks.sample_table ...\"}";
+        MockResponse response = new MockResponse().setResponseCode(OK.code()).setBody(schemaAsString);
+        enqueue(response);
+
+        SchemaResponse result = client.schema("cycling").get(30, TimeUnit.SECONDS);
+        assertThat(result).isNotNull();
+        assertThat(result.keyspace()).isEqualTo("cycling");
+        assertThat(result.schema()).isEqualTo("CREATE KEYSPACE sample_ks.sample_table ...");
+
+        validateResponseServed(ApiEndpointsV1.KEYSPACE_SCHEMA_ROUTE.replaceAll(ApiEndpointsV1.KEYSPACE_PATH_PARAM,
+                                                                               "cycling"));
+    }
+
+    @Test
+    void testRing() throws Exception
+    {
+        String schemaAsString = "[{\"datacenter\":\"dc\",\"address\":\"127.0.0.1\",\"port\":80,\"rack\":\"r1\"," +
+                                "\"status\":\"up\",\"state\":\"normal\",\"load\":\"1 KiB\",\"owns\":\"1%\"," +
+                                "\"token\":\"100\",\"fqdn\":\"local\",\"hostId\":\"000\"}]";
+        MockResponse response = new MockResponse().setResponseCode(OK.code()).setBody(schemaAsString);
+        enqueue(response);
+
+        RingResponse result = client.ring("cycling").get(30, TimeUnit.SECONDS);
+        assertThat(result).isNotNull()
+                          .hasSize(1);
+        RingEntry entry = result.iterator().next();
+        assertThat(entry.datacenter()).isEqualTo("dc");
+        assertThat(entry.address()).isEqualTo("127.0.0.1");
+        assertThat(entry.port()).isEqualTo(80);
+        assertThat(entry.rack()).isEqualTo("r1");
+        assertThat(entry.status()).isEqualTo("up");
+        assertThat(entry.state()).isEqualTo("normal");
+        assertThat(entry.load()).isEqualTo("1 KiB");
+        assertThat(entry.owns()).isEqualTo("1%");
+        assertThat(entry.token()).isEqualTo("100");
+        assertThat(entry.fqdn()).isEqualTo("local");
+        assertThat(entry.hostId()).isEqualTo("000");
+
+        validateResponseServed(ApiEndpointsV1.RING_ROUTE_PER_KEYSPACE.replaceAll(ApiEndpointsV1.KEYSPACE_PATH_PARAM,
+                                                                                 "cycling"));
+    }
+
+    @Test
+    public void testNodeSettings() throws Exception
+    {
+        String nodeSettingsAsString = "{\"partitioner\":\"test-partitioner\", \"releaseVersion\": \"4.0.0\"}";
+        MockResponse response = new MockResponse().setResponseCode(OK.code()).setBody(nodeSettingsAsString);
+        enqueue(response);
+
+        NodeSettings result = client.nodeSettings().get(30, TimeUnit.SECONDS);
+        assertThat(result).isNotNull();
+        assertThat(result.partitioner()).isEqualTo("test-partitioner");
+        assertThat(result.releaseVersion()).isEqualTo("4.0.0");
+
+        validateResponseServed(ApiEndpointsV1.NODE_SETTINGS_ROUTE);
+    }
+
+    @Test
+    public void testNodeSettingsFromSpecifiedInstance() throws Exception
+    {
+        String nodeSettingsAsString = "{\"partitioner\":\"test-partitioner\", \"releaseVersion\": \"4.0.0\"}";
+        MockResponse response = new MockResponse().setResponseCode(OK.code()).setBody(nodeSettingsAsString);
+        MockWebServer mockWebServer = servers.get(1);
+        mockWebServer.enqueue(response);
+
+        SidecarInstanceImpl instance = new SidecarInstanceImpl(mockWebServer.getHostName(), mockWebServer.getPort());
+        NodeSettings result = client.nodeSettings(instance).get(30, TimeUnit.SECONDS);
+        assertThat(result).isNotNull();
+        assertThat(result.partitioner()).isEqualTo("test-partitioner");
+        assertThat(result.releaseVersion()).isEqualTo("4.0.0");
+
+        assertThat(mockWebServer.getRequestCount()).isEqualTo(1);
+        RecordedRequest request = mockWebServer.takeRequest();
+        assertThat(request.getPath()).isEqualTo(ApiEndpointsV1.NODE_SETTINGS_ROUTE);
+    }
+
+    @Test
+    public void testGossipInfo() throws Exception
+    {
+        String gossipInfoAsString = "{\"/127.0.0.1:7000\":{\"generation\":\"1\",\"schema\":\"4994b214\"," +
+                                    "\"rack\":\"r2\",\"heartbeat\":\"214\",\"releaseVersion\":\"4.0.7\"," +
+                                    "\"sstableVersions\":\"big-nb\"}}";
+        MockResponse response = new MockResponse().setResponseCode(OK.code()).setBody(gossipInfoAsString);
+        enqueue(response);
+
+        GossipInfoResponse result = client.gossipInfo().get(30, TimeUnit.SECONDS);
+        assertThat(result).isNotNull().hasSize(1);
+        String key = result.entrySet().iterator().next().getKey();
+        GossipInfoResponse.GossipInfo gossipInfo = result.get(key);
+        assertThat(gossipInfo.generation()).isEqualTo("1");
+        assertThat(gossipInfo.schema()).isEqualTo("4994b214");
+        assertThat(gossipInfo.rack()).isEqualTo("r2");
+        assertThat(gossipInfo.heartbeat()).isEqualTo("214");
+        assertThat(gossipInfo.releaseVersion()).isEqualTo("4.0.7");
+        assertThat(gossipInfo.sstableVersions()).isEqualTo(Collections.singletonList("big-nb"));
+
+        validateResponseServed(ApiEndpointsV1.GOSSIP_INFO_ROUTE);
+    }
+
+    @Test
+    public void testTimeSkew() throws Exception
+    {
+        String timeSkewAsString = "{\"currentTime\":\"123456789\",\"allowableSkewInMinutes\":\"122\"}}";
+        MockResponse response = new MockResponse().setResponseCode(OK.code()).setBody(timeSkewAsString);
+        enqueue(response);
+
+        TimeSkewResponse result = client.timeSkew().get(30, TimeUnit.SECONDS);
+        assertThat(result).isNotNull();
+        assertThat(result.allowableSkewInMinutes).isEqualTo(122);
+        assertThat(result.currentTime).isEqualTo(123456789);
+
+        validateResponseServed(ApiEndpointsV1.TIME_SKEW_ROUTE);
+    }
+
+    @Test
+    public void testTimeSkewFromReplicaSet() throws Exception
+    {
+        String timeSkewAsString = "{\"currentTime\":\"5555555\",\"allowableSkewInMinutes\":\"24\"}}";
+        MockResponse response = new MockResponse().setResponseCode(OK.code()).setBody(timeSkewAsString);
+        enqueue(response);
+
+        TimeSkewResponse result = client.timeSkew(instances.subList(1, 2)).get(30, TimeUnit.SECONDS);
+        assertThat(result).isNotNull();
+        assertThat(result.allowableSkewInMinutes).isEqualTo(24);
+        assertThat(result.currentTime).isEqualTo(5555555);
+
+        validateResponseServed(ApiEndpointsV1.TIME_SKEW_ROUTE);
+    }
+
+    @Test
+    public void testListSnapshotFiles() throws Exception
+    {
+        String responseAsString = "{\"snapshotFilesInfo\":[{\"size\":15,\"host\":\"localhost1\",\"port\":2020," +
+                                  "\"dataDirIndex\":1,\"snapshotName\":\"2023.04.11\",\"keySpaceName\":\"cycling\"," +
+                                  "\"tableName\":\"cyclist_name\",\"fileName\":\"nb-203-big-TOC.txt\"}]}";
+        MockResponse response = new MockResponse().setResponseCode(OK.code()).setBody(responseAsString);
+        SidecarInstanceImpl sidecarInstance = instances.get(2);
+        MockWebServer mockWebServer = servers.get(2);
+        mockWebServer.enqueue(response);
+
+        ListSnapshotFilesResponse result = client.listSnapshotFiles(sidecarInstance,
+                                                                    "cycling",
+                                                                    "cyclist_name",
+                                                                    "2023.04.11")
+                                                 .get(30, TimeUnit.SECONDS);
+        assertThat(result).isNotNull();
+        assertThat(result.snapshotFilesInfo()).hasSize(1);
+        ListSnapshotFilesResponse.FileInfo fileInfo = result.snapshotFilesInfo().get(0);
+        assertThat(fileInfo.size).isEqualTo(15);
+        assertThat(fileInfo.host).isEqualTo("localhost1");
+        assertThat(fileInfo.port).isEqualTo(2020);
+        assertThat(fileInfo.dataDirIndex).isEqualTo(1);
+        assertThat(fileInfo.snapshotName).isEqualTo("2023.04.11");
+        assertThat(fileInfo.keySpaceName).isEqualTo("cycling");
+        assertThat(fileInfo.tableName).isEqualTo("cyclist_name");
+        assertThat(fileInfo.fileName).isEqualTo("nb-203-big-TOC.txt");
+
+        assertThat(mockWebServer.getRequestCount()).isEqualTo(1);
+        RecordedRequest request = mockWebServer.takeRequest();
+        assertThat(request.getPath()).isEqualTo(ApiEndpointsV1.SNAPSHOTS_ROUTE
+                                                .replaceAll(ApiEndpointsV1.KEYSPACE_PATH_PARAM, "cycling")
+                                                .replaceAll(ApiEndpointsV1.TABLE_PATH_PARAM, "cyclist_name")
+                                                .replaceAll(ApiEndpointsV1.SNAPSHOT_PATH_PARAM, "2023.04.11")
+                                                + "?includeSecondaryIndexFiles=true");
+    }
+
+    @Test
+    public void testListSnapshotFilesWithoutSecondaryIndexFiles() throws Exception
+    {
+        String responseAsString = "{\"snapshotFilesInfo\":[{\"size\":15,\"host\":\"localhost1\",\"port\":2020," +
+                                  "\"dataDirIndex\":1,\"snapshotName\":\"2023.04.11\",\"keySpaceName\":\"cycling\"," +
+                                  "\"tableName\":\"cyclist_name\",\"fileName\":\"nb-203-big-TOC.txt\"}]}";
+        MockResponse response = new MockResponse().setResponseCode(OK.code()).setBody(responseAsString);
+        SidecarInstanceImpl sidecarInstance = instances.get(2);
+        MockWebServer mockWebServer = servers.get(2);
+        mockWebServer.enqueue(response);
+
+        ListSnapshotFilesResponse result = client.listSnapshotFiles(sidecarInstance,
+                                                                    "cycling",
+                                                                    "cyclist_name",
+                                                                    "2023.04.11",
+                                                                    false)
+                                                 .get(30, TimeUnit.SECONDS);
+        assertThat(result).isNotNull();
+        assertThat(result.snapshotFilesInfo()).hasSize(1);
+        ListSnapshotFilesResponse.FileInfo fileInfo = result.snapshotFilesInfo().get(0);
+        assertThat(fileInfo.size).isEqualTo(15);
+        assertThat(fileInfo.host).isEqualTo("localhost1");
+        assertThat(fileInfo.port).isEqualTo(2020);
+        assertThat(fileInfo.dataDirIndex).isEqualTo(1);
+        assertThat(fileInfo.snapshotName).isEqualTo("2023.04.11");
+        assertThat(fileInfo.keySpaceName).isEqualTo("cycling");
+        assertThat(fileInfo.tableName).isEqualTo("cyclist_name");
+        assertThat(fileInfo.fileName).isEqualTo("nb-203-big-TOC.txt");
+
+        assertThat(mockWebServer.getRequestCount()).isEqualTo(1);
+        RecordedRequest request = mockWebServer.takeRequest();
+        assertThat(request.getPath()).isEqualTo(ApiEndpointsV1.SNAPSHOTS_ROUTE
+                                                .replaceAll(ApiEndpointsV1.KEYSPACE_PATH_PARAM, "cycling")
+                                                .replaceAll(ApiEndpointsV1.TABLE_PATH_PARAM, "cyclist_name")
+                                                .replaceAll(ApiEndpointsV1.SNAPSHOT_PATH_PARAM, "2023.04.11"));
+    }
+
+
+    @Test
+    void testClearSnapshot() throws Exception
+    {
+        MockResponse response = new MockResponse().setResponseCode(OK.code());
+        SidecarInstanceImpl sidecarInstance = instances.get(2);
+        MockWebServer mockWebServer = servers.get(2);
+        mockWebServer.enqueue(response);
+
+        client.clearSnapshot(sidecarInstance, "cycling", "cyclist_name", "2023.04.11")
+              .get(30, TimeUnit.SECONDS);
+
+        assertThat(mockWebServer.getRequestCount()).isEqualTo(1);
+        RecordedRequest request = mockWebServer.takeRequest();
+        assertThat(request.getPath()).isEqualTo(ApiEndpointsV1.SNAPSHOTS_ROUTE
+                                                .replaceAll(ApiEndpointsV1.KEYSPACE_PATH_PARAM, "cycling")
+                                                .replaceAll(ApiEndpointsV1.TABLE_PATH_PARAM, "cyclist_name")
+                                                .replaceAll(ApiEndpointsV1.SNAPSHOT_PATH_PARAM, "2023.04.11"));
+        assertThat(request.getMethod()).isEqualTo("DELETE");
+    }
+
+    @Test
+    void testCreateSnapshot() throws Exception
+    {
+        MockResponse response = new MockResponse().setResponseCode(OK.code());
+        SidecarInstanceImpl sidecarInstance = instances.get(3);
+        MockWebServer mockWebServer = servers.get(3);
+        mockWebServer.enqueue(response);
+
+        client.createSnapshot(sidecarInstance, "cycling", "cyclist_name", "2023.04.11")
+              .get(30, TimeUnit.SECONDS);
+
+        assertThat(mockWebServer.getRequestCount()).isEqualTo(1);
+        RecordedRequest request = mockWebServer.takeRequest();
+        assertThat(request.getPath()).isEqualTo(ApiEndpointsV1.SNAPSHOTS_ROUTE
+                                                .replaceAll(ApiEndpointsV1.KEYSPACE_PATH_PARAM, "cycling")
+                                                .replaceAll(ApiEndpointsV1.TABLE_PATH_PARAM, "cyclist_name")
+                                                .replaceAll(ApiEndpointsV1.SNAPSHOT_PATH_PARAM, "2023.04.11"));
+        assertThat(request.getMethod()).isEqualTo("PUT");
+    }
+
+    @Test
+    void testCleanUploadSession() throws Exception
+    {
+        MockResponse response = new MockResponse().setResponseCode(OK.code());
+        SidecarInstanceImpl sidecarInstance = instances.get(0);
+        MockWebServer mockWebServer = servers.get(0);
+        mockWebServer.enqueue(response);
+
+        client.cleanUploadSession(sidecarInstance, "00000")
+              .get(30, TimeUnit.SECONDS);
+
+        assertThat(mockWebServer.getRequestCount()).isEqualTo(1);
+        RecordedRequest request = mockWebServer.takeRequest();
+        assertThat(request.getPath()).isEqualTo(ApiEndpointsV1.SSTABLE_CLEANUP_ROUTE
+                                                .replaceAll(ApiEndpointsV1.UPLOAD_ID_PATH_PARAM, "00000"));
+        assertThat(request.getMethod()).isEqualTo("DELETE");
+    }
+
+    @Test
+    void testSSTableImport() throws Exception
+    {
+        String responseAsString = "{\"success\":true,\"uploadId\":\"0000-0000\",\"keyspace\":\"cycling\"," +
+                                  "\"tableName\":\"cyclist_name\"}";
+        MockResponse response = new MockResponse().setResponseCode(OK.code()).setBody(responseAsString);
+        SidecarInstanceImpl sidecarInstance = instances.get(0);
+        MockWebServer mockWebServer = servers.get(0);
+        mockWebServer.enqueue(response);
+
+        ImportSSTableRequest.ImportOptions options = new ImportSSTableRequest.ImportOptions();
+        SSTableImportResponse result = client.importSSTableRequest(sidecarInstance,
+                                                                   "cycling",
+                                                                   "cyclist_name",
+                                                                   "0000-0000",
+                                                                   options)
+                                             .get(30, TimeUnit.SECONDS);
+
+        assertThat(result).isNotNull();
+        assertThat(result.keyspace()).isEqualTo("cycling");
+        assertThat(result.tableName()).isEqualTo("cyclist_name");
+        assertThat(result.success()).isTrue();
+        assertThat(result.uploadId()).isEqualTo("0000-0000");
+
+        assertThat(mockWebServer.getRequestCount()).isEqualTo(1);
+        RecordedRequest request = mockWebServer.takeRequest();
+        assertThat(request.getPath()).isEqualTo(ApiEndpointsV1.SSTABLE_IMPORT_ROUTE
+                                                .replaceAll(ApiEndpointsV1.KEYSPACE_PATH_PARAM, "cycling")
+                                                .replaceAll(ApiEndpointsV1.TABLE_PATH_PARAM, "cyclist_name")
+                                                .replaceAll(ApiEndpointsV1.UPLOAD_ID_PATH_PARAM, "0000-0000"));
+        assertThat(request.getMethod()).isEqualTo("PUT");
+    }
+
+    @Test
+    void testSSTableImportWithAcceptedResponse() throws Exception
+    {
+        String responseAsString = "{\"success\":true,\"uploadId\":\"0000-0000\",\"keyspace\":\"cycling\"," +
+                                  "\"tableName\":\"cyclist_name\"}";
+        SidecarInstanceImpl sidecarInstance = instances.get(0);
+        MockWebServer mockWebServer = servers.get(0);
+        mockWebServer.enqueue(new MockResponse().setResponseCode(ACCEPTED.code()));
+        mockWebServer.enqueue(new MockResponse().setResponseCode(ACCEPTED.code()));
+        mockWebServer.enqueue(new MockResponse().setResponseCode(ACCEPTED.code()));
+        mockWebServer.enqueue(new MockResponse().setResponseCode(ACCEPTED.code()));
+        mockWebServer.enqueue(new MockResponse().setResponseCode(OK.code()).setBody(responseAsString));
+
+        ImportSSTableRequest.ImportOptions options = new ImportSSTableRequest.ImportOptions();
+        SSTableImportResponse result = client.importSSTableRequest(sidecarInstance,
+                                                                   "cycling",
+                                                                   "cyclist_name",
+                                                                   "0000-0000",
+                                                                   options)
+                                             .get(30, TimeUnit.SECONDS);
+
+        assertThat(result).isNotNull();
+        assertThat(result.keyspace()).isEqualTo("cycling");
+        assertThat(result.tableName()).isEqualTo("cyclist_name");
+        assertThat(result.success()).isTrue();
+        assertThat(result.uploadId()).isEqualTo("0000-0000");
+
+        assertThat(mockWebServer.getRequestCount()).isEqualTo(5);
+        RecordedRequest request = mockWebServer.takeRequest();
+        assertThat(request.getPath()).isEqualTo(ApiEndpointsV1.SSTABLE_IMPORT_ROUTE
+                                                .replaceAll(ApiEndpointsV1.KEYSPACE_PATH_PARAM, "cycling")
+                                                .replaceAll(ApiEndpointsV1.TABLE_PATH_PARAM, "cyclist_name")
+                                                .replaceAll(ApiEndpointsV1.UPLOAD_ID_PATH_PARAM, "0000-0000"));
+        assertThat(request.getMethod()).isEqualTo("PUT");
+    }
+
+    @Test
+    void testUploadSSTableFailsWhenFileDoesNotExist()
+    {
+
+        assertThatIllegalArgumentException()
+        .isThrownBy(() -> client.uploadSSTableRequest(new SidecarInstanceImpl("host", 8080),
+                                                      "cycling",
+                                                      "cyclist_name",
+                                                      "0000-0000",
+                                                      "nb-1-big-TOC.txt",
+                                                      null,
+                                                      "path")
+                                .get(30, TimeUnit.SECONDS))
+        .withMessage("File 'path' does not exist");
+    }
+
+    @Test
+    void testUploadSSTableWithoutChecksum(@TempDir Path tempDirectory) throws Exception
+    {
+        Path fileToUpload = prepareFile(tempDirectory);
+        try (MockWebServer server = new MockWebServer())
+        {
+            server.enqueue(new MockResponse().setResponseCode(OK.code()));
+
+            SidecarInstanceImpl sidecarInstance = RequestExecutorTest.newSidecarInstance(server);
+            client.uploadSSTableRequest(sidecarInstance,
+                                        "cycling",
+                                        "cyclist_name",
+                                        "0000-0000",
+                                        "nb-1-big-TOC.txt",
+                                        null,
+                                        fileToUpload.toString())
+                  .get(30, TimeUnit.SECONDS);
+
+            assertThat(server.getRequestCount()).isEqualTo(1);
+            RecordedRequest request = server.takeRequest();
+            assertThat(request.getPath())
+            .isEqualTo(ApiEndpointsV1.SSTABLE_UPLOAD_ROUTE
+                       .replaceAll(ApiEndpointsV1.UPLOAD_ID_PATH_PARAM, "0000-0000")
+                       .replaceAll(ApiEndpointsV1.KEYSPACE_PATH_PARAM, "cycling")
+                       .replaceAll(ApiEndpointsV1.TABLE_PATH_PARAM, "cyclist_name")
+                       .replaceAll(ApiEndpointsV1.COMPONENT_PATH_PARAM, "nb-1-big-TOC.txt"));
+            assertThat(request.getMethod()).isEqualTo("PUT");
+            assertThat(request.getHeader(HttpHeaderNames.CONTENT_MD5.toString())).isNull();
+            assertThat(request.getHeader(HttpHeaderNames.CONTENT_LENGTH.toString())).isEqualTo("80");
+            assertThat(request.getBodySize()).isEqualTo(80);
+        }
+    }
+
+    @Test
+    void testUploadSSTableWithChecksum(@TempDir Path tempDirectory) throws Exception
+    {
+        Path fileToUpload = prepareFile(tempDirectory);
+        try (MockWebServer server = new MockWebServer())
+        {
+            server.enqueue(new MockResponse().setResponseCode(OK.code()));
+
+            SidecarInstanceImpl sidecarInstance = RequestExecutorTest.newSidecarInstance(server);
+            client.uploadSSTableRequest(sidecarInstance,
+                                        "cycling",
+                                        "cyclist_name",
+                                        "0000-0000",
+                                        "nb-1-big-TOC.txt",
+                                        "15a69dc6501aa5ae17af037fe053f610",
+                                        fileToUpload.toString())
+                  .get(30, TimeUnit.SECONDS);
+
+            assertThat(server.getRequestCount()).isEqualTo(1);
+            RecordedRequest request = server.takeRequest();
+            assertThat(request.getPath())
+            .isEqualTo(ApiEndpointsV1.SSTABLE_UPLOAD_ROUTE
+                       .replaceAll(ApiEndpointsV1.UPLOAD_ID_PATH_PARAM, "0000-0000")
+                       .replaceAll(ApiEndpointsV1.KEYSPACE_PATH_PARAM, "cycling")
+                       .replaceAll(ApiEndpointsV1.TABLE_PATH_PARAM, "cyclist_name")
+                       .replaceAll(ApiEndpointsV1.COMPONENT_PATH_PARAM, "nb-1-big-TOC.txt"));
+            assertThat(request.getMethod()).isEqualTo("PUT");
+            assertThat(request.getHeader(HttpHeaderNames.CONTENT_MD5.toString()))
+            .isEqualTo("15a69dc6501aa5ae17af037fe053f610");
+            assertThat(request.getHeader(HttpHeaderNames.CONTENT_LENGTH.toString())).isEqualTo("80");
+            assertThat(request.getBodySize()).isEqualTo(80);
+        }
+    }
+
+    @Test
+    void testStreamSSTableComponentWithNoRange() throws Exception
+    {
+        try (MockWebServer server = new MockWebServer())
+        {
+            CountDownLatch latch = new CountDownLatch(1);
+            List<byte[]> receivedBytes = new ArrayList<>();
+            StreamConsumer mockStreamConsumer = new StreamConsumer()
+            {
+                @Override
+                public void onRead(StreamBuffer buffer)
+                {
+                    assertThat(buffer.readableBytes()).isGreaterThan(0);
+                    byte[] dst = new byte[buffer.readableBytes()];
+                    buffer.copyBytes(0, dst, 0, buffer.readableBytes());
+                    receivedBytes.add(dst);
+                }
+
+                @Override
+                public void onComplete()
+                {
+                    latch.countDown();
+                }
+
+                @Override
+                public void onError(Throwable throwable)
+                {
+                    latch.countDown();
+                    fail("Should not encounter an error", throwable);
+                }
+            };
+            InputStream inputStream = resourceInputStream("sstables/nb-1-big-TOC.txt");
+            Buffer buffer = Okio.buffer(Okio.source(inputStream)).getBuffer();
+            Okio.use(buffer, buffer1 -> {
+                try
+                {
+                    return buffer1.writeAll(Okio.source(inputStream));
+                }
+                catch (IOException e)
+                {
+                    throw new RuntimeException(e);
+                }
+            });
+
+            SidecarInstanceImpl sidecarInstance = RequestExecutorTest.newSidecarInstance(server);
+            MockResponse response =
+            new MockResponse().setResponseCode(OK.code())
+                              .setHeader(HttpHeaderNames.CONTENT_TYPE.toString(),
+                                         HttpHeaderValues.APPLICATION_OCTET_STREAM)
+                              .setHeader(HttpHeaderNames.ACCEPT_RANGES.toString(), "bytes")
+                              .setBody(buffer);
+            server.enqueue(response);
+
+            client.streamSSTableComponent(sidecarInstance,
+                                          "cycling",
+                                          "cyclist_name",
+                                          "2023.04.12",
+                                          "nb-203-big-Data.db",
+                                          null,
+                                          mockStreamConsumer);
+
+            assertThat(latch.await(1, TimeUnit.MINUTES)).isTrue();
+
+            RecordedRequest request = server.takeRequest();
+            assertThat(request.getPath())
+            .isEqualTo(ApiEndpointsV1.COMPONENTS_ROUTE
+                       .replaceAll(ApiEndpointsV1.KEYSPACE_PATH_PARAM, "cycling")
+                       .replaceAll(ApiEndpointsV1.TABLE_PATH_PARAM, "cyclist_name")
+                       .replaceAll(ApiEndpointsV1.SNAPSHOT_PATH_PARAM, "2023.04.12")
+                       .replaceAll(ApiEndpointsV1.COMPONENT_PATH_PARAM, "nb-203-big-Data.db"));
+            assertThat(request.getHeader("User-Agent")).isEqualTo("cassandra-sidecar-test/0.0.1");
+            assertThat(request.getHeader("range")).isNull();
+
+            byte[] bytes = receivedBytes.stream()
+                                        .collect(ByteArrayOutputStream::new,
+                                                 (outputStream, src) -> outputStream.write(src, 0, src.length),
+                                                 (outputStream, src) -> {
+                                                 })
+                                        .toByteArray();
+            assertThat(new String(bytes, StandardCharsets.UTF_8)).isEqualTo("Summary.db\n" +
+                                                                            "TOC.txt\n" +
+                                                                            "Statistics.db\n" +
+                                                                            "Filter.db\n" +
+                                                                            "Data.db\n" +
+                                                                            "CRC.db\n" +
+                                                                            "Digest.crc32\n" +
+                                                                            "Index.db\n");
+        }
+    }
+
+    @Test
+    void testStreamSSTableComponentWithRange() throws Exception
+    {
+        try (MockWebServer server = new MockWebServer())
+        {
+            CountDownLatch latch = new CountDownLatch(1);
+            List<byte[]> receivedBytes = new ArrayList<>();
+            StreamConsumer mockStreamConsumer = new StreamConsumer()
+            {
+                @Override
+                public void onRead(StreamBuffer buffer)
+                {
+                    assertThat(buffer.readableBytes()).isGreaterThan(0);
+                    byte[] dst = new byte[buffer.readableBytes()];
+                    buffer.copyBytes(0, dst, 0, buffer.readableBytes());
+                    receivedBytes.add(dst);
+                }
+
+                @Override
+                public void onComplete()
+                {
+                    latch.countDown();
+                }
+
+                @Override
+                public void onError(Throwable throwable)
+                {
+                    latch.countDown();
+                    fail("Should not encounter an error", throwable);
+                }
+            };
+
+            SidecarInstanceImpl sidecarInstance = RequestExecutorTest.newSidecarInstance(server);
+            MockResponse response =
+            new MockResponse().setResponseCode(PARTIAL_CONTENT.code())
+                              .setHeader(HttpHeaderNames.CONTENT_TYPE.toString(),
+                                         HttpHeaderValues.APPLICATION_OCTET_STREAM)
+                              .setHeader(HttpHeaderNames.ACCEPT_RANGES.toString(), "bytes")
+                              .setHeader(HttpHeaderNames.CONTENT_RANGE.toString(), "bytes 10-20/80")
+                              .setBody("TOC.txt\nSt");
+            server.enqueue(response);
+
+            client.streamSSTableComponent(sidecarInstance,
+                                          "cycling",
+                                          "cyclist_name",
+                                          "2023.04.12",
+                                          "nb-203-big-Data.db",
+                                          HttpRange.of(10, 20),
+                                          mockStreamConsumer);
+
+            assertThat(latch.await(1, TimeUnit.MINUTES)).isTrue();
+
+            RecordedRequest request = server.takeRequest();
+            assertThat(request.getPath())
+            .isEqualTo(ApiEndpointsV1.COMPONENTS_ROUTE
+                       .replaceAll(ApiEndpointsV1.KEYSPACE_PATH_PARAM, "cycling")
+                       .replaceAll(ApiEndpointsV1.TABLE_PATH_PARAM, "cyclist_name")
+                       .replaceAll(ApiEndpointsV1.SNAPSHOT_PATH_PARAM, "2023.04.12")
+                       .replaceAll(ApiEndpointsV1.COMPONENT_PATH_PARAM, "nb-203-big-Data.db"));
+            assertThat(request.getHeader("User-Agent")).isEqualTo("cassandra-sidecar-test/0.0.1");
+            assertThat(request.getHeader("range")).isEqualTo("bytes=10-20");
+
+            byte[] bytes = receivedBytes.stream()
+                                        .collect(ByteArrayOutputStream::new,
+                                                 (outputStream, src) -> outputStream.write(src, 0, src.length),
+                                                 (outputStream, src) -> {
+                                                 })
+                                        .toByteArray();
+            assertThat(new String(bytes, StandardCharsets.UTF_8)).isEqualTo("TOC.txt\nSt");
+        }
+    }
+
+    @Test
+    void testStreamSSTableComponentFailsMidStream() throws Exception
+    {
+        try (MockWebServer server = new MockWebServer())
+        {
+            CountDownLatch latch = new CountDownLatch(1);
+            List<byte[]> receivedBytes = new ArrayList<>();
+            StreamConsumer mockStreamConsumer = new StreamConsumer()
+            {
+                @Override
+                public void onRead(StreamBuffer buffer)
+                {
+                    assertThat(buffer.readableBytes()).isGreaterThan(0);
+                    byte[] dst = new byte[buffer.readableBytes()];
+                    buffer.copyBytes(0, dst, 0, buffer.readableBytes());
+                    receivedBytes.add(dst);
+                }
+
+                @Override
+                public void onComplete()
+                {
+                }
+
+                @Override
+                public void onError(Throwable throwable)
+                {
+                    latch.countDown();
+                    assertThat(throwable).isNotNull();
+                }
+            };
+            InputStream inputStream = resourceInputStream("sstables/nb-1-big-TOC.txt");
+            Buffer buffer = Okio.buffer(Okio.source(inputStream)).getBuffer();
+            Okio.use(buffer, buffer1 -> {
+                try
+                {
+                    return buffer1.writeAll(Okio.source(inputStream));
+                }
+                catch (IOException e)
+                {
+                    throw new RuntimeException(e);
+                }
+            });
+
+            SidecarInstanceImpl sidecarInstance = RequestExecutorTest.newSidecarInstance(server);
+            MockResponse response =
+            new MockResponse().setResponseCode(OK.code())
+                              .setHeader(HttpHeaderNames.CONTENT_TYPE.toString(),
+                                         HttpHeaderValues.APPLICATION_OCTET_STREAM)
+                              .setHeader(HttpHeaderNames.ACCEPT_RANGES.toString(), "bytes")
+                              .setBody(buffer)
+                              .setSocketPolicy(SocketPolicy.DISCONNECT_DURING_RESPONSE_BODY);
+            server.enqueue(response);
+
+            client.streamSSTableComponent(sidecarInstance,
+                                          "cycling",
+                                          "cyclist_name",
+                                          "2023.04.12",
+                                          "nb-203-big-Data.db",
+                                          null,
+                                          mockStreamConsumer);
+
+            assertThat(latch.await(1, TimeUnit.MINUTES)).isTrue();
+
+            RecordedRequest request = server.takeRequest();
+            assertThat(request.getPath())
+            .isEqualTo(ApiEndpointsV1.COMPONENTS_ROUTE
+                       .replaceAll(ApiEndpointsV1.KEYSPACE_PATH_PARAM, "cycling")
+                       .replaceAll(ApiEndpointsV1.TABLE_PATH_PARAM, "cyclist_name")
+                       .replaceAll(ApiEndpointsV1.SNAPSHOT_PATH_PARAM, "2023.04.12")
+                       .replaceAll(ApiEndpointsV1.COMPONENT_PATH_PARAM, "nb-203-big-Data.db"));
+            assertThat(request.getHeader("User-Agent")).isEqualTo("cassandra-sidecar-test/0.0.1");
+            assertThat(request.getHeader("range")).isNull();
+            assertThat(receivedBytes).hasSizeGreaterThan(0);
+        }
+    }
+
+    @Test
+    void testStreamSSTableComponentWithRetries() throws Exception
+    {
+        try (MockWebServer server = new MockWebServer())
+        {
+            CountDownLatch latch = new CountDownLatch(1);
+            List<byte[]> receivedBytes = new ArrayList<>();
+            StreamConsumer mockStreamConsumer = new StreamConsumer()
+            {
+                @Override
+                public void onRead(StreamBuffer buffer)
+                {
+                    assertThat(buffer.readableBytes()).isGreaterThan(0);
+                    byte[] dst = new byte[buffer.readableBytes()];
+                    buffer.copyBytes(0, dst, 0, buffer.readableBytes());
+                    receivedBytes.add(dst);
+                }
+
+                @Override
+                public void onComplete()
+                {
+                    latch.countDown();
+                }
+
+                @Override
+                public void onError(Throwable throwable)
+                {
+                }
+            };
+
+            SidecarInstanceImpl sidecarInstance = RequestExecutorTest.newSidecarInstance(server);
+            MockResponse response =
+            new MockResponse().setResponseCode(PARTIAL_CONTENT.code())
+                              .setHeader(HttpHeaderNames.CONTENT_TYPE.toString(),
+                                         HttpHeaderValues.APPLICATION_OCTET_STREAM)
+                              .setHeader(HttpHeaderNames.ACCEPT_RANGES.toString(), "bytes")
+                              .setHeader(HttpHeaderNames.CONTENT_RANGE.toString(), "bytes 10-20/80")
+                              .setBody("TOC.txt\nSt");
+            server.enqueue(new MockResponse().setResponseCode(INTERNAL_SERVER_ERROR.code())
+                                             .setBody("{\"error\":\"some error\"}"));
+            server.enqueue(new MockResponse().setResponseCode(INTERNAL_SERVER_ERROR.code())
+                                             .setBody("{\"error\":\"some error\"}"));
+            server.enqueue(response);
+
+            client.streamSSTableComponent(sidecarInstance,
+                                          "cycling",
+                                          "cyclist_name",
+                                          "2023.04.12",
+                                          "nb-203-big-Data.db",
+                                          HttpRange.of(10, 20),
+                                          mockStreamConsumer);
+
+            assertThat(latch.await(1, TimeUnit.MINUTES)).isTrue();
+
+            server.takeRequest(); // first 500
+            server.takeRequest(); // second 500
+            RecordedRequest request3 = server.takeRequest();
+            assertThat(request3.getPath())
+            .isEqualTo(ApiEndpointsV1.COMPONENTS_ROUTE
+                       .replaceAll(ApiEndpointsV1.KEYSPACE_PATH_PARAM, "cycling")
+                       .replaceAll(ApiEndpointsV1.TABLE_PATH_PARAM, "cyclist_name")
+                       .replaceAll(ApiEndpointsV1.SNAPSHOT_PATH_PARAM, "2023.04.12")
+                       .replaceAll(ApiEndpointsV1.COMPONENT_PATH_PARAM, "nb-203-big-Data.db"));
+            assertThat(request3.getHeader("User-Agent")).isEqualTo("cassandra-sidecar-test/0.0.1");
+            assertThat(request3.getHeader("range")).isEqualTo("bytes=10-20");
+
+            byte[] bytes = receivedBytes.stream()
+                                        .collect(ByteArrayOutputStream::new,
+                                                 (outputStream, src) -> outputStream.write(src, 0, src.length),
+                                                 (outputStream, src) -> {
+                                                 })
+                                        .toByteArray();
+            assertThat(new String(bytes, StandardCharsets.UTF_8)).isEqualTo("TOC.txt\nSt");
+        }
+    }
+
+    @Test
+    void testFailsWithOneAttemptPerServer()
+    {
+        for (MockWebServer server : servers)
+        {
+            MockResponse response = new MockResponse().setResponseCode(INTERNAL_SERVER_ERROR.code())
+                                                      .setBody("{\"error\":\"some error\"}");
+            server.enqueue(response);
+        }
+
+        assertThatExceptionOfType(ExecutionException.class)
+        .isThrownBy(() -> client.schema("cycling").get(30, TimeUnit.SECONDS))
+        .withRootCauseInstanceOf(RetriesExhaustedException.class)
+        .withMessageContaining("Unable to complete request '/api/v1/keyspaces/cycling/schema' after 4 attempts");
+    }
+
+    @Test
+    void testProvidingCustomRetryPolicy() throws ExecutionException, InterruptedException, TimeoutException
+    {
+        String nodeSettingsAsString = "{\"partitioner\":\"test-partitioner\", \"releaseVersion\": \"4.0.0\"}";
+        MockResponse response = new MockResponse().setResponseCode(ACCEPTED.code()).setBody(nodeSettingsAsString);
+        enqueue(response);
+
+        RequestContext requestContext = client.requestBuilder()
+                                     .request(new NodeSettingsRequest())
+                                     .retryPolicy(new RetryPolicy()
+                                     {
+                                         @Override
+                                         public void onResponse(CompletableFuture<HttpResponse> responseFuture,
+                                                                Request request,
+                                                                HttpResponse response,
+                                                                Throwable throwable,
+                                                                int attempts,
+                                                                boolean canRetryOnADifferentHost,
+                                                                RetryAction retryAction)
+                                         {
+                                             if (response != null && response.statusCode() == ACCEPTED.code())
+                                             {
+                                                 responseFuture.complete(response);
+                                             }
+                                             else
+                                             {
+                                                 client.defaultRetryPolicy().onResponse(responseFuture,
+                                                                                        request,
+                                                                                        response,
+                                                                                        throwable,
+                                                                                        attempts,
+                                                                                        canRetryOnADifferentHost,
+                                                                                        retryAction);
+                                             }
+                                         }
+                                     })
+                                     .build();
+        NodeSettings result = client.<NodeSettings>executeRequestAsync(requestContext).get(30, TimeUnit.SECONDS);
+        assertThat(result).isNotNull();
+        assertThat(result.partitioner()).isEqualTo("test-partitioner");
+        assertThat(result.releaseVersion()).isEqualTo("4.0.0");
+
+        validateResponseServed(ApiEndpointsV1.NODE_SETTINGS_ROUTE);
+    }
+
+    private void enqueue(MockResponse response)
+    {
+        for (MockWebServer server : servers)
+        {
+            server.enqueue(response);
+        }
+    }
+
+    private void validateResponseServed(String expectedEndpointPath) throws InterruptedException
+    {
+        for (MockWebServer server : servers)
+        {
+            if (server.getRequestCount() > 0)
+            {
+                assertThat(server.getRequestCount()).isEqualTo(1);
+                RecordedRequest request = server.takeRequest();
+                assertThat(request.getPath()).isEqualTo(expectedEndpointPath);
+                return;
+            }
+        }
+        fail("The request was not served by any of the provided servers");
+    }
+
+    private InputStream resourceInputStream(String name)
+    {
+        InputStream inputStream = getClass().getClassLoader().getResourceAsStream(name);
+        assertThat(inputStream).isNotNull();
+        return inputStream;
+    }
+
+    private Path prepareFile(Path tempDirectory) throws IOException
+    {
+        Path fileToUpload = tempDirectory.resolve("nb-1-big-TOC.txt");
+        try (InputStream inputStream = resourceInputStream("sstables/nb-1-big-TOC.txt"))
+        {
+            Files.copy(inputStream, fileToUpload, StandardCopyOption.REPLACE_EXISTING);
+        }
+        return fileToUpload;
+    }
+}
diff --git a/client/src/testFixtures/java/org/apache/cassandra/sidecar/client/request/BaseRequestTest.java b/client/src/testFixtures/java/org/apache/cassandra/sidecar/client/request/BaseRequestTest.java
new file mode 100644
index 0000000..b05388d
--- /dev/null
+++ b/client/src/testFixtures/java/org/apache/cassandra/sidecar/client/request/BaseRequestTest.java
@@ -0,0 +1,88 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.sidecar.client.request;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import okhttp3.mockwebserver.MockWebServer;
+import org.apache.cassandra.sidecar.client.HttpClient;
+import org.apache.cassandra.sidecar.client.HttpClientConfig;
+import org.apache.cassandra.sidecar.client.RequestContext;
+import org.apache.cassandra.sidecar.client.RequestExecutor;
+import org.apache.cassandra.sidecar.client.SidecarInstanceImpl;
+import org.apache.cassandra.sidecar.client.SimpleSidecarInstancesProvider;
+import org.apache.cassandra.sidecar.client.retry.NoRetryPolicy;
+import org.apache.cassandra.sidecar.client.retry.RetryPolicy;
+import org.apache.cassandra.sidecar.client.selection.InstanceSelectionPolicy;
+import org.apache.cassandra.sidecar.client.selection.OrderedInstanceSelectionPolicy;
+import org.apache.cassandra.sidecar.client.selection.RandomInstanceSelectionPolicy;
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * Provides base functionality for testing requests
+ */
+public abstract class BaseRequestTest
+{
+    protected abstract RequestExecutor sidecarClient();
+
+    protected abstract HttpClient httpClient();
+
+    protected abstract HttpClientConfig httpClientConfig();
+
+    protected RetryPolicy retryPolicy()
+    {
+        return new NoRetryPolicy();
+    }
+
+    protected RequestContext.Builder builder(InstanceSelectionPolicy instanceSelectionPolicy)
+    {
+        return newBuilder()
+               .instanceSelectionPolicy(instanceSelectionPolicy)
+               .retryPolicy(retryPolicy());
+    }
+
+    protected RequestContext.Builder newBuilder()
+    {
+        return new RequestContext.Builder();
+    }
+
+    public static InstanceSelectionPolicy buildRandomFromServerList(MockWebServer... servers)
+    {
+        List<SidecarInstanceImpl> instances = Arrays.stream(servers)
+                                                    .map(RequestExecutorTest::newSidecarInstance)
+                                                    .collect(Collectors.toList());
+        return new RandomInstanceSelectionPolicy(new SimpleSidecarInstancesProvider(instances));
+    }
+
+    public static InstanceSelectionPolicy buildFromServerList(MockWebServer... servers)
+    {
+        List<SidecarInstanceImpl> instances = Arrays.stream(servers)
+                                                    .map(RequestExecutorTest::newSidecarInstance)
+                                                    .collect(Collectors.toList());
+        return new OrderedInstanceSelectionPolicy(new SimpleSidecarInstancesProvider(instances));
+    }
+
+    @NotNull
+    public static SidecarInstanceImpl newSidecarInstance(MockWebServer server)
+    {
+        return new SidecarInstanceImpl(server.getHostName(), server.getPort());
+    }
+}
diff --git a/client/src/testFixtures/java/org/apache/cassandra/sidecar/client/request/FullSchemaRequestTestParameters.java b/client/src/testFixtures/java/org/apache/cassandra/sidecar/client/request/FullSchemaRequestTestParameters.java
new file mode 100644
index 0000000..6d7ee96
--- /dev/null
+++ b/client/src/testFixtures/java/org/apache/cassandra/sidecar/client/request/FullSchemaRequestTestParameters.java
@@ -0,0 +1,56 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.sidecar.client.request;
+
+import org.apache.cassandra.sidecar.client.RequestContext;
+import org.apache.cassandra.sidecar.common.ApiEndpointsV1;
+import org.apache.cassandra.sidecar.common.data.SchemaResponse;
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Unit tests for client requests accessing the full database schema
+ */
+public class FullSchemaRequestTestParameters implements RequestTestParameters<SchemaResponse>
+{
+    @Override
+    public RequestContext.Builder specificRequest(RequestContext.Builder requestContextBuilder)
+    {
+        return requestContextBuilder.schemaRequest();
+    }
+
+    @Override
+    public String okResponseBody()
+    {
+        return "{\"schema\":\"CREATE KEYSPACE system_traces ...\"}";
+    }
+
+    @Override
+    public String expectedEndpointPath()
+    {
+        return ApiEndpointsV1.ALL_KEYSPACES_SCHEMA_ROUTE;
+    }
+
+    @Override
+    public void validateResponse(SchemaResponse response)
+    {
+        assertThat(response).isNotNull();
+        assertThat(response.keyspace()).isNull();
+        assertThat(response.schema()).contains("CREATE KEYSPACE system_traces");
+    }
+}
diff --git a/client/src/testFixtures/java/org/apache/cassandra/sidecar/client/request/GossipInfoRequestTestParameters.java b/client/src/testFixtures/java/org/apache/cassandra/sidecar/client/request/GossipInfoRequestTestParameters.java
new file mode 100644
index 0000000..37aae26
--- /dev/null
+++ b/client/src/testFixtures/java/org/apache/cassandra/sidecar/client/request/GossipInfoRequestTestParameters.java
@@ -0,0 +1,72 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.sidecar.client.request;
+
+import org.apache.cassandra.sidecar.client.RequestContext;
+import org.apache.cassandra.sidecar.common.ApiEndpointsV1;
+import org.apache.cassandra.sidecar.common.data.GossipInfoResponse;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Unit tests for client requests accessing the gossip info endpoint
+ */
+public class GossipInfoRequestTestParameters implements RequestTestParameters<GossipInfoResponse>
+{
+    @Override
+    public RequestContext.Builder specificRequest(RequestContext.Builder requestContextBuilder)
+    {
+        return requestContextBuilder.gossipInfoRequest();
+    }
+
+    @Override
+    public String okResponseBody()
+    {
+        return "{\"/127.0.0.3:7000\":{\"generation\":\"1679961466\"," +
+               "\"schema\":\"4994b214-7a05-35ea-a11c-d092a554dd5d\",\"rack\":\"101000301\"," +
+               "\"heartbeat\":\"2114\",\"releaseVersion\":\"4.0.8\"," +
+               "\"hostId\":\"be19c254-becb-40b9-8951-30c589c7028e\",\"nativeAddressAndPort\":\"127.0.0.3:9042\"," +
+               "\"load\":\"89083.0\",\"sstableVersions\":\"big-nb\",\"tokens\":\"<hidden>\",\"rpcReady\":\"true\"," +
+               "\"dc\":\"LO\",\"netVersion\":\"12\",\"statusWithPort\":\"NORMAL,3074457345618258602\"}," +
+               "\"/127.0.0.1:7000\":{\"generation\":\"1679961466\"," +
+               "\"schema\":\"4994b214-7a05-35ea-a11c-d092a554dd5d\",\"rack\":\"101000101\",\"heartbeat\":\"2108\"," +
+               "\"releaseVersion\":\"4.0.8\",\"rpcAddress\":\"172.17.0.2\"," +
+               "\"hostId\":\"33cae238-8203-41c1-880f-8cdf98ee6720\",\"nativeAddressAndPort\":\"172.17.0.2:9042\"," +
+               "\"load\":\"89082.0\",\"sstableVersions\":\"big-nb\",\"tokens\":\"<hidden>\",\"rpcReady\":\"true\"," +
+               "\"status\":\"NORMAL,-9223372036854775808\",\"dc\":\"LO\",\"netVersion\":\"12\",\"statusWithPort\":" +
+               "\"NORMAL,-9223372036854775808\"},\"/127.0.0.2:7000\":{\"generation\":\"1679961465\"," +
+               "\"schema\":\"4994b214-7a05-35ea-a11c-d092a554dd5d\",\"rack\":\"101000201\",\"heartbeat\":\"2108\"," +
+               "\"releaseVersion\":\"4.0.8\",\"hostId\":\"dba02656-ea8c-4a1d-8011-cbc0dab5f411\"," +
+               "\"nativeAddressAndPort\":\"127.0.0.2:9042\",\"load\":\"89084.0\",\"sstableVersions\":\"big-nb\"," +
+               "\"tokens\":\"<hidden>\",\"rpcReady\":\"true\",\"dc\":\"LO\",\"netVersion\":\"12\"," +
+               "\"statusWithPort\":\"NORMAL,-3074457345618258603\"}}";
+    }
+
+    @Override
+    public String expectedEndpointPath()
+    {
+        return ApiEndpointsV1.GOSSIP_INFO_ROUTE;
+    }
+
+    @Override
+    public void validateResponse(GossipInfoResponse gossipInfo)
+    {
+        assertThat(gossipInfo.size()).isEqualTo(3);
+    }
+}
diff --git a/client/src/testFixtures/java/org/apache/cassandra/sidecar/client/request/ListSnapshotFilesRequestTestParameters.java b/client/src/testFixtures/java/org/apache/cassandra/sidecar/client/request/ListSnapshotFilesRequestTestParameters.java
new file mode 100644
index 0000000..eb93427
--- /dev/null
+++ b/client/src/testFixtures/java/org/apache/cassandra/sidecar/client/request/ListSnapshotFilesRequestTestParameters.java
@@ -0,0 +1,108 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.sidecar.client.request;
+
+import org.apache.cassandra.sidecar.client.RequestContext;
+import org.apache.cassandra.sidecar.common.ApiEndpointsV1;
+import org.apache.cassandra.sidecar.common.data.ListSnapshotFilesResponse;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Unit tests for client requests accessing the list snapshot endpoint
+ */
+public class ListSnapshotFilesRequestTestParameters implements RequestTestParameters<ListSnapshotFilesResponse>
+{
+    @Override
+    public RequestContext.Builder specificRequest(RequestContext.Builder requestContextBuilder)
+    {
+        return requestContextBuilder.listSnapshotFilesRequest("keyspace1", "standard1", "2023.04.10");
+    }
+
+    @Override
+    public String okResponseBody()
+    {
+        return "{\"snapshotFilesInfo\":[{\"size\":80,\"host\":\"localhost2\",\"port\":9043,\"dataDirIndex\":0," +
+               "\"snapshotName\":\"2023.04.10\",\"keySpaceName\":\"keyspace1\",\"tableName\":" +
+               "\"standard1-bc1a8c20d7f111eda9b8056729c856e9\",\"fileName\":\"nb-1-big-TOC.txt\"}," +
+               "{\"size\":10,\"host\":\"localhost2\",\"port\":9043," +
+               "\"dataDirIndex\":0,\"snapshotName\":\"2023.04.10\"," +
+               "\"keySpaceName\":\"keyspace1\",\"tableName\":\"standard1-bc1a8c20d7f111eda9b8056729c856e9\"," +
+               "\"fileName\":\"nb-1-big-Digest.crc32\"},{\"size\":424,\"host\":\"localhost2\",\"port\":9043," +
+               "\"dataDirIndex\":0,\"snapshotName\":\"2023.04.10\",\"keySpaceName\":\"keyspace1\"," +
+               "\"tableName\":\"standard1-bc1a8c20d7f111eda9b8056729c856e9\",\"fileName\":\"nb-1-big-CRC.db\"}," +
+               "{\"size\":5222,\"host\":\"localhost2\",\"port\":9043,\"dataDirIndex\":0," +
+               "\"snapshotName\":\"2023.04.10\",\"keySpaceName\":\"keyspace1\"," +
+               "\"tableName\":\"standard1-bc1a8c20d7f111eda9b8056729c856e9\",\"fileName\":\"nb-1-big-Summary.db\"}," +
+               "{\"size\":500769,\"host\":\"localhost2\",\"port\":9043,\"dataDirIndex\":0," +
+               "\"snapshotName\":\"2023.04.10\",\"keySpaceName\":\"keyspace1\"," +
+               "\"tableName\":\"standard1-bc1a8c20d7f111eda9b8056729c856e9\",\"fileName\":\"nb-1-big-Index.db\"}," +
+               "{\"size\":31,\"host\":\"localhost2\",\"port\":9043,\"dataDirIndex\":0," +
+               "\"snapshotName\":\"2023.04.10\",\"keySpaceName\":\"keyspace1\"," +
+               "\"tableName\":\"standard1-bc1a8c20d7f111eda9b8056729c856e9\",\"fileName\":\"manifest.json\"}," +
+               "{\"size\":6870000,\"host\":\"localhost2\",\"port\":9043,\"dataDirIndex\":0," +
+               "\"snapshotName\":\"2023.04.10\",\"keySpaceName\":\"keyspace1\"," +
+               "\"tableName\":\"standard1-bc1a8c20d7f111eda9b8056729c856e9\",\"fileName\":\"nb-1-big-Data.db\"}," +
+               "{\"size\":864,\"host\":\"localhost2\",\"port\":9043,\"dataDirIndex\":0," +
+               "\"snapshotName\":\"2023.04.10\",\"keySpaceName\":\"keyspace1\"," +
+               "\"tableName\":\"standard1-bc1a8c20d7f111eda9b8056729c856e9\",\"fileName\":\"schema.cql\"}," +
+               "{\"size\":37512,\"host\":\"localhost2\",\"port\":9043,\"dataDirIndex\":0," +
+               "\"snapshotName\":\"2023.04.10\",\"keySpaceName\":\"keyspace1\"," +
+               "\"tableName\":\"standard1-bc1a8c20d7f111eda9b8056729c856e9\",\"fileName\":\"nb-1-big-Filter.db\"}," +
+               "{\"size\":10353,\"host\":\"localhost2\",\"port\":9043,\"dataDirIndex\":0," +
+               "\"snapshotName\":\"2023.04.10\",\"keySpaceName\":\"keyspace1\"," +
+               "\"tableName\":\"standard1-bc1a8c20d7f111eda9b8056729c856e9\"," +
+               "\"fileName\":\"nb-1-big-Statistics.db\"}]}";
+    }
+
+    @Override
+    public String expectedEndpointPath()
+    {
+        return ApiEndpointsV1.SNAPSHOTS_ROUTE.replaceAll(ApiEndpointsV1.KEYSPACE_PATH_PARAM, "keyspace1")
+                                             .replaceAll(ApiEndpointsV1.TABLE_PATH_PARAM, "standard1")
+                                             .replaceAll(ApiEndpointsV1.SNAPSHOT_PATH_PARAM, "2023.04.10");
+    }
+
+    @Override
+    public void validateResponse(ListSnapshotFilesResponse response)
+    {
+        assertThat(response).isNotNull();
+        assertThat(response.snapshotFilesInfo()).hasSize(10);
+        assertThat(response.snapshotFilesInfo().get(0).fileName).isEqualTo("nb-1-big-TOC.txt");
+        assertThat(response.snapshotFilesInfo().get(0).size).isEqualTo(80);
+        assertThat(response.snapshotFilesInfo().get(1).fileName).isEqualTo("nb-1-big-Digest.crc32");
+        assertThat(response.snapshotFilesInfo().get(1).size).isEqualTo(10);
+        assertThat(response.snapshotFilesInfo().get(2).fileName).isEqualTo("nb-1-big-CRC.db");
+        assertThat(response.snapshotFilesInfo().get(2).size).isEqualTo(424);
+        assertThat(response.snapshotFilesInfo().get(3).fileName).isEqualTo("nb-1-big-Summary.db");
+        assertThat(response.snapshotFilesInfo().get(3).size).isEqualTo(5222);
+        assertThat(response.snapshotFilesInfo().get(4).fileName).isEqualTo("nb-1-big-Index.db");
+        assertThat(response.snapshotFilesInfo().get(4).size).isEqualTo(500769);
+        assertThat(response.snapshotFilesInfo().get(5).fileName).isEqualTo("manifest.json");
+        assertThat(response.snapshotFilesInfo().get(5).size).isEqualTo(31);
+        assertThat(response.snapshotFilesInfo().get(6).fileName).isEqualTo("nb-1-big-Data.db");
+        assertThat(response.snapshotFilesInfo().get(6).size).isEqualTo(6870000);
+        assertThat(response.snapshotFilesInfo().get(7).fileName).isEqualTo("schema.cql");
+        assertThat(response.snapshotFilesInfo().get(7).size).isEqualTo(864);
+        assertThat(response.snapshotFilesInfo().get(8).fileName).isEqualTo("nb-1-big-Filter.db");
+        assertThat(response.snapshotFilesInfo().get(8).size).isEqualTo(37512);
+        assertThat(response.snapshotFilesInfo().get(9).fileName).isEqualTo("nb-1-big-Statistics.db");
+        assertThat(response.snapshotFilesInfo().get(9).size).isEqualTo(10353);
+    }
+}
diff --git a/client/src/testFixtures/java/org/apache/cassandra/sidecar/client/request/NodeSettingsRequestTestParameters.java b/client/src/testFixtures/java/org/apache/cassandra/sidecar/client/request/NodeSettingsRequestTestParameters.java
new file mode 100644
index 0000000..bf33938
--- /dev/null
+++ b/client/src/testFixtures/java/org/apache/cassandra/sidecar/client/request/NodeSettingsRequestTestParameters.java
@@ -0,0 +1,56 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.sidecar.client.request;
+
+import org.apache.cassandra.sidecar.client.RequestContext;
+import org.apache.cassandra.sidecar.common.ApiEndpointsV1;
+import org.apache.cassandra.sidecar.common.NodeSettings;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Unit tests for client requests accessing the node settings endpoint
+ */
+public class NodeSettingsRequestTestParameters implements RequestTestParameters<NodeSettings>
+{
+    @Override
+    public RequestContext.Builder specificRequest(RequestContext.Builder requestContextBuilder)
+    {
+        return requestContextBuilder.nodeSettingsRequest();
+    }
+
+    @Override
+    public String okResponseBody()
+    {
+        return "{\"partitioner\":\"test-partitioner\", \"releaseVersion\": \"4.0.0\"}";
+    }
+
+    @Override
+    public String expectedEndpointPath()
+    {
+        return ApiEndpointsV1.NODE_SETTINGS_ROUTE;
+    }
+
+    @Override
+    public void validateResponse(NodeSettings settings)
+    {
+        assertThat(settings.partitioner()).isEqualTo("test-partitioner");
+        assertThat(settings.releaseVersion()).isEqualTo("4.0.0");
+    }
+}
diff --git a/client/src/testFixtures/java/org/apache/cassandra/sidecar/client/request/RequestExecutorTest.java b/client/src/testFixtures/java/org/apache/cassandra/sidecar/client/request/RequestExecutorTest.java
new file mode 100644
index 0000000..3a08423
--- /dev/null
+++ b/client/src/testFixtures/java/org/apache/cassandra/sidecar/client/request/RequestExecutorTest.java
@@ -0,0 +1,247 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.sidecar.client.request;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Stream;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.extension.ExtensionContext;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.ArgumentsProvider;
+import org.junit.jupiter.params.provider.ArgumentsSource;
+
+import okhttp3.mockwebserver.MockResponse;
+import okhttp3.mockwebserver.MockWebServer;
+import okhttp3.mockwebserver.RecordedRequest;
+import org.apache.cassandra.sidecar.client.HttpClientConfig;
+import org.apache.cassandra.sidecar.client.RequestContext;
+import org.apache.cassandra.sidecar.client.exception.RetriesExhaustedException;
+import org.apache.cassandra.sidecar.client.selection.InstanceSelectionPolicy;
+
+import static io.netty.handler.codec.http.HttpResponseStatus.INTERNAL_SERVER_ERROR;
+import static io.netty.handler.codec.http.HttpResponseStatus.OK;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatException;
+
+/**
+ * Provides base functionality for testing the RequestExecutor
+ *
+ * @param <T> the type of the Request to test
+ */
+public abstract class RequestExecutorTest<T> extends BaseRequestTest
+{
+    @AfterEach
+    void cleanup() throws Exception
+    {
+        sidecarClient().close();
+    }
+
+    @ParameterizedTest(name = "testHappyPath {index} => request={0}, async={1}")
+    @ArgumentsSource(TestParameters.class)
+    public void testHappyPath(RequestTestParameters<T> parameters, boolean async) throws Exception
+    {
+        try (MockWebServer server = new MockWebServer())
+        {
+            server.enqueue(new MockResponse().setResponseCode(OK.code()).setBody(parameters.okResponseBody()));
+
+            // Start the server.
+            server.start();
+
+            runTestScenario(parameters, buildRandomFromServerList(server), async);
+
+            RecordedRequest request = server.takeRequest();
+            assertThat(request.getPath()).isEqualTo(parameters.expectedEndpointPath());
+            assertThat(request.getHeader("User-Agent")).isEqualTo("sidecar-client-test/1.0.0");
+        }
+    }
+
+    @ParameterizedTest(name = "testRetriesOnTimeoutWithSlowServer {index} => request={0}, async={1}")
+    @ArgumentsSource(TestParameters.class)
+    public void testRetriesOnTimeoutWithSlowServer(RequestTestParameters<T> parameters, boolean async) throws Exception
+    {
+        String happyPathResponse = parameters.okResponseBody();
+        MockWebServer slowServer = new MockWebServer();
+        try (MockWebServer fastServer = new MockWebServer())
+        {
+            // something egregiously slow
+            slowServer.enqueue(new MockResponse().setBodyDelay(5, TimeUnit.MINUTES)
+                                                 .setResponseCode(OK.code())
+                                                 .setBody(happyPathResponse));
+            fastServer.enqueue(new MockResponse().setResponseCode(OK.code()).setBody(happyPathResponse));
+
+            // Start both servers.
+            slowServer.start();
+            fastServer.start();
+
+            runTestScenario(parameters, buildFromServerList(slowServer, fastServer), async);
+
+            assertThat(slowServer.getRequestCount()).isEqualTo(1);
+            RecordedRequest requestToSlowServer = slowServer.takeRequest();
+            assertThat(requestToSlowServer.getPath()).isEqualTo(parameters.expectedEndpointPath());
+
+            assertThat(fastServer.getRequestCount()).isEqualTo(1);
+            RecordedRequest requestToFastServer = fastServer.takeRequest();
+            assertThat(requestToFastServer.getPath()).isEqualTo(parameters.expectedEndpointPath());
+            assertThat(requestToFastServer.getHeader("User-Agent")).isEqualTo("sidecar-client-test/1.0.0");
+        }
+    }
+
+    @ParameterizedTest(name = "testRetriesWithServerReturningServerError {index} => request={0}, async={1}")
+    @ArgumentsSource(TestParameters.class)
+    public void testRetriesWithServerReturningServerError(RequestTestParameters<T> parameters, boolean async)
+    throws Exception
+    {
+        try (MockWebServer serverErrorServer = new MockWebServer();
+             MockWebServer normalOperatingServer = new MockWebServer())
+        {
+            serverErrorServer.enqueue(new MockResponse().setResponseCode(INTERNAL_SERVER_ERROR.code())
+                                                        .setBody(parameters.serverErrorResponseBody()));
+            normalOperatingServer.enqueue(new MockResponse().setResponseCode(OK.code())
+                                                            .setBody(parameters.okResponseBody()));
+
+            // Start both servers.
+            serverErrorServer.start();
+            normalOperatingServer.start();
+
+            runTestScenario(parameters, buildFromServerList(serverErrorServer, normalOperatingServer), async);
+
+            assertThat(serverErrorServer.getRequestCount()).isEqualTo(1);
+            RecordedRequest requestToErrorServer = serverErrorServer.takeRequest();
+            assertThat(requestToErrorServer.getPath()).isEqualTo(parameters.expectedEndpointPath());
+
+            assertThat(normalOperatingServer.getRequestCount()).isEqualTo(1);
+            RecordedRequest requestToNormalServer = normalOperatingServer.takeRequest();
+            assertThat(requestToNormalServer.getPath()).isEqualTo(parameters.expectedEndpointPath());
+            assertThat(requestToNormalServer.getHeader("User-Agent")).isEqualTo("sidecar-client-test/1.0.0");
+        }
+    }
+
+    @ParameterizedTest(name = "testWithSingleServerAndRetries {index} => request={0}, async={1}")
+    @ArgumentsSource(TestParameters.class)
+    public void testWithSingleServerAndRetries(RequestTestParameters<T> parameters, boolean async) throws Exception
+    {
+        try (MockWebServer server = new MockWebServer())
+        {
+            String serverErrorResponseBody = parameters.serverErrorResponseBody();
+            server.enqueue(new MockResponse().setResponseCode(INTERNAL_SERVER_ERROR.code())
+                                             .setBody(serverErrorResponseBody));
+            server.enqueue(new MockResponse().setResponseCode(INTERNAL_SERVER_ERROR.code())
+                                             .setBody(serverErrorResponseBody));
+            server.enqueue(new MockResponse().setResponseCode(OK.code()).setBody(parameters.okResponseBody()));
+
+            // Start both servers.
+            server.start();
+
+            runTestScenario(parameters, buildFromServerList(server), async);
+
+            assertThat(server.getRequestCount()).isEqualTo(3);
+            RecordedRequest request = server.takeRequest();
+            assertThat(request.getPath()).isEqualTo(parameters.expectedEndpointPath());
+            assertThat(request.getHeader("User-Agent")).isEqualTo("sidecar-client-test/1.0.0");
+        }
+    }
+
+    @ParameterizedTest(name = "testWithSingleServerExhaustsRetries {index} => request={0}, async={1}")
+    @ArgumentsSource(TestParameters.class)
+    public void testWithSingleServerExhaustsRetries(RequestTestParameters<T> parameters, boolean async)
+    throws Exception
+    {
+        String serverErrorResponseBody = parameters.serverErrorResponseBody();
+
+        try (MockWebServer server = new MockWebServer())
+        {
+            server.enqueue(new MockResponse().setResponseCode(INTERNAL_SERVER_ERROR.code())
+                                             .setBody(serverErrorResponseBody));
+            server.enqueue(new MockResponse().setResponseCode(INTERNAL_SERVER_ERROR.code())
+                                             .setBody(serverErrorResponseBody));
+            server.enqueue(new MockResponse().setResponseCode(INTERNAL_SERVER_ERROR.code())
+                                             .setBody(serverErrorResponseBody));
+            server.enqueue(new MockResponse().setResponseCode(OK.code()).setBody(parameters.okResponseBody()));
+
+            // Start both servers.
+            server.start();
+
+            assertThatException().isThrownBy(() ->
+                                             runTestScenario(parameters, buildFromServerList(server), async))
+                                 .withCauseInstanceOf(RetriesExhaustedException.class)
+                                 .withMessageContaining("Unable to complete request '")
+                                 .withMessageContaining("' after 3 attempts");
+
+            assertThat(server.getRequestCount()).isEqualTo(3);
+            RecordedRequest request = server.takeRequest();
+            assertThat(request.getPath()).isEqualTo(parameters.expectedEndpointPath());
+            assertThat(request.getHeader("User-Agent")).isEqualTo("sidecar-client-test/1.0.0");
+        }
+    }
+
+    protected HttpClientConfig.Builder<?> httpClientConfigBuilder()
+    {
+        return new HttpClientConfig.Builder<>()
+               .userAgent("sidecar-client-test/1.0.0")
+               .ssl(false)
+               .timeoutMillis(100)
+               .idleTimeoutMillis(100);
+    }
+
+    private void runTestScenario(RequestTestParameters<T> parameters,
+                                 InstanceSelectionPolicy policy,
+                                 boolean async) throws Exception
+    {
+        RequestContext requestContext = parameters.specificRequest(builder(policy)).build();
+        T responseObject;
+        if (async)
+        {
+            CompletableFuture<T> future = sidecarClient().executeRequestAsync(requestContext);
+            responseObject = future.join();
+            assertThat(future).isDone();
+        }
+        else
+        {
+            responseObject = sidecarClient().executeRequest(requestContext, 20, TimeUnit.SECONDS);
+        }
+        assertThat(responseObject).isNotNull();
+        parameters.validateResponse(responseObject);
+    }
+
+    static class TestParameters implements ArgumentsProvider
+    {
+        private final List<RequestTestParameters<?>> requestTestParameters = Arrays.asList(
+        new GossipInfoRequestTestParameters(),
+        new NodeSettingsRequestTestParameters(),
+        new RingRequestForKeyspaceTestParameters(),
+        new RingRequestTestParameters(),
+        new TimeSkewRequestTestParameters(),
+        new FullSchemaRequestTestParameters(),
+        new SchemaRequestTestParameters(),
+        new ListSnapshotFilesRequestTestParameters()
+        );
+
+        @Override
+        public Stream<? extends Arguments> provideArguments(ExtensionContext context)
+        {
+            return Stream.of(true, false)
+                         .flatMap(async -> requestTestParameters.stream().map(p -> Arguments.arguments(p, async)));
+        }
+    }
+}
diff --git a/common/src/main/java/org/apache/cassandra/sidecar/common/MockCassandraFactory.java b/client/src/testFixtures/java/org/apache/cassandra/sidecar/client/request/RequestTestParameters.java
similarity index 61%
copy from common/src/main/java/org/apache/cassandra/sidecar/common/MockCassandraFactory.java
copy to client/src/testFixtures/java/org/apache/cassandra/sidecar/client/request/RequestTestParameters.java
index 5e6cd68..bdf4091 100644
--- a/common/src/main/java/org/apache/cassandra/sidecar/common/MockCassandraFactory.java
+++ b/client/src/testFixtures/java/org/apache/cassandra/sidecar/client/request/RequestTestParameters.java
@@ -16,16 +16,27 @@
  * limitations under the License.
  */
 
-package org.apache.cassandra.sidecar.common;
+package org.apache.cassandra.sidecar.client.request;
+
+import org.apache.cassandra.sidecar.client.RequestContext;
 
 /**
+ * An interface defining the request testing for the client
  *
+ * @param <T> the type
  */
-public class MockCassandraFactory implements ICassandraFactory
+public interface RequestTestParameters<T>
 {
-    @Override
-    public ICassandraAdapter create(CQLSession session, JmxClient jmxClient)
+    RequestContext.Builder specificRequest(RequestContext.Builder requestContextBuilder);
+
+    String okResponseBody();
+
+    String expectedEndpointPath();
+
+    void validateResponse(T responseObject);
+
+    default String serverErrorResponseBody()
     {
-        return null;
+        return "{\"error\":\"some error\"}";
     }
 }
diff --git a/client/src/testFixtures/java/org/apache/cassandra/sidecar/client/request/RingRequestForKeyspaceTestParameters.java b/client/src/testFixtures/java/org/apache/cassandra/sidecar/client/request/RingRequestForKeyspaceTestParameters.java
new file mode 100644
index 0000000..c119b65
--- /dev/null
+++ b/client/src/testFixtures/java/org/apache/cassandra/sidecar/client/request/RingRequestForKeyspaceTestParameters.java
@@ -0,0 +1,77 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.sidecar.client.request;
+
+import java.util.List;
+import java.util.stream.Collectors;
+import java.util.stream.StreamSupport;
+
+import org.apache.cassandra.sidecar.client.RequestContext;
+import org.apache.cassandra.sidecar.common.ApiEndpointsV1;
+import org.apache.cassandra.sidecar.common.data.RingEntry;
+import org.apache.cassandra.sidecar.common.data.RingResponse;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Unit tests for client requests accessing the ring endpoint for a given keyspace
+ */
+public class RingRequestForKeyspaceTestParameters implements RequestTestParameters<RingResponse>
+{
+    public static final String KEYSPACE = "testkeyspace";
+
+    @Override
+    public RequestContext.Builder specificRequest(RequestContext.Builder requestContextBuilder)
+    {
+        return requestContextBuilder.ringRequest(KEYSPACE);
+    }
+
+    @Override
+    public String okResponseBody()
+    {
+        return "[{\"datacenter\":\"LO\",\"address\":\"172.17.0.2\",\"port\":7000,\"rack\":\"101000101\"," +
+               "\"status\":\"Up\",\"state\":\"Normal\",\"load\":\"86.99 KiB\",\"owns\":\"100.00%\"," +
+               "\"token\":\"-9223372036854775808\",\"fqdn\":\"172.17.0.2\"," +
+               "\"hostId\":\"33cae238-8203-41c1-880f-8cdf98ee6720\"},{\"datacenter\":\"LO\"," +
+               "\"address\":\"127.0.0.2\",\"port\":7000,\"rack\":\"101000201\",\"status\":\"Up\"," +
+               "\"state\":\"Normal\",\"load\":\"87 KiB\",\"owns\":\"100.00%\",\"token\":\"-3074457345618258603\"," +
+               "\"fqdn\":\"127.0.0.2\",\"hostId\":\"dba02656-ea8c-4a1d-8011-cbc0dab5f411\"},{\"datacenter\":\"LO\"," +
+               "\"address\":\"127.0.0.3\",\"port\":7000,\"rack\":\"101000301\",\"status\":\"Up\"," +
+               "\"state\":\"Normal\",\"load\":\"87 KiB\",\"owns\":\"100.00%\",\"token\":\"3074457345618258602\"," +
+               "\"fqdn\":\"127.0.0.3\",\"hostId\":\"be19c254-becb-40b9-8951-30c589c7028e\"}]";
+    }
+
+    @Override
+    public String expectedEndpointPath()
+    {
+        return ApiEndpointsV1.RING_ROUTE_PER_KEYSPACE.replaceAll(ApiEndpointsV1.KEYSPACE_PATH_PARAM, KEYSPACE);
+    }
+
+    @Override
+    public void validateResponse(RingResponse response)
+    {
+        assertThat(response.size()).isEqualTo(3);
+        List<RingEntry> ringEntryList = StreamSupport
+                                        .stream(((Iterable<RingEntry>) response).spliterator(), false)
+                                        .collect(Collectors.toList());
+        assertThat(ringEntryList.get(0).token()).isEqualTo("-9223372036854775808");
+        assertThat(ringEntryList.get(1).token()).isEqualTo("-3074457345618258603");
+        assertThat(ringEntryList.get(2).token()).isEqualTo("3074457345618258602");
+    }
+}
diff --git a/client/src/testFixtures/java/org/apache/cassandra/sidecar/client/request/RingRequestTestParameters.java b/client/src/testFixtures/java/org/apache/cassandra/sidecar/client/request/RingRequestTestParameters.java
new file mode 100644
index 0000000..0f020df
--- /dev/null
+++ b/client/src/testFixtures/java/org/apache/cassandra/sidecar/client/request/RingRequestTestParameters.java
@@ -0,0 +1,82 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.cassandra.sidecar.client.request;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.apache.cassandra.sidecar.client.RequestContext;
+import org.apache.cassandra.sidecar.common.ApiEndpointsV1;
+import org.apache.cassandra.sidecar.common.data.RingEntry;
+import org.apache.cassandra.sidecar.common.data.RingResponse;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Unit tests for client requests accessing the ring endpoint
+ */
+public class RingRequestTestParameters implements RequestTestParameters<RingResponse>
+{
+    @Override
+    public RequestContext.Builder specificRequest(RequestContext.Builder requestContextBuilder)
+    {
+        return requestContextBuilder.ringRequest();
+    }
+
+    @Override
+    public String okResponseBody()
+    {
+        return "[{\"datacenter\":\"dc1\",\"address\":\"172.17.0.2\",\"port\":7000,\"rack\":\"101000101\"," +
+               "\"status\":\"Up\",\"state\":\"Normal\",\"load\":\"81.74 KiB\",\"owns\":\"66.67%\"," +
+               "\"token\":\"-9223372036854775808\",\"fqdn\":\"172.17.0.2\",\"hostId\":" +
+               "\"33cae238-8203-41c1-880f-8cdf98ee6720\"},{\"datacenter\":\"dc1\",\"address\":\"127.0.0.2\"," +
+               "\"port\":7000,\"rack\":\"101000201\",\"status\":\"Up\",\"state\":\"Normal\",\"load\":\"81.74 KiB\"," +
+               "\"owns\":\"66.67%\",\"token\":\"-3074457345618258603\",\"fqdn\":\"127.0.0.2\",\"hostId\":" +
... 19441 lines suppressed ...


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@cassandra.apache.org
For additional commands, e-mail: commits-help@cassandra.apache.org