Stop crashing on transient network failures during archive media streams.

This commit is contained in:
Greyson Parrelli
2026-08-12 20:59:39 -04:00
parent 57e66c33c5
commit ca64a613c9
3 changed files with 92 additions and 1 deletions
@@ -0,0 +1,31 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.network
import org.signal.libsignal.net.BadRequestError
import org.signal.libsignal.net.RequestResult
import java.io.IOException
import org.signal.libsignal.net.toRequestResult as libsignalToRequestResult
/**
* Classifies [this] as a [RequestResult]. Prefer this over libsignal's `Throwable.toRequestResult()`.
*
* Libsignal's classifier misses handling things like IOException and forwards them as application errors.
*/
inline fun <reified E : BadRequestError> Throwable.toRequestResult(): RequestResult<Nothing, E> {
if (this is E) {
return RequestResult.NonSuccess(this)
}
val result = libsignalToRequestResult()
val unclassified = (result as? RequestResult.ApplicationError)?.cause
return if (unclassified is IOException) {
RequestResult.RetryableNetworkError(unclassified)
} else {
result
}
}
@@ -0,0 +1,60 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.network
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isInstanceOf
import assertk.assertions.isSameInstanceAs
import org.junit.Test
import org.signal.libsignal.net.RequestResult
import org.signal.libsignal.net.RequestUnauthorizedException
import org.signal.libsignal.net.RetryLaterException
import java.net.SocketException
import java.time.Duration
class RequestResultClassificationTest {
@Test
fun `plain IOException is retryable`() {
val exception = SocketException("no connection")
val result = exception.toRequestResult<RequestUnauthorizedException>()
assertThat(result).isInstanceOf(RequestResult.RetryableNetworkError::class)
assertThat((result as RequestResult.RetryableNetworkError).networkError).isSameInstanceAs(exception)
}
@Test
fun `non-IOException is an application error`() {
val exception = IllegalStateException("bug")
val result = exception.toRequestResult<RequestUnauthorizedException>()
assertThat(result).isInstanceOf(RequestResult.ApplicationError::class)
assertThat((result as RequestResult.ApplicationError).cause).isSameInstanceAs(exception)
}
@Test
fun `libsignal classification still wins over the IOException fallback`() {
val exception = RetryLaterException(Duration.ofSeconds(30))
val result = exception.toRequestResult<RequestUnauthorizedException>()
assertThat(result).isInstanceOf(RequestResult.RetryableNetworkError::class)
assertThat((result as RequestResult.RetryableNetworkError).retryAfter).isEqualTo(Duration.ofSeconds(30))
}
@Test
fun `expected error is a non-success even though it is an IOException`() {
val exception = RequestUnauthorizedException("nope")
val result = exception.toRequestResult<RequestUnauthorizedException>()
assertThat(result).isInstanceOf(RequestResult.NonSuccess::class)
assertThat((result as RequestResult.NonSuccess).error).isSameInstanceAs(exception)
}
}
@@ -30,7 +30,6 @@ import org.signal.libsignal.net.ServerSideErrorException
import org.signal.libsignal.net.UnauthBackupsService
import org.signal.libsignal.net.UnauthenticatedChatConnection
import org.signal.libsignal.net.UploadForm
import org.signal.libsignal.net.toRequestResult
import org.signal.libsignal.protocol.ecc.ECPrivateKey
import org.signal.libsignal.zkgroup.GenericServerPublicParams
import org.signal.libsignal.zkgroup.InvalidInputException
@@ -38,6 +37,7 @@ import org.signal.libsignal.zkgroup.VerificationFailedException
import org.signal.libsignal.zkgroup.backups.BackupAuthCredential
import org.signal.libsignal.zkgroup.backups.BackupAuthCredentialRequestContext
import org.signal.libsignal.zkgroup.backups.BackupAuthCredentialResponse
import org.signal.network.toRequestResult
import org.signal.network.websocket.WebSocketRequestMessage
import org.signal.network.websocket.WebsocketResponse
import org.signal.network.websocket.get