Repository: redis/lettuce (java)

Issue:
Connection handshake fails with `ERR unknown command CLIENT, with args beginning with: SETINFO lib-name`
## Bug Report

<!-- First of all: Have you checked the docs https://github.com/lettuce-io/lettuce-core/wiki, GitHub issues, or Stack Overflow whether someone else has already reported your issue? -->

#### Current Behavior

The connection does not work and fails to connect to Google Cloud Redis (Memory Store) v7.2 with the following error CLIENT 'SETINFO'. The Client Setinfo not supported by GCP Redis

<details>
<summary>Stack trace</summary>

```java
Exception in thread "main" io.lettuce.core.RedisConnectionException: Unable to connect to IP_ADDRESS/<unresolved>:6379
	at io.lettuce.core.RedisConnectionException.create(RedisConnectionException.java:78)
	at io.lettuce.core.RedisConnectionException.create(RedisConnectionException.java:56)
	at io.lettuce.core.AbstractRedisClient.getConnection(AbstractRedisClient.java:350)
	at io.lettuce.core.RedisClient.connect(RedisClient.java:215)
	at io.lettuce.core.RedisClient.connect(RedisClient.java:200)
	at com.wayfair.smart.ds587.demo.bt.graphql.RedisTest.main(RedisTest.java:29)
Caused by: io.lettuce.core.RedisCommandExecutionException: ERR unknown command 'CLIENT', with args beginning with: 'SETINFO' 'lib-name' 'Lettuce'
	at io.lettuce.core.internal.ExceptionFactory.createExecutionException(ExceptionFactory.java:147)
	at io.lettuce.core.internal.ExceptionFactory.createExecutionException(ExceptionFactory.java:116)
	at io.lettuce.core.protocol.AsyncCommand.completeResult(AsyncCommand.java:120)
	at io.lettuce.core.protocol.AsyncCommand.complete(AsyncCommand.java:111)
	at io.lettuce.core.protocol.CommandWrapper.complete(CommandWrapper.java:63)
	at io.lettuce.core.protocol.CommandHandler.complete(CommandHandler.java:745)
	at io.lettuce.core.protocol.CommandHandler.decode(CommandHandler.java:680)
	at io.lettuce.core.protocol.CommandHandler.channelRead(CommandHandler.java:597)
	at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:442)
	at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:420)
	at io.netty.channel.AbstractChannelHandlerContext.fireChannelRead(AbstractChannelHandlerContext.java:412)
	at io.netty.channel.DefaultChannelPipeline$HeadContext.channelRead(DefaultChannelPipeline.java:1410)
	at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:440)
	at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:420)
	at io.netty.channel.DefaultChannelPipeline.fireChannelRead(DefaultChannelPipeline.java:919)
	at io.netty.channel.epoll.AbstractEpollStreamChannel$EpollStreamUnsafe.epollInReady(AbstractEpollStreamChannel.java:800)
	at io.netty.channel.epoll.EpollEventLoop.processReady(EpollEventLoop.java:509)
	at io.netty.channel.epoll.EpollEventLoop.run(EpollEventLoop.java:407)
	at io.netty.util.concurrent.SingleThreadEventExecutor$4.run(SingleThreadEventExecutor.java:997)
	at io.netty.util.internal.ThreadExecutorMap$2.run(ThreadExecutorMap.java:74)
	at io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30)
	at java.base/java.lang.Thread.run(Unknown Source)
```

</details>

#### Input Code

<!-- Java/Kotlin/Scala/Groovy/… or Repo link if applicable: -->

<details>
<summary>Input Code</summary>

```java
// your code here;
```

</details>

#### Expected behavior/code

<!-- A clear and concise description of what you expected to happen (or code). -->

#### Environment

- Lettuce version(s): 6.3.2.RELEASE
- Redis version: Google Cloud Redis (Memory Store) v7.2 


#### Possible Solution

<!-- Only if you have suggestions on a fix for the bug -->

#### Additional context

<!-- Add any other context about the problem here. Do not add code as screenshots. -->


Pull request:
Send the CLIENT SETINFO command in a fire-and-forget way
Closes #2817 

This solution aims to bring consistency between the Lettuce driver and most other drivers maintained by the Redis team.
The current approach is - instead of probing for the version of the remote server and deciding based on that wether or not to send the CLIENT SETINFO command - to always send it and ignore the server response. There are two main reasons behind this: different flavors of the Redis server handle this in different versions and also the information sent is not critical to the operation of the driver, so failures should not necessary terminate the connection.

1. Tested against Redis 6.0 - verified no connection failures are shown
2. Tested against Redis  (unstable) - verified no connection failures are shown; and that CLIENT LIST contains the expected data on the `lib-name` and `lib-ver` are set properly.
3. Added unit tests

Make sure that:

- [x] You have read the [contribution guidelines](https://github.com/lettuce-io/lettuce-core/blob/main/.github/CONTRIBUTING.md).
- [x] You have created a feature request first to discuss your contribution intent. Please reference the feature request ticket number in the pull request.
- [x] You use the code formatters provided [here](https://github.com/lettuce-io/lettuce-core/blob/main/formatting.xml) and have them applied to your changes. Don’t submit any formatting related changes.
- [x] You submit test cases (unit or integration tests) that back your changes.

 <!--
Great! Live long and prosper.
-->


Patch:
diff --git a/src/main/java/io/lettuce/core/RedisHandshake.java b/src/main/java/io/lettuce/core/RedisHandshake.java
index 111d30328..a7ccc7a9a 100644
--- a/src/main/java/io/lettuce/core/RedisHandshake.java
+++ b/src/main/java/io/lettuce/core/RedisHandshake.java
@@ -38,6 +38,8 @@ import io.lettuce.core.protocol.Command;
 import io.lettuce.core.protocol.ConnectionInitializer;
 import io.lettuce.core.protocol.ProtocolVersion;
 import io.netty.channel.Channel;
+import io.netty.util.internal.logging.InternalLogger;
+import io.netty.util.internal.logging.InternalLoggerFactory;
 
 /**
  * Redis RESP2/RESP3 handshake using the configured {@link ProtocolVersion} and other options for connection initialization and
@@ -49,7 +51,7 @@ import io.netty.channel.Channel;
  */
 class RedisHandshake implements ConnectionInitializer {
 
-    private static final RedisVersion CLIENT_SET_INFO_SINCE = RedisVersion.of("7.2");
+    private static final InternalLogger LOG = InternalLoggerFactory.getInstance(RedisHandshake.class);
 
     private final RedisCommandBuilder<String, String> commandBuilder = new RedisCommandBuilder<>(StringCodec.UTF8);
 
@@ -99,8 +101,21 @@ class RedisHandshake implements ConnectionInitializer {
                     new RedisConnectionException("Protocol version" + this.requestedProtocolVersion + " not supported"));
         }
 
-        return handshake.thenCompose(
-                ignore -> applyPostHandshake(channel, connectionState.getRedisVersion(), getNegotiatedProtocolVersion()));
+        // post-handshake commands, whose execution failures would cause the connection to be considered
+        // unsuccessfully established
+        CompletableFuture<Void> postHandshake = applyPostHandshake(channel);
+
+        // post-handshake commands, executed in a 'fire and forget' manner, to avoid having to react to different
+        // implementations or versions of the server runtime, and whose execution result (whether a success or a
+        // failure ) should not alter the outcome of the connection attempt
+        CompletableFuture<Void> connectionMetadata = applyConnectionMetadata(channel).handle((result, error) -> {
+            if (error != null) {
+                LOG.debug("Error applying connection metadata", error);
+            }
+            return null;
+        });
+
+        return handshake.thenCompose(ignore -> postHandshake).thenCompose(ignore -> connectionMetadata);
     }
 
     private CompletionStage<?> tryHandshakeResp3(Channel channel) {
@@ -237,41 +252,44 @@ class RedisHandshake implements ConnectionInitializer {
         return dispatch(channel, this.commandBuilder.hello(3, null, null, connectionState.getClientName()));
     }
 
-    private CompletableFuture<Void> applyPostHandshake(Channel channel, String redisVersion,
-            ProtocolVersion negotiatedProtocolVersion) {
+    private CompletableFuture<Void> applyPostHandshake(Channel channel) {
 
         List<AsyncCommand<?, ?, ?>> postHandshake = new ArrayList<>();
 
-        ConnectionMetadata metadata = connectionState.getConnectionMetadata();
+        if (connectionState.getDb() > 0) {
+            postHandshake.add(new AsyncCommand<>(this.commandBuilder.select(connectionState.getDb())));
+        }
 
-        if (metadata.getClientName() != null && negotiatedProtocolVersion == ProtocolVersion.RESP2) {
-            postHandshake.add(new AsyncCommand<>(this.commandBuilder.clientSetname(connectionState.getClientName())));
+        if (connectionState.isReadOnly()) {
+            postHandshake.add(new AsyncCommand<>(this.commandBuilder.readOnly()));
         }
 
-        if (negotiatedProtocolVersion == ProtocolVersion.RESP3) {
+        if (postHandshake.isEmpty()) {
+            return CompletableFuture.completedFuture(null);
+        }
 
-            RedisVersion currentVersion = RedisVersion.of(redisVersion);
+        return dispatch(channel, postHandshake);
+    }
 
-            if (currentVersion.isGreaterThanOrEqualTo(CLIENT_SET_INFO_SINCE)) {
+    private CompletableFuture<Void> applyConnectionMetadata(Channel channel) {
 
-                if (LettuceStrings.isNotEmpty(metadata.getLibraryName())) {
-                    postHandshake
-                            .add(new AsyncCommand<>(this.commandBuilder.clientSetinfo("lib-name", metadata.getLibraryName())));
-                }
+        List<AsyncCommand<?, ?, ?>> postHandshake = new ArrayList<>();
 
-                if (LettuceStrings.isNotEmpty(metadata.getLibraryVersion())) {
-                    postHandshake.add(
-                            new AsyncCommand<>(this.commandBuilder.clientSetinfo("lib-ver", metadata.getLibraryVersion())));
-                }
-            }
+        ConnectionMetadata metadata = connectionState.getConnectionMetadata();
+        ProtocolVersion negotiatedProtocolVersion = getNegotiatedProtocolVersion();
+
+        if (metadata.getClientName() != null && negotiatedProtocolVersion == ProtocolVersion.RESP2) {
+            postHandshake.add(new AsyncCommand<>(this.commandBuilder.clientSetname(connectionState.getClientName())));
         }
 
-        if (connectionState.getDb() > 0) {
-            postHandshake.add(new AsyncCommand<>(this.commandBuilder.select(connectionState.getDb())));
+        if (LettuceStrings.isNotEmpty(metadata.getLibraryName())) {
+            postHandshake.add(
+                    new AsyncCommand<>(this.commandBuilder.clientSetinfo("lib-name", metadata.getLibraryName())));
         }
 
-        if (connectionState.isReadOnly()) {
-            postHandshake.add(new AsyncCommand<>(this.commandBuilder.readOnly()));
+        if (LettuceStrings.isNotEmpty(metadata.getLibraryVersion())) {
+            postHandshake.add(
+                    new AsyncCommand<>(this.commandBuilder.clientSetinfo("lib-ver", metadata.getLibraryVersion())));
         }
 
         if (postHandshake.isEmpty()) {


Test patch:
diff --git a/src/test/java/io/lettuce/core/RedisHandshakeUnitTests.java b/src/test/java/io/lettuce/core/RedisHandshakeUnitTests.java
index ad818c25d..ed96e8be7 100644
--- a/src/test/java/io/lettuce/core/RedisHandshakeUnitTests.java
+++ b/src/test/java/io/lettuce/core/RedisHandshakeUnitTests.java
@@ -3,7 +3,9 @@ package io.lettuce.core;
 import static org.assertj.core.api.Assertions.*;
 
 import java.nio.ByteBuffer;
+import java.util.List;
 import java.util.Map;
+import java.util.concurrent.CompletionStage;
 
 import org.junit.jupiter.api.Test;
 
@@ -19,6 +21,8 @@ import io.netty.channel.embedded.EmbeddedChannel;
  */
 class RedisHandshakeUnitTests {
 
+    public static final String ERR_UNKNOWN_COMMAND = "ERR unknown command 'CLIENT', with args beginning with: 'SETINFO' 'lib-name' 'Lettuce'";
+
     @Test
     void handshakeWithResp3ShouldPass() {
 
@@ -71,6 +75,34 @@ class RedisHandshakeUnitTests {
         assertThat(state.getNegotiatedProtocolVersion()).isEqualTo(ProtocolVersion.RESP2);
     }
 
+    @Test
+    void handshakeFireAndForgetPostHandshake() {
+
+        EmbeddedChannel channel = new EmbeddedChannel(true, false);
+
+        ConnectionMetadata connectionMetdata = new ConnectionMetadata();
+        connectionMetdata.setLibraryName("library-name");
+        connectionMetdata.setLibraryVersion("library-version");
+
+        ConnectionState state = new ConnectionState();
+        state.setCredentialsProvider(new StaticCredentialsProvider(null, null));
+        state.apply(connectionMetdata);
+        RedisHandshake handshake = new RedisHandshake(null, false, state);
+        CompletionStage<Void> handshakeInit = handshake.initialize(channel);
+
+        AsyncCommand<String, String, Map<String, String>> hello = channel.readOutbound();
+        helloResponse(hello.getOutput());
+        hello.complete();
+
+        List<AsyncCommand<String, String, Map<String, String>>> postHandshake = channel.readOutbound();
+        postHandshake.get(0).getOutput().setError(ERR_UNKNOWN_COMMAND);
+        postHandshake.get(0).completeExceptionally(new RedisException(ERR_UNKNOWN_COMMAND));
+        postHandshake.get(0).complete();
+
+        assertThat(postHandshake.size()).isEqualTo(2);
+        assertThat(handshakeInit.toCompletableFuture().isCompletedExceptionally()).isFalse();
+    }
+
     @Test
     void shouldParseVersionWithCharacters() {