From 45c92e4c14d3a886ba56f4b1a0b0fbebadfdcc19 Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Wed, 16 Sep 2026 18:45:43 +0200 Subject: [PATCH 1/5] Treat a send rejected as a duplicate as a message that was delivered Co-Authored-By: Claude Opus 5 (1M context) --- .../chat/android/client/errors/ChatError.kt | 12 +++++++++ .../android/client/errors/ChatErrorTest.kt | 26 +++++++++++++++++++ .../internal/SendMessageListenerDatabase.kt | 6 +++++ .../SendMessageListenerDatabaseTest.kt | 26 +++++++++++++++++++ .../internal/SendMessageListenerState.kt | 6 +++++ .../android/state/internal/SyncManagerTest.kt | 23 ++++++++++++++++ .../internal/SendMessageListenerStateTest.kt | 24 +++++++++++++++++ 7 files changed, 123 insertions(+) diff --git a/stream-chat-android-core/src/main/java/io/getstream/chat/android/client/errors/ChatError.kt b/stream-chat-android-core/src/main/java/io/getstream/chat/android/client/errors/ChatError.kt index ce3edc5c9489..30e1cf14769c 100644 --- a/stream-chat-android-core/src/main/java/io/getstream/chat/android/client/errors/ChatError.kt +++ b/stream-chat-android-core/src/main/java/io/getstream/chat/android/client/errors/ChatError.kt @@ -28,6 +28,7 @@ import java.net.UnknownHostException private const val HTTP_TOO_MANY_REQUESTS = 429 private const val HTTP_TIMEOUT = 408 private const val HTTP_API_ERROR = 500 +private const val MESSAGE_ALREADY_EXISTS = "already exists" /** * Creates [Error.NetworkError] from [ChatErrorCode] with custom status code and optional cause. @@ -79,6 +80,17 @@ public fun Error.isPermanent(): Boolean { } } +/** + * @return If the error reports that the message being sent is already stored on the server, which happens + * when a send that did reach the backend is retried. The backend answers with the generic validation code, + * so the message has to be matched as well. + */ +@InternalStreamChatApi +public fun Error.isMessageAlreadyExists(): Boolean = + this is Error.NetworkError && + serverErrorCode == ChatErrorCode.VALIDATION_ERROR.code && + message.contains(MESSAGE_ALREADY_EXISTS) + /** * Copies the original [Error] objects with custom message. * diff --git a/stream-chat-android-core/src/test/java/io/getstream/chat/android/client/errors/ChatErrorTest.kt b/stream-chat-android-core/src/test/java/io/getstream/chat/android/client/errors/ChatErrorTest.kt index 240b282e95c6..28c4316ac4a5 100644 --- a/stream-chat-android-core/src/test/java/io/getstream/chat/android/client/errors/ChatErrorTest.kt +++ b/stream-chat-android-core/src/test/java/io/getstream/chat/android/client/errors/ChatErrorTest.kt @@ -63,6 +63,15 @@ internal class ChatErrorTest { error.isPermanent() `should be equal to` isPermanent } + @ParameterizedTest + @MethodSource("isMessageAlreadyExistsArguments") + fun testIsMessageAlreadyExists( + error: Error, + isAlreadyExists: Boolean, + ) { + error.isMessageAlreadyExists() `should be equal to` isAlreadyExists + } + @ParameterizedTest @MethodSource("copyWithMessageArguments") fun testCopyWithMessage( @@ -144,6 +153,23 @@ internal class ChatErrorTest { ) } + @JvmStatic + fun isMessageAlreadyExistsArguments() = listOf( + Arguments.of(alreadyExistsError(), true), + Arguments.of(alreadyExistsError(message = "a message with ID abc already exists"), true), + Arguments.of(alreadyExistsError(message = "channel members are limited to 100"), false), + Arguments.of( + alreadyExistsError(code = ChatErrorCode.AUTHENTICATION_ERROR.code), + false, + ), + Arguments.of(Error.GenericError("a message with ID abc already exists"), false), + ) + + private fun alreadyExistsError( + code: Int = ChatErrorCode.VALIDATION_ERROR.code, + message: String = "a message with ID abc already exists", + ): Error.NetworkError = Error.NetworkError(message, code, 400) + private fun networkError( code: ChatErrorCode, statusCode: Int = 400, diff --git a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/plugin/listener/internal/SendMessageListenerDatabase.kt b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/plugin/listener/internal/SendMessageListenerDatabase.kt index 677ff79dec70..b895988a990b 100644 --- a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/plugin/listener/internal/SendMessageListenerDatabase.kt +++ b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/plugin/listener/internal/SendMessageListenerDatabase.kt @@ -16,6 +16,7 @@ package io.getstream.chat.android.offline.plugin.listener.internal +import io.getstream.chat.android.client.errors.isMessageAlreadyExists import io.getstream.chat.android.client.errors.isPermanent import io.getstream.chat.android.client.extensions.enrichWithCid import io.getstream.chat.android.client.extensions.internal.users @@ -79,6 +80,11 @@ internal class SendMessageListenerDatabase( message: Message, error: Error, ) { + if (error.isMessageAlreadyExists()) { + StreamLog.w(TAG) { "[handleSendMessageFailure] message already stored server side" } + messageRepository.insertMessage(message.copy(syncStatus = SyncStatus.COMPLETED)) + return + } val isPermanentError = error.isPermanent() StreamLog.w(TAG) { "[handleSendMessageFailure] isPermanentError: $isPermanentError" } diff --git a/stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/plugin/listener/internal/SendMessageListenerDatabaseTest.kt b/stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/plugin/listener/internal/SendMessageListenerDatabaseTest.kt index 0b4754b3554c..315677b61a80 100644 --- a/stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/plugin/listener/internal/SendMessageListenerDatabaseTest.kt +++ b/stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/plugin/listener/internal/SendMessageListenerDatabaseTest.kt @@ -16,6 +16,7 @@ package io.getstream.chat.android.offline.plugin.listener.internal +import io.getstream.chat.android.client.errors.ChatErrorCode import io.getstream.chat.android.client.extensions.internal.users import io.getstream.chat.android.client.persistance.repository.MessageRepository import io.getstream.chat.android.client.persistance.repository.UserRepository @@ -110,6 +111,31 @@ internal class SendMessageListenerDatabaseTest { ) } + @Test + fun `when the send is rejected as a duplicate, the message should be stored as completed`() = runTest { + whenever(messageRepository.selectMessage(any())) doReturn null + + val testMessage = randomMessage(syncStatus = SyncStatus.SYNC_NEEDED) + val alreadyExists = Error.NetworkError( + message = "a message with ID ${testMessage.id} already exists", + serverErrorCode = ChatErrorCode.VALIDATION_ERROR.code, + statusCode = 400, + ) + + sendMessageListenerDatabase.onMessageSendResult( + result = Result.Failure(alreadyExists), + channelType = randomString(), + channelId = randomString(), + message = testMessage, + ) + + verify(messageRepository).insertMessage( + argThat { message -> + message.id == testMessage.id && message.syncStatus == SyncStatus.COMPLETED + }, + ) + } + @Test fun `when message is already in database and completed, it should not be inserted again`() = runTest { whenever(messageRepository.selectMessage(any())) doReturn randomMessage(syncStatus = SyncStatus.COMPLETED) diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/SendMessageListenerState.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/SendMessageListenerState.kt index 678c16f5eeea..7edb1e1f4e4c 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/SendMessageListenerState.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/SendMessageListenerState.kt @@ -16,6 +16,7 @@ package io.getstream.chat.android.state.plugin.listener.internal +import io.getstream.chat.android.client.errors.isMessageAlreadyExists import io.getstream.chat.android.client.errors.isPermanent import io.getstream.chat.android.client.extensions.enrichWithCid import io.getstream.chat.android.client.plugin.listeners.SendMessageListener @@ -85,6 +86,11 @@ internal class SendMessageListenerState(private val logic: LogicRegistry) : Send message: Message, error: Error, ) { + if (error.isMessageAlreadyExists()) { + StreamLog.w(TAG) { "[handleSendMessageFailure] message already stored server side" } + message.copy(syncStatus = SyncStatus.COMPLETED).also(::updateState) + return + } val isPermanentError = error.isPermanent() StreamLog.w(TAG) { "[handleSendMessageFailure] isPermanentError: $isPermanentError" } message.copy( diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/internal/SyncManagerTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/internal/SyncManagerTest.kt index d82ecd73a00e..b9efab1b7702 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/internal/SyncManagerTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/internal/SyncManagerTest.kt @@ -82,6 +82,7 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.TestInstance import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argThat import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doReturn import org.mockito.kotlin.eq @@ -514,6 +515,28 @@ internal class SyncManagerTest { verify(repositoryFacade, never()).insertMessage(any()) } + @Test + fun `retryMessages should not fail a message the server already stored`() = runTest(testDispatcher) { + val message = localRandomMessage() + val channelClient: ChannelClient = mock() + val alreadyExists = Error.NetworkError( + message = "a message with ID ${'$'}{message.id} already exists", + serverErrorCode = ChatErrorCode.VALIDATION_ERROR.code, + statusCode = 400, + ) + whenever(repositoryFacade.selectMessageIdsBySyncState(SyncStatus.SYNC_NEEDED)) doReturn listOf(message.id) + whenever(repositoryFacade.selectMessage(message.id)) doReturn message + whenever(chatClient.channel(message.cid)) doReturn channelClient + whenever(channelClient.sendMessage(message)) doReturn TestCall(Result.Failure(alreadyExists)) + + val sut = buildSyncManager() + sut.retryMessages() + + verify(repositoryFacade, never()).insertMessage( + argThat { message -> message.syncStatus == SyncStatus.FAILED_PERMANENTLY }, + ) + } + @Test fun `when isAutomaticSyncOnReconnectEnabled is true, getSyncHistory should be called on connected event`() = runTest(testDispatcher) { diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/listener/internal/SendMessageListenerStateTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/listener/internal/SendMessageListenerStateTest.kt index 36699c29775b..d2198f614d28 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/listener/internal/SendMessageListenerStateTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/listener/internal/SendMessageListenerStateTest.kt @@ -16,6 +16,7 @@ package io.getstream.chat.android.state.plugin.listener.internal +import io.getstream.chat.android.client.errors.ChatErrorCode import io.getstream.chat.android.client.extensions.cidToTypeAndId import io.getstream.chat.android.models.SyncStatus import io.getstream.chat.android.randomMessage @@ -153,6 +154,29 @@ internal class SendMessageListenerStateTest { ) } + @Test + fun `when the send is rejected as a duplicate, the message should be marked completed`() = runTest { + val testMessage = randomMessage(syncStatus = SyncStatus.SYNC_NEEDED) + val alreadyExists = Error.NetworkError( + message = "a message with ID ${testMessage.id} already exists", + serverErrorCode = ChatErrorCode.VALIDATION_ERROR.code, + statusCode = 400, + ) + + sendMessageListener.onMessageSendResult( + result = Result.Failure(alreadyExists), + channelType = randomString(), + channelId = randomString(), + message = testMessage, + ) + + verify(channelLogic).upsertMessage( + argThat { message -> + message.id == testMessage.id && message.syncStatus == SyncStatus.COMPLETED + }, + ) + } + @Test fun `when no old message exists, createdLocallyAt should be null on success`() = runTest { val testMessage = randomMessage( From af5a23cce5465788452c0a0b414e71620b754a94 Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Wed, 16 Sep 2026 18:45:43 +0200 Subject: [PATCH 2/5] Retry a send that failed with a transport error instead of failing it permanently Co-Authored-By: Claude Opus 5 (1M context) --- .../getstream/chat/android/client/errors/ChatError.kt | 7 ++++--- .../chat/android/client/errors/ChatErrorTest.kt | 10 ++++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/stream-chat-android-core/src/main/java/io/getstream/chat/android/client/errors/ChatError.kt b/stream-chat-android-core/src/main/java/io/getstream/chat/android/client/errors/ChatError.kt index 30e1cf14769c..e5d7a2b7240a 100644 --- a/stream-chat-android-core/src/main/java/io/getstream/chat/android/client/errors/ChatError.kt +++ b/stream-chat-android-core/src/main/java/io/getstream/chat/android/client/errors/ChatError.kt @@ -19,8 +19,7 @@ package io.getstream.chat.android.client.errors import io.getstream.chat.android.core.internal.InternalStreamChatApi import io.getstream.result.Error import io.getstream.result.Error.NetworkError.Companion.UNKNOWN_STATUS_CODE -import java.net.ConnectException -import java.net.UnknownHostException +import java.io.IOException /** * Represents the error in the SDK. @@ -72,7 +71,9 @@ public fun Error.isPermanent(): Boolean { when { statusCode in temporaryErrors -> false - cause is UnknownHostException || cause is ConnectException -> false + // Transport failures leave the outcome unknown; a send that did land is caught by + // isMessageAlreadyExists when it is retried + cause is IOException -> false else -> true } } else { diff --git a/stream-chat-android-core/src/test/java/io/getstream/chat/android/client/errors/ChatErrorTest.kt b/stream-chat-android-core/src/test/java/io/getstream/chat/android/client/errors/ChatErrorTest.kt index 28c4316ac4a5..9d7c1333fd25 100644 --- a/stream-chat-android-core/src/test/java/io/getstream/chat/android/client/errors/ChatErrorTest.kt +++ b/stream-chat-android-core/src/test/java/io/getstream/chat/android/client/errors/ChatErrorTest.kt @@ -22,6 +22,8 @@ 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 java.net.SocketException +import java.net.SocketTimeoutException import java.net.UnknownHostException internal class ChatErrorTest { @@ -104,6 +106,14 @@ internal class ChatErrorTest { networkError(ChatErrorCode.NETWORK_FAILED, statusCode = 400, cause = UnknownHostException()), false, ), + Arguments.of( + networkError(ChatErrorCode.NETWORK_FAILED, statusCode = 400, cause = SocketTimeoutException()), + false, + ), + Arguments.of( + networkError(ChatErrorCode.NETWORK_FAILED, statusCode = 400, cause = SocketException()), + false, + ), Arguments.of(networkError(ChatErrorCode.NETWORK_FAILED, 400), true), Arguments.of(networkError(ChatErrorCode.PARSER_ERROR, 400), true), Arguments.of(networkError(ChatErrorCode.SOCKET_CLOSED, 400), true), From bd220bf7a1960d9de9eabaff2b223e8d29c154ca Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Thu, 17 Sep 2026 16:17:09 +0200 Subject: [PATCH 3/5] Interpolate the message id in the duplicate send test Co-Authored-By: Claude Opus 5 (1M context) --- .../io/getstream/chat/android/state/internal/SyncManagerTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/internal/SyncManagerTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/internal/SyncManagerTest.kt index b9efab1b7702..00896dc4b3a6 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/internal/SyncManagerTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/internal/SyncManagerTest.kt @@ -520,7 +520,7 @@ internal class SyncManagerTest { val message = localRandomMessage() val channelClient: ChannelClient = mock() val alreadyExists = Error.NetworkError( - message = "a message with ID ${'$'}{message.id} already exists", + message = "a message with ID ${message.id} already exists", serverErrorCode = ChatErrorCode.VALIDATION_ERROR.code, statusCode = 400, ) From b16793a92cefb5beb7f0e0e970c69b14fced2eb5 Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Fri, 18 Sep 2026 10:48:06 +0200 Subject: [PATCH 4/5] Stamp a creation date on a message resolved as already stored Co-Authored-By: Claude Opus 5 (1M context) --- .../listener/internal/SendMessageListenerDatabase.kt | 9 ++++++++- .../internal/SendMessageListenerDatabaseTest.kt | 12 ++++++++++-- .../listener/internal/SendMessageListenerState.kt | 7 ++++++- .../internal/SendMessageListenerStateTest.kt | 11 +++++++++-- 4 files changed, 33 insertions(+), 6 deletions(-) diff --git a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/plugin/listener/internal/SendMessageListenerDatabase.kt b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/plugin/listener/internal/SendMessageListenerDatabase.kt index b895988a990b..21262bd66237 100644 --- a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/plugin/listener/internal/SendMessageListenerDatabase.kt +++ b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/plugin/listener/internal/SendMessageListenerDatabase.kt @@ -82,7 +82,14 @@ internal class SendMessageListenerDatabase( ) { if (error.isMessageAlreadyExists()) { StreamLog.w(TAG) { "[handleSendMessageFailure] message already stored server side" } - messageRepository.insertMessage(message.copy(syncStatus = SyncStatus.COMPLETED)) + messageRepository.insertMessage( + message.copy( + syncStatus = SyncStatus.COMPLETED, + // The server stored the message but its reply never arrived, so the local date + // stands in until the next channel query replaces the row with the server copy. + createdAt = message.createdAt ?: message.createdLocallyAt, + ), + ) return } val isPermanentError = error.isPermanent() diff --git a/stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/plugin/listener/internal/SendMessageListenerDatabaseTest.kt b/stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/plugin/listener/internal/SendMessageListenerDatabaseTest.kt index 315677b61a80..2367a4b54d79 100644 --- a/stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/plugin/listener/internal/SendMessageListenerDatabaseTest.kt +++ b/stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/plugin/listener/internal/SendMessageListenerDatabaseTest.kt @@ -36,6 +36,7 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +import java.util.Date @OptIn(ExperimentalCoroutinesApi::class) internal class SendMessageListenerDatabaseTest { @@ -115,7 +116,12 @@ internal class SendMessageListenerDatabaseTest { fun `when the send is rejected as a duplicate, the message should be stored as completed`() = runTest { whenever(messageRepository.selectMessage(any())) doReturn null - val testMessage = randomMessage(syncStatus = SyncStatus.SYNC_NEEDED) + val createdLocallyAt = Date() + val testMessage = randomMessage( + syncStatus = SyncStatus.SYNC_NEEDED, + createdAt = null, + createdLocallyAt = createdLocallyAt, + ) val alreadyExists = Error.NetworkError( message = "a message with ID ${testMessage.id} already exists", serverErrorCode = ChatErrorCode.VALIDATION_ERROR.code, @@ -131,7 +137,9 @@ internal class SendMessageListenerDatabaseTest { verify(messageRepository).insertMessage( argThat { message -> - message.id == testMessage.id && message.syncStatus == SyncStatus.COMPLETED + message.id == testMessage.id && + message.syncStatus == SyncStatus.COMPLETED && + message.createdAt == createdLocallyAt }, ) } diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/SendMessageListenerState.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/SendMessageListenerState.kt index 7edb1e1f4e4c..205e02ddd332 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/SendMessageListenerState.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/SendMessageListenerState.kt @@ -88,7 +88,12 @@ internal class SendMessageListenerState(private val logic: LogicRegistry) : Send ) { if (error.isMessageAlreadyExists()) { StreamLog.w(TAG) { "[handleSendMessageFailure] message already stored server side" } - message.copy(syncStatus = SyncStatus.COMPLETED).also(::updateState) + message.copy( + syncStatus = SyncStatus.COMPLETED, + // The server stored the message but its reply never arrived, so the local date stands + // in until the next channel query replaces the row with the server copy. + createdAt = message.createdAt ?: message.createdLocallyAt, + ).also(::updateState) return } val isPermanentError = error.isPermanent() diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/listener/internal/SendMessageListenerStateTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/listener/internal/SendMessageListenerStateTest.kt index d2198f614d28..0ea060f19d49 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/listener/internal/SendMessageListenerStateTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/listener/internal/SendMessageListenerStateTest.kt @@ -156,7 +156,12 @@ internal class SendMessageListenerStateTest { @Test fun `when the send is rejected as a duplicate, the message should be marked completed`() = runTest { - val testMessage = randomMessage(syncStatus = SyncStatus.SYNC_NEEDED) + val createdLocallyAt = Date() + val testMessage = randomMessage( + syncStatus = SyncStatus.SYNC_NEEDED, + createdAt = null, + createdLocallyAt = createdLocallyAt, + ) val alreadyExists = Error.NetworkError( message = "a message with ID ${testMessage.id} already exists", serverErrorCode = ChatErrorCode.VALIDATION_ERROR.code, @@ -172,7 +177,9 @@ internal class SendMessageListenerStateTest { verify(channelLogic).upsertMessage( argThat { message -> - message.id == testMessage.id && message.syncStatus == SyncStatus.COMPLETED + message.id == testMessage.id && + message.syncStatus == SyncStatus.COMPLETED && + message.createdAt == createdLocallyAt }, ) } From f7d2f706b5943cf619b7df9defe0ca68b83a3c26 Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Fri, 18 Sep 2026 10:57:36 +0200 Subject: [PATCH 5/5] Match the duplicate send error on its full message shape Co-Authored-By: Claude Opus 5 (1M context) --- .../getstream/chat/android/client/errors/ChatError.kt | 10 ++++++---- .../chat/android/client/errors/ChatErrorTest.kt | 10 +++++++++- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/stream-chat-android-core/src/main/java/io/getstream/chat/android/client/errors/ChatError.kt b/stream-chat-android-core/src/main/java/io/getstream/chat/android/client/errors/ChatError.kt index e5d7a2b7240a..d2eecce0a494 100644 --- a/stream-chat-android-core/src/main/java/io/getstream/chat/android/client/errors/ChatError.kt +++ b/stream-chat-android-core/src/main/java/io/getstream/chat/android/client/errors/ChatError.kt @@ -27,7 +27,8 @@ import java.io.IOException private const val HTTP_TOO_MANY_REQUESTS = 429 private const val HTTP_TIMEOUT = 408 private const val HTTP_API_ERROR = 500 -private const val MESSAGE_ALREADY_EXISTS = "already exists" +private const val MESSAGE_DUPLICATE_PREFIX = "a message with ID" +private const val MESSAGE_DUPLICATE_SUFFIX = "already exists" /** * Creates [Error.NetworkError] from [ChatErrorCode] with custom status code and optional cause. @@ -83,14 +84,15 @@ public fun Error.isPermanent(): Boolean { /** * @return If the error reports that the message being sent is already stored on the server, which happens - * when a send that did reach the backend is retried. The backend answers with the generic validation code, - * so the message has to be matched as well. + * when a send that did reach the backend is retried. The backend answers with the generic validation code + * shared by every input error, so the message shape has to be matched as well. */ @InternalStreamChatApi public fun Error.isMessageAlreadyExists(): Boolean = this is Error.NetworkError && serverErrorCode == ChatErrorCode.VALIDATION_ERROR.code && - message.contains(MESSAGE_ALREADY_EXISTS) + message.contains(MESSAGE_DUPLICATE_PREFIX) && + message.contains(MESSAGE_DUPLICATE_SUFFIX) /** * Copies the original [Error] objects with custom message. diff --git a/stream-chat-android-core/src/test/java/io/getstream/chat/android/client/errors/ChatErrorTest.kt b/stream-chat-android-core/src/test/java/io/getstream/chat/android/client/errors/ChatErrorTest.kt index 9d7c1333fd25..eac20a6c9af4 100644 --- a/stream-chat-android-core/src/test/java/io/getstream/chat/android/client/errors/ChatErrorTest.kt +++ b/stream-chat-android-core/src/test/java/io/getstream/chat/android/client/errors/ChatErrorTest.kt @@ -67,7 +67,7 @@ internal class ChatErrorTest { @ParameterizedTest @MethodSource("isMessageAlreadyExistsArguments") - fun testIsMessageAlreadyExists( + fun `Verify isMessageAlreadyExists() extension function returns proper value`( error: Error, isAlreadyExists: Boolean, ) { @@ -168,6 +168,14 @@ internal class ChatErrorTest { Arguments.of(alreadyExistsError(), true), Arguments.of(alreadyExistsError(message = "a message with ID abc already exists"), true), Arguments.of(alreadyExistsError(message = "channel members are limited to 100"), false), + Arguments.of(alreadyExistsError(message = "poll with ID `p1` already exists"), false), + Arguments.of(alreadyExistsError(message = "vote already exists for user `u1` on poll `p1`"), false), + Arguments.of( + alreadyExistsError( + message = "SendMessage failed with error: \"a message with ID abc already exists\"", + ), + true, + ), Arguments.of( alreadyExistsError(code = ChatErrorCode.AUTHENTICATION_ERROR.code), false,