Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions library/src/main/java/io/constructor/core/ConstructorIo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@ import io.constructor.util.urlEncode
import io.reactivex.Completable
import io.reactivex.Observable
import io.reactivex.disposables.CompositeDisposable
import io.reactivex.exceptions.UndeliverableException
import io.reactivex.plugins.RxJavaPlugins
import io.reactivex.schedulers.Schedulers
import java.io.IOException
import java.util.*

typealias ConstructorError = ((Throwable) -> Unit)?
Expand Down Expand Up @@ -110,6 +113,10 @@ object ConstructorIo {
}
this.context = context.applicationContext

// Install the error handler early, before component initialization triggers the Dagger
// graph, so that any undeliverable RxJava exceptions during init are also caught.
setupRxJavaErrorHandler()

configMemoryHolder = component.configMemoryHolder()
configMemoryHolder.autocompleteResultCount = constructorIoConfig.autocompleteResultCount
configMemoryHolder.testCellParams = constructorIoConfig.testCells
Expand All @@ -131,6 +138,48 @@ object ConstructorIo {
dataManager = component.dataManager()
}

/**
* Sets up a global RxJava error handler to gracefully handle undeliverable exceptions.
* These exceptions can occur when network errors happen after the RxJava stream has
* already completed or been disposed, particularly with OkHttp async operations.
*/
internal fun setupRxJavaErrorHandler() {
Copy link
Contributor

Choose a reason for hiding this comment

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

Claude mentioned a race condition for this one:

setupRxJavaErrorHandler() has a small race condition. The getErrorHandler() != null check and the setErrorHandler() call are not atomic. If two threads call init() concurrently, or a host app sets its own handler between the check and the set, one handler could be silently overwritten.

Consider adding @synchronized:

@Synchronized
internal fun setupRxJavaErrorHandler() {
  if (RxJavaPlugins.getErrorHandler() != null) return
    RxJavaPlugins.setErrorHandler { throwable ->
      // ...
    }
}

Since ConstructorIo is a Kotlin object, @synchronized locks on the singleton instance, making the check then-set atomic with negligible performance cost (it only runs once during init).

if (RxJavaPlugins.getErrorHandler() != null) return
RxJavaPlugins.setErrorHandler { throwable ->
var error = throwable
// Unwrap the actual cause from UndeliverableException
if (error is UndeliverableException) {
error = error.cause ?: error
}

// InterruptedException signals the thread should stop — restore the interrupt flag
// so cooperative cancellation continues to work, then return without crashing
if (error is InterruptedException) {
Thread.currentThread().interrupt()
e("Constructor.io: Non-fatal interrupted error: ${error.message}")
return@setErrorHandler
}

// Network exceptions are expected during normal operation (timeout, no connectivity, etc.)
// Log them but don't crash the app
if (error is IOException) {
e("Constructor.io: Non-fatal network error: ${error.javaClass.simpleName} - ${error.message}")
return@setErrorHandler
}

// Unexpected exception — forward to the thread's uncaught exception handler,
// falling back to the JVM default handler if the thread has none set.
// If no handler is available, log the error.
val handler = Thread.currentThread().uncaughtExceptionHandler
?: Thread.getDefaultUncaughtExceptionHandler()
if (handler != null) {
handler.uncaughtException(Thread.currentThread(), error)
} else {
e("Constructor.io: Undeliverable unexpected exception (no uncaught handler): $error")
}
}
Comment on lines +163 to +180
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

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

The global error handler currently swallows all undeliverable exceptions by logging and returning. RxJava’s recommended pattern is to forward programmer bugs (e.g., NullPointerException, IllegalArgumentException, IllegalStateException, OnErrorNotImplementedException) to the thread’s UncaughtExceptionHandler (or delegate to any existing error handler) so they aren’t silently ignored in the host app.

Copilot uses AI. Check for mistakes.
}

/**
* Returns the current session identifier (an incrementing integer)
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import io.constructor.data.local.PreferencesHelper
import io.constructor.data.memory.ConfigMemoryHolder
import io.constructor.data.model.dataadapter.ResultDataAdapter
import io.constructor.injection.ConstructorSdk
import io.reactivex.schedulers.Schedulers
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
Expand All @@ -33,7 +34,7 @@ object NetworkModule {
.baseUrl(preferencesHelper.scheme + "://" + preferencesHelper.serviceUrl)
.client(okHttpClient)
.addConverterFactory(MoshiConverterFactory.create(moshi))
.addCallAdapterFactory(RxJava2CallAdapterFactory.create())
.addCallAdapterFactory(RxJava2CallAdapterFactory.createWithScheduler(Schedulers.io()))
.build()

@Provides
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package io.constructor.core

import io.reactivex.exceptions.UndeliverableException
import io.reactivex.functions.Consumer
import io.reactivex.plugins.RxJavaPlugins
import org.junit.After
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.io.IOException
import java.net.SocketTimeoutException

class ConstructorIoRxErrorHandlerTest {

private var constructorIo = ConstructorIo

Comment on lines +16 to +17
Copy link
Contributor

Choose a reason for hiding this comment

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

@HHHindawy thoughts?

@Before
fun setup() {
RxJavaPlugins.reset()
constructorIo.setupRxJavaErrorHandler()
}

@After
fun teardown() {
RxJavaPlugins.reset()
}

@Test
fun isIdempotent() {
val handlerAfterFirstCall = RxJavaPlugins.getErrorHandler()
constructorIo.setupRxJavaErrorHandler()
assertSame(handlerAfterFirstCall, RxJavaPlugins.getErrorHandler())
}

@Test
fun doesNotOverwriteExistingErrorHandler() {
RxJavaPlugins.reset()
val existingHandler = Consumer<Throwable> { }
RxJavaPlugins.setErrorHandler(existingHandler)

constructorIo.setupRxJavaErrorHandler()

assertSame(existingHandler, RxJavaPlugins.getErrorHandler())
}

@Test
fun handlesUndeliverableIOException() {
// Should not throw - IOException is handled gracefully
RxJavaPlugins.onError(UndeliverableException(IOException("network timeout")))
}

@Test
fun handlesUndeliverableInterruptedException() {
// Should not throw - InterruptedException is handled gracefully
// and the interrupt flag should be restored on the current thread
Thread.interrupted() // clear any pre-existing interrupt flag
RxJavaPlugins.onError(UndeliverableException(InterruptedException("thread interrupted")))
assertTrue("Interrupt flag should be restored", Thread.interrupted())
}

@Test
fun handlesRawIOException() {
// Should not throw - IOException without UndeliverableException wrapper
RxJavaPlugins.onError(IOException("connection reset"))
}

@Test
fun handlesSocketTimeoutException() {
// Should not throw - SocketTimeoutException is a subclass of IOException
RxJavaPlugins.onError(UndeliverableException(SocketTimeoutException("connect timed out")))
}

@Test
fun forwardsUnexpectedExceptionToUncaughtExceptionHandler() {
val original = Thread.currentThread().uncaughtExceptionHandler
try {
var caughtThrowable: Throwable? = null
Thread.currentThread().uncaughtExceptionHandler =
Thread.UncaughtExceptionHandler { _, throwable -> caughtThrowable = throwable }

val error = IllegalStateException("unexpected error")
RxJavaPlugins.onError(UndeliverableException(error))

assertSame(error, caughtThrowable)
} finally {
Thread.currentThread().uncaughtExceptionHandler = original
}
}

@Test
fun logsUnexpectedExceptionWhenNoUncaughtHandler() {
val original = Thread.currentThread().uncaughtExceptionHandler
val defaultHandler = Thread.getDefaultUncaughtExceptionHandler()
try {
Thread.currentThread().uncaughtExceptionHandler = null
Thread.setDefaultUncaughtExceptionHandler(null)

// Should not throw — falls back to logging when both handlers are null
RxJavaPlugins.onError(UndeliverableException(IllegalStateException("no handler")))
} finally {
Thread.currentThread().uncaughtExceptionHandler = original
Thread.setDefaultUncaughtExceptionHandler(defaultHandler)
}
}

@Test
fun forwardsNullCauseExceptionToUncaughtExceptionHandler() {
val original = Thread.currentThread().uncaughtExceptionHandler
try {
var caughtThrowable: Throwable? = null
Thread.currentThread().uncaughtExceptionHandler =
Thread.UncaughtExceptionHandler { _, throwable -> caughtThrowable = throwable }

val error = UndeliverableException(null)
RxJavaPlugins.onError(error)

// When cause is null, the UndeliverableException itself is forwarded
assertSame(error, caughtThrowable)
} finally {
Thread.currentThread().uncaughtExceptionHandler = original
}
}
}
Loading