Skip to content

Commit 24b05e3

Browse files
fix(core): report SFU WebSocket connection/join timeouts accurately
Splits the SFU socket connect into a transport-open phase (bounded by OkHttp's connect timeout) and a distinct join-response phase (bounded by a dedicated timer via a new WebSocketConnected state), so a silent SFU no longer hangs the join. Surfaces real transport failure messages and HTTP status codes on the resulting NetworkError, routes all recoverable errors through a single DisconnectedTemporarily state carrying the exact code/reason, and maps both timeout flavours to REQUEST_TIMEOUT in analytics (join-response via error code, transport via SocketTimeoutException cause). Also moves join-error analytics to the join flow only, adds a per-session SFU WS retry counter, and wires connectionTimeoutInMs from the builder to both the OkHttp client and the join-response deadline. Co-authored-by: Cursor <[email protected]>
1 parent 6035a0d commit 24b05e3

19 files changed

Lines changed: 757 additions & 106 deletions

File tree

stream-video-android-core/api/stream-video-android-core.api

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12038,6 +12038,7 @@ public final class io/getstream/video/android/core/errors/VideoErrorCode : java/
1203812038
public static final field NETWORK_FAILED Lio/getstream/video/android/core/errors/VideoErrorCode;
1203912039
public static final field NO_ERROR_BODY Lio/getstream/video/android/core/errors/VideoErrorCode;
1204012040
public static final field PARSER_ERROR Lio/getstream/video/android/core/errors/VideoErrorCode;
12041+
public static final field SFU_JOIN_RESPONSE_TIMEOUT Lio/getstream/video/android/core/errors/VideoErrorCode;
1204112042
public static final field SOCKET_CLOSED Lio/getstream/video/android/core/errors/VideoErrorCode;
1204212043
public static final field SOCKET_FAILURE Lio/getstream/video/android/core/errors/VideoErrorCode;
1204312044
public static final field TOKEN_DATE_INCORRECT Lio/getstream/video/android/core/errors/VideoErrorCode;
@@ -14408,6 +14409,11 @@ public final class io/getstream/video/android/core/socket/common/StreamWebSocket
1440814409
public fun toString ()Ljava/lang/String;
1440914410
}
1441014411

14412+
public final class io/getstream/video/android/core/socket/common/StreamWebSocketEvent$Open : io/getstream/video/android/core/socket/common/StreamWebSocketEvent {
14413+
public static final field INSTANCE Lio/getstream/video/android/core/socket/common/StreamWebSocketEvent$Open;
14414+
public fun toString ()Ljava/lang/String;
14415+
}
14416+
1441114417
public final class io/getstream/video/android/core/socket/common/StreamWebSocketEvent$SfuMessage : io/getstream/video/android/core/socket/common/StreamWebSocketEvent {
1441214418
public fun <init> (Lio/getstream/video/android/core/events/SfuDataEvent;)V
1441314419
public final fun component1 ()Lio/getstream/video/android/core/events/SfuDataEvent;
@@ -14858,6 +14864,11 @@ public final class io/getstream/video/android/core/socket/sfu/state/SfuSocketSta
1485814864
public fun toString ()Ljava/lang/String;
1485914865
}
1486014866

14867+
public final class io/getstream/video/android/core/socket/sfu/state/SfuSocketState$WebSocketConnected : io/getstream/video/android/core/socket/sfu/state/SfuSocketState {
14868+
public static final field INSTANCE Lio/getstream/video/android/core/socket/sfu/state/SfuSocketState$WebSocketConnected;
14869+
public fun toString ()Ljava/lang/String;
14870+
}
14871+
1486114872
public abstract class io/getstream/video/android/core/socket/sfu/state/SfuSocketStateEvent {
1486214873
}
1486314874

@@ -14935,6 +14946,11 @@ public final class io/getstream/video/android/core/socket/sfu/state/SfuSocketSta
1493514946
public fun toString ()Ljava/lang/String;
1493614947
}
1493714948

14949+
public final class io/getstream/video/android/core/socket/sfu/state/SfuSocketStateEvent$WebSocketConnected : io/getstream/video/android/core/socket/sfu/state/SfuSocketStateEvent {
14950+
public static final field INSTANCE Lio/getstream/video/android/core/socket/sfu/state/SfuSocketStateEvent$WebSocketConnected;
14951+
public fun toString ()Ljava/lang/String;
14952+
}
14953+
1493814954
public final class io/getstream/video/android/core/socket/sfu/state/SfuSocketStateEvent$WebSocketEventLost : io/getstream/video/android/core/socket/sfu/state/SfuSocketStateEvent {
1493914955
public static final field INSTANCE Lio/getstream/video/android/core/socket/sfu/state/SfuSocketStateEvent$WebSocketEventLost;
1494014956
public fun toString ()Ljava/lang/String;

stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt

Lines changed: 73 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow
112112
import kotlinx.coroutines.flow.MutableStateFlow
113113
import kotlinx.coroutines.flow.StateFlow
114114
import kotlinx.coroutines.flow.filterNotNull
115+
import kotlinx.coroutines.flow.first
115116
import kotlinx.coroutines.flow.flatMapLatest
116117
import kotlinx.coroutines.flow.launchIn
117118
import kotlinx.coroutines.flow.map
@@ -770,24 +771,81 @@ public class Call(
770771
}
771772
session.value = localSession
772773

774+
if (session.value == null) {
775+
return Failure(Error.GenericError("RtcSession was null during connection to sfu"))
776+
}
777+
773778
session.value?.let {
774779
state._connection.value = RealtimeConnection.Joined(it)
775780
}
776781

777-
when (val result = session.value?.connectInternal()) {
778-
is SfuConnectionResult.Connected -> Unit
779-
is SfuConnectionResult.Failed ->
780-
return Failure(
781-
Error.GenericError(result.error.message ?: "RtcSession error occurred."),
782-
)
783-
null ->
784-
return Failure(Error.GenericError("RtcSession was null during connect"))
782+
// This is the SFU ws connection
783+
val sfuConnectionResult = session.value!!.connectInternal()
784+
785+
when (sfuConnectionResult) {
786+
is SfuConnectionResult.Success -> Unit
787+
is SfuConnectionResult.Failure -> {
788+
if (sfuConnectionResult.recoverable) {
789+
logger.w { "[_join] Recoverable SFU connection failure — awaiting reconnect outcome" }
790+
if (!didReconnectSucceed()) {
791+
logger.e { "[_join] Could not recover. Error : $sfuConnectionResult" }
792+
sendJoinErrorAnalytics(sfuConnectionResult)
793+
return Failure(
794+
Error.GenericError(
795+
sfuConnectionResult.error.message ?: "SFU connection failed",
796+
),
797+
)
798+
}
799+
} else {
800+
logger.e {
801+
"[_join] Got non recoverable error while connecting to SFU. Error : $sfuConnectionResult"
802+
}
803+
sendJoinErrorAnalytics(sfuConnectionResult)
804+
return Failure(
805+
Error.GenericError(
806+
sfuConnectionResult.error.message ?: "RtcSession error occurred.",
807+
),
808+
)
809+
}
810+
}
785811
}
786812
client.state.setActiveCall(this)
787813
monitorSession(result.value)
788814
return Success(value = session.value!!)
789815
}
790816

817+
/**
818+
* Reports the SFU WebSocket join failure to analytics. Only called from the join
819+
* flow ([_join]) so that reconnect-driven [RtcSession.connectInternal] failures are
820+
* not counted as join errors. The retry count comes from the session's
821+
* [RtcSession.sfuWsRetryCount]; the failure reason and abort code come straight from
822+
* the [SfuConnectionResult.Failure] the connect attempt produced.
823+
*/
824+
private fun sendJoinErrorAnalytics(failure: SfuConnectionResult.Failure) {
825+
callAnalytics.sfuAnalytics.onSfuWsCompleted(
826+
success = false,
827+
retryCount = session.value?.sfuWsRetryCount?.get() ?: 0,
828+
failureReason = failure.error.message,
829+
failureCode = (failure.abortReason ?: AnalyticsCallAbortReason.SFU_ERROR).name,
830+
)
831+
}
832+
833+
/**
834+
* Suspends until the reconnect loop triggered by a recoverable connection failure
835+
* reaches a terminal state, returning `true` if the call recovered (became
836+
* [RealtimeConnection.Connected]) and `false` otherwise
837+
* ([RealtimeConnection.ReconnectingFailed] / [RealtimeConnection.Disconnected]).
838+
*/
839+
private suspend fun didReconnectSucceed(): Boolean {
840+
val terminal = state.connection.first {
841+
it is RealtimeConnection.Connected ||
842+
it is RealtimeConnection.ReconnectingFailed ||
843+
it is RealtimeConnection.Disconnected
844+
}
845+
logger.d { "[_join] Reconnect after recoverable connection failure settled on $terminal" }
846+
return terminal is RealtimeConnection.Connected
847+
}
848+
791849
private fun Call.monitorSession(result: JoinCallResponse) {
792850
sfuEvents?.cancel()
793851
sfuListener?.cancel()
@@ -1207,15 +1265,14 @@ public class Call(
12071265
val result = newSession.connectInternal(
12081266
reconnectDetails,
12091267
currentOptions,
1210-
JoinAnalyticsModel(joinAnalyticsModel.retryAttempt),
12111268
)
12121269
) {
1213-
is SfuConnectionResult.Connected -> {
1270+
is SfuConnectionResult.Success -> {
12141271
newSession.sfuTracer.trace("rejoin", reason)
12151272
monitorSession(joinResponse.value)
12161273
ReconnectOutcome.Success
12171274
}
1218-
is SfuConnectionResult.Failed -> ReconnectOutcome.Failed(result.error)
1275+
is SfuConnectionResult.Failure -> ReconnectOutcome.Failed(result.error)
12191276
}
12201277
}
12211278

@@ -1291,14 +1348,13 @@ public class Call(
12911348
val result = newSession.connectInternal(
12921349
reconnectDetails,
12931350
currentOptions,
1294-
JoinAnalyticsModel(joinAnalyticsModel.retryAttempt),
12951351
)
12961352
when (result) {
1297-
is SfuConnectionResult.Connected -> {
1353+
is SfuConnectionResult.Success -> {
12981354
monitorSession(joinResponse.value)
12991355
ReconnectOutcome.Success
13001356
}
1301-
is SfuConnectionResult.Failed -> ReconnectOutcome.Failed(result.error)
1357+
is SfuConnectionResult.Failure -> ReconnectOutcome.Failed(result.error)
13021358
}
13031359
} finally {
13041360
oldSession.finalizeMigration()
@@ -2218,8 +2274,9 @@ public class Call(
22182274
companion object {
22192275
/** How many consecutive FAST reconnect failures are allowed before
22202276
* escalating to a full REJOIN. Kept small because each failed FAST
2221-
* attempt can cost up to DEFAULT_SOCKET_TIMEOUT (10 s) waiting for
2222-
* the WebSocket handshake to time out. */
2277+
* attempt can cost up to the socket connection deadline before it gives
2278+
* up: OkHttp's WebSocket-upgrade timeout, followed by the join-response
2279+
* wait — both driven by StreamVideoBuilder.connectionTimeoutInMs (default 10s). */
22232280
private const val MAX_FAST_RECONNECT_ATTEMPTS = 3
22242281

22252282
/** Absolute upper bound on loop iterations across all strategies

stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoBuilder.kt

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,10 @@ import java.net.ConnectException
8484
* @property loggingLevel Represents and wraps SDK logging levels for Stream logger, HTTP interceptor and native WebRTC logging.
8585
* @property notificationConfig The configurations for handling push notification.
8686
* @property ringNotification Overwrite the default notification logic for incoming calls.
87-
* @property connectionTimeoutInMs Connection timeout in seconds.
87+
* @property connectionTimeoutInMs Connection timeout in milliseconds. Applies both to the
88+
* coordinator/SFU OkHttp clients (HTTP and WebSocket upgrade) and to the SFU join-response
89+
* wait (time allowed for the SFU to deliver its JoinCallResponse after the socket opens).
90+
* Defaults to 10s.
8891
* @property ensureSingleInstance Verify that only 1 version of the video client exists. Prevents integration mistakes.
8992
* @property videoDomain URL overwrite to allow for testing against a local instance of video.
9093
* @property callServiceConfig Configuration for the call foreground service. See [CallServiceConfig]. (Deprecated) Use `callServiceConfigRegistry` instead.
@@ -127,7 +130,7 @@ public class StreamVideoBuilder @JvmOverloads constructor(
127130
private val loggingLevel: LoggingLevel = LoggingLevel(),
128131
private val notificationConfig: NotificationConfig = NotificationConfig(),
129132
private val ringNotification: ((call: Call) -> Notification?)? = null,
130-
private val connectionTimeoutInMs: Long = 10_000,
133+
private val connectionTimeoutInMs: Long = 5_000,
131134
private var ensureSingleInstance: Boolean = true,
132135
private val videoDomain: String = "video.stream-io-api.com",
133136
@Deprecated(
@@ -297,6 +300,7 @@ public class StreamVideoBuilder @JvmOverloads constructor(
297300
appName = appName,
298301
audioProcessing = audioProcessing,
299302
loggingLevel = loggingLevel,
303+
connectionTimeoutInMs = connectionTimeoutInMs,
300304
leaveAfterDisconnectSeconds = leaveAfterDisconnectSeconds,
301305
enableCallUpdatesAfterLeave = callUpdatesAfterLeave,
302306
enableStatsCollection = enableStatsReporting,

stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoClient.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@ internal class StreamVideoClient internal constructor(
188188
internal val appName: String? = null,
189189
internal val audioProcessing: ManagedAudioProcessingFactory? = null,
190190
internal val loggingLevel: LoggingLevel = LoggingLevel(),
191+
internal val connectionTimeoutInMs: Long = 10_000,
191192
internal val leaveAfterDisconnectSeconds: Long = 30,
192193
internal val appVersion: String? = null,
193194
internal val enableCallUpdatesAfterLeave: Boolean = false,

0 commit comments

Comments
 (0)