Skip to content

Replace sfu connect exception with sfu connect failure domain model#1745

Closed
rahul-lohra wants to merge 3 commits into
fix/sfu-join-self-triggered-reconnectfrom
fix/rahullohra/replace-sfu-connect-exception-with-sfu-connect-failure
Closed

Replace sfu connect exception with sfu connect failure domain model#1745
rahul-lohra wants to merge 3 commits into
fix/sfu-join-self-triggered-reconnectfrom
fix/rahullohra/replace-sfu-connect-exception-with-sfu-connect-failure

Conversation

@rahul-lohra

@rahul-lohra rahul-lohra commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Goal

Refactor SFU connect failure classification from exception subclasses to typed domain failures.

The implementation on PR #1741 uses SfuConnectException.Timeout and
SfuConnectException.Disconnected to tell _join() how to handle a failed SFU
connect attempt. That works mechanically, but the model is misleading:

  • Timeout is not a network-layer exception here; it means connectInternal()
    gave up waiting for the socket to reach a terminal state.
  • Disconnected is an SFU socket state, not an exception.
  • _join() is really branching on domain failure causes, not exception types.

This PR keeps the recovery behavior, but makes the producer/consumer boundary
clearer: connectInternal() reports a typed SFU connect failure, and _join()
decides how to orchestrate recovery from that failure.

Implementation

Replace SfuConnectException with a typed SfuConnectFailure domain model:

internal sealed interface SfuConnectFailure {
    val error: Exception

    //[RtcSession.connectInternal] stopped waiting for the socket to reach a terminal state.
    data class SocketStateObservationTimeout(
        override val error: Exception,
    ) : SfuConnectFailure

    //The socket failure is already being recovered by [RtcSession.stateJob].
    data class RecoverableSocketFailure(
        override val error: Exception,
    ) : SfuConnectFailure

    //The socket failure should fail immediately without recovery.
    data class TerminalSocketFailure(
        override val error: Exception,
    ) : SfuConnectFailure
}

Update SfuConnectionResult.Failure to carry the typed failure:

data class Failure(
    val cause: SfuConnectFailure,
    val abortReason: AnalyticsCallAbortReason? = null,
) : SfuConnectionResult()

RtcSession.connectInternal() now produces one of three domain failures:

  • SocketStateObservationTimeout: connectInternal() stopped waiting for the
    socket to reach Connected or Disconnected
  • RecoverableSocketFailure: the socket reached a recoverable disconnected
    state, and stateJob already owns reconnect
  • TerminalSocketFailure: the socket reached a terminal disconnected state and
    should fail without recovery

Call._join() branches on the typed failure instead of exception subclasses:

  • start REJOIN for SocketStateObservationTimeout
  • await the existing reconnect for RecoverableSocketFailure
  • fail immediately for TerminalSocketFailure

This keeps Exception as error detail and uses SfuConnectFailure as the domain-level connection result.

🎨 UI Changes

None

Testing

None

Summary by CodeRabbit

  • Bug Fixes
    • Improved SFU connection failure handling during joining and reconnecting.
    • Recoverable failures now wait for reconnection, timeouts trigger a rejoin, and terminal failures fail immediately.
    • Improved join failure analytics with more accurate error details.
  • Tests
    • Expanded coverage for reconnect, timeout, recoverable, and terminal failure scenarios.

makes the producer/consumer boundary
clearer: `connectInternal()` reports a typed SFU connect failure, and `_join()`
decides how to orchestrate recovery from that failure.
@github-actions

Copy link
Copy Markdown
Contributor

PR checklist ❌

The following issues were detected:

  • Missing required label: at least one label starting with pr:.
  • Linked issue missing. Add a Linear ticket reference (e.g. AND-123, Closes AND-123, or a https://linear.app/... link) or a GitHub issue (Closes #123) to the PR description.

What we check

  1. Title is concise (5–18 words) unless labeled pr:ignore-for-release.
  2. At least one pr: label exists (e.g., pr:bug, pr:new-feature).
  3. Sections ### Goal, ### Implementation, and ### Testing contain content. Bot-authored PRs are exempt.
  4. PR description references an issue (Linear ticket like AND-123, a Linear URL, or a GitHub Closes #N). Bot-authored PRs are exempt.

@rahul-lohra rahul-lohra changed the title Fix/rahullohra/replace sfu connect exception with sfu connect failure Replace sfu connect exception with sfu connect failure domain model Jul 10, 2026
@github-actions

Copy link
Copy Markdown
Contributor

SDK Size Comparison 📏

SDK Before After Difference Status
stream-video-android-core 12.27 MB 12.27 MB 0.00 MB 🟢
stream-video-android-ui-xml 5.68 MB 5.70 MB 0.02 MB 🟢
stream-video-android-ui-compose 6.20 MB 6.20 MB 0.00 MB 🟢

@rahul-lohra rahul-lohra marked this pull request as ready for review July 11, 2026 05:54
@rahul-lohra rahul-lohra requested a review from a team as a code owner July 11, 2026 05:54
@coderabbitai

coderabbitai Bot commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

SFU connection failures now use typed causes for socket observation timeouts, recoverable failures, and terminal failures. Join and reconnect flows branch on these causes, while analytics and tests consume the updated failure structure.

Changes

SFU failure handling

Layer / File(s) Summary
Failure model and connection classification
stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/*
SfuConnectionResult.Failure now carries SfuConnectFailure; RtcSession creates typed timeout, recoverable, and terminal failure causes.
Join and reconnect failure routing
stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt
Join handling triggers REJOIN, waits for recovery, or fails immediately based on the typed cause; analytics and reconnect mappings read nested errors.
Typed failure validation
stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/rtc/*
Tests construct and assert the new timeout, recoverable, and terminal failure variants.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant RtcSession
  participant Call
  participant ReconnectLoop
  RtcSession->>Call: Return typed SFU failure cause
  Call->>ReconnectLoop: Start REJOIN or await recovery
  Call->>Call: Fail immediately for terminal failure
Loading

Possibly related PRs

Suggested labels: pr:improvement

Suggested reviewers: aleksandar-apostolov, PratimMallick

Poem

I’m a rabbit with typed faults in flight,
Timeout hops left, terminal hops right.
Recoverable waits by the reconnect tree,
While tests guard each path carefully.
Hop, hop—SFU joins now know what to be!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 31.25% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: replacing the SFU connect exception model with a failure domain model.
Description check ✅ Passed The description includes the required Goal, Implementation, UI Changes, and Testing sections and is mostly complete.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/rahullohra/replace-sfu-connect-exception-with-sfu-connect-failure

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/RtcSession.kt (1)

976-991: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider preserving the original throwable as cause when wrapping.

Exception(msg) here drops the underlying networkError.cause (e.g. SocketTimeoutException/InterruptedIOException), so any consumer inspecting SfuConnectFailure.error.cause (crash reporting, logs) loses the original exception type/stack trace once wrapped as RecoverableSocketFailure/TerminalSocketFailure.

♻️ Proposed fix to preserve the original cause
-            val error = Exception(msg)
+            val error = Exception(msg, networkError?.cause)
             val failure = if ((sfuSocketState as? SfuSocketState.Disconnected)?.isRecoverable() == true) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/RtcSession.kt`
around lines 976 - 991, Preserve the underlying throwable when constructing the
connection failure in the `RtcSession` connect failure handling block. Replace
the `Exception(msg)` construction with an exception that uses `networkError` as
its cause when available, while retaining the existing message and fallback
behavior, so `RecoverableSocketFailure` and `TerminalSocketFailure` expose the
original error chain.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt`:
- Around line 783-828: Update the SocketStateObservationTimeout branch in
_join() to await the REJOIN result instead of launching it asynchronously and
falling through. Mirror the RecoverableSocketFailure handling: invoke reconnect
with REJOIN, await didReconnectSucceed(), and return an appropriate Failure
after recovery settles unsuccessfully, ensuring _join() does not proceed to
monitorSession or return Success prematurely.

---

Nitpick comments:
In
`@stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/RtcSession.kt`:
- Around line 976-991: Preserve the underlying throwable when constructing the
connection failure in the `RtcSession` connect failure handling block. Replace
the `Exception(msg)` construction with an exception that uses `networkError` as
its cause when available, while retaining the existing message and fallback
behavior, so `RecoverableSocketFailure` and `TerminalSocketFailure` expose the
original error chain.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 0f5b7f55-5160-448f-a3a3-040a7f74ddc4

📥 Commits

Reviewing files that changed from the base of the PR and between 94c9231 and af22c42.

📒 Files selected for processing (6)
  • stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt
  • stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/RtcSession.kt
  • stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/SfuConnectFailure.kt
  • stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/rtc/FastReconnectIceRestartTest.kt
  • stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/rtc/JoinRecoverableFailureTest.kt
  • stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/rtc/RtcSessionTest2.kt

Comment on lines +783 to 828
when (sfuConnectionResult.cause) {
is SfuConnectFailure.SocketStateObservationTimeout -> {
// REJOIN (not FAST) on purpose: a connect timeout means the initial
// join never completed. There is no established SFU session or
// negotiated media path to resume, so a FAST resume would likely hit
// PARTICIPANT_NOT_FOUND and burn attempts before the loop escalates.
// A full REJOIN re-fetches credentials and starts a clean join, which
// is the only thing that can actually succeed here.
logger.w {
"[_join] Recoverable SFU connect timeout with no recovery started — triggering REJOIN"
"[_join] SFU socket state observation timed out with no recovery started — triggering REJOIN"
}
scope.launch {
reconnect(
WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_REJOIN,
"join-recoverable-connect-failure",
)
}
} else {
logger.w { "[_join] Recoverable SFU connection failure — awaiting recovery outcome" }
}
if (!didReconnectSucceed()) {
logger.e { "[_join] Could not recover. Error : $sfuConnectionResult" }

is SfuConnectFailure.RecoverableSocketFailure -> {
logger.w { "[_join] Recoverable SFU socket failure — awaiting recovery outcome" }
if (!didReconnectSucceed()) {
logger.e { "[_join] Could not recover. Error : $sfuConnectionResult" }
sendJoinErrorAnalytics(sfuConnectionResult)
return Failure(
Error.GenericError(
sfuConnectionResult.cause.error.message ?: "SFU connection failed",
),
)
}
}

is SfuConnectFailure.TerminalSocketFailure -> {
logger.e {
"[_join] Got terminal error while connecting to SFU. Error : $sfuConnectionResult"
}
sendJoinErrorAnalytics(sfuConnectionResult)
return Failure(
Error.GenericError(
sfuConnectionResult.error.message ?: "SFU connection failed",
sfuConnectionResult.cause.error.message ?: "RtcSession error occurred.",
),
)
}
} else {
logger.e {
"[_join] Got non recoverable error while connecting to SFU. Error : $sfuConnectionResult"
}
sendJoinErrorAnalytics(sfuConnectionResult)
return Failure(
Error.GenericError(
sfuConnectionResult.error.message ?: "RtcSession error occurred.",
),
)
}
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

SocketStateObservationTimeout branch never awaits the REJOIN it triggers — _join() falls through to Success.

Unlike the RecoverableSocketFailure branch, this branch fires scope.launch { reconnect(REJOIN, ...) } and then falls out of the when with no return, straight into val connectedSession = session.value ?: return Failure(...) (Line 829). Since session.value is already non-null (set at Line 773 before connectInternal() ran), execution proceeds to monitorSession(...) and return Success(...) immediately — without ever observing whether the REJOIN succeeded.

This contradicts JoinRecoverableFailureTest.socket state observation timeout starts a REJOIN itself, which expects _join() to stay suspended after triggering REJOIN and resolve to Failure only once the connection settles as ReconnectingFailed. As written, _join() returns Success synchronously, so that test would fail, and in production the join flow would falsely report success while the SFU connection actually timed out.

🐛 Proposed fix to await the triggered REJOIN, mirroring the RecoverableSocketFailure branch
                     is SfuConnectFailure.SocketStateObservationTimeout -> {
                         logger.w {
                             "[_join] SFU socket state observation timed out with no recovery started — triggering REJOIN"
                         }
                         scope.launch {
                             reconnect(
                                 WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_REJOIN,
                                 "join-recoverable-connect-failure",
                             )
                         }
+                        if (!didReconnectSucceed()) {
+                            logger.e { "[_join] Could not recover from socket state observation timeout. Error : $sfuConnectionResult" }
+                            sendJoinErrorAnalytics(sfuConnectionResult)
+                            return Failure(
+                                Error.GenericError(
+                                    sfuConnectionResult.cause.error.message ?: "SFU connection timed out",
+                                ),
+                            )
+                        }
                     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
when (sfuConnectionResult.cause) {
is SfuConnectFailure.SocketStateObservationTimeout -> {
// REJOIN (not FAST) on purpose: a connect timeout means the initial
// join never completed. There is no established SFU session or
// negotiated media path to resume, so a FAST resume would likely hit
// PARTICIPANT_NOT_FOUND and burn attempts before the loop escalates.
// A full REJOIN re-fetches credentials and starts a clean join, which
// is the only thing that can actually succeed here.
logger.w {
"[_join] Recoverable SFU connect timeout with no recovery started — triggering REJOIN"
"[_join] SFU socket state observation timed out with no recovery started — triggering REJOIN"
}
scope.launch {
reconnect(
WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_REJOIN,
"join-recoverable-connect-failure",
)
}
} else {
logger.w { "[_join] Recoverable SFU connection failure — awaiting recovery outcome" }
}
if (!didReconnectSucceed()) {
logger.e { "[_join] Could not recover. Error : $sfuConnectionResult" }
is SfuConnectFailure.RecoverableSocketFailure -> {
logger.w { "[_join] Recoverable SFU socket failure — awaiting recovery outcome" }
if (!didReconnectSucceed()) {
logger.e { "[_join] Could not recover. Error : $sfuConnectionResult" }
sendJoinErrorAnalytics(sfuConnectionResult)
return Failure(
Error.GenericError(
sfuConnectionResult.cause.error.message ?: "SFU connection failed",
),
)
}
}
is SfuConnectFailure.TerminalSocketFailure -> {
logger.e {
"[_join] Got terminal error while connecting to SFU. Error : $sfuConnectionResult"
}
sendJoinErrorAnalytics(sfuConnectionResult)
return Failure(
Error.GenericError(
sfuConnectionResult.error.message ?: "SFU connection failed",
sfuConnectionResult.cause.error.message ?: "RtcSession error occurred.",
),
)
}
} else {
logger.e {
"[_join] Got non recoverable error while connecting to SFU. Error : $sfuConnectionResult"
}
sendJoinErrorAnalytics(sfuConnectionResult)
return Failure(
Error.GenericError(
sfuConnectionResult.error.message ?: "RtcSession error occurred.",
),
)
}
}
}
when (sfuConnectionResult.cause) {
is SfuConnectFailure.SocketStateObservationTimeout -> {
// REJOIN (not FAST) on purpose: a connect timeout means the initial
// join never completed. There is no established SFU session or
// negotiated media path to resume, so a FAST resume would likely hit
// PARTICIPANT_NOT_FOUND and burn attempts before the loop escalates.
// A full REJOIN re-fetches credentials and starts a clean join, which
// is the only thing that can actually succeed here.
logger.w {
"[_join] SFU socket state observation timed out with no recovery started — triggering REJOIN"
}
scope.launch {
reconnect(
WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_REJOIN,
"join-recoverable-connect-failure",
)
}
if (!didReconnectSucceed()) {
logger.e { "[_join] Could not recover from socket state observation timeout. Error : $sfuConnectionResult" }
sendJoinErrorAnalytics(sfuConnectionResult)
return Failure(
Error.GenericError(
sfuConnectionResult.cause.error.message ?: "SFU connection timed out",
),
)
}
}
is SfuConnectFailure.RecoverableSocketFailure -> {
logger.w { "[_join] Recoverable SFU socket failure — awaiting recovery outcome" }
if (!didReconnectSucceed()) {
logger.e { "[_join] Could not recover. Error : $sfuConnectionResult" }
sendJoinErrorAnalytics(sfuConnectionResult)
return Failure(
Error.GenericError(
sfuConnectionResult.cause.error.message ?: "SFU connection failed",
),
)
}
}
is SfuConnectFailure.TerminalSocketFailure -> {
logger.e {
"[_join] Got terminal error while connecting to SFU. Error : $sfuConnectionResult"
}
sendJoinErrorAnalytics(sfuConnectionResult)
return Failure(
Error.GenericError(
sfuConnectionResult.cause.error.message ?: "RtcSession error occurred.",
),
)
}
}
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt`
around lines 783 - 828, Update the SocketStateObservationTimeout branch in
_join() to await the REJOIN result instead of launching it asynchronously and
falling through. Mirror the RecoverableSocketFailure handling: invoke reconnect
with REJOIN, await didReconnectSucceed(), and return an appropriate Failure
after recovery settles unsuccessfully, ensuring _join() does not proceed to
monitorSession or return Success prematurely.

@PratimMallick

Copy link
Copy Markdown
Contributor

Merged the other PR #1744, instead of this as that looked cleaner

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants