-
Notifications
You must be signed in to change notification settings - Fork 3
[CDX-358] Handle undeliverable RxJava exceptions #160
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
fb93148
c937920
f8a524e
5bdae08
09aaf14
599576a
1cb75a0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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)? | ||
|
|
@@ -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 | ||
|
|
@@ -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() { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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: 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
|
||
| } | ||
|
|
||
| /** | ||
| * Returns the current session identifier (an incrementing integer) | ||
| */ | ||
|
|
||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
| } | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.