[CDX-358] Handle undeliverable RxJava exceptions#160
[CDX-358] Handle undeliverable RxJava exceptions#160
Conversation
This comment has been minimized.
This comment has been minimized.
This reverts commit c937920.
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Pull request overview
This PR aims to prevent host-app crashes caused by RxJava “undeliverable” exceptions (often originating from OkHttp/Retrofit calls after disposal) by installing a global RxJava error handler and standardizing Retrofit Rx subscriptions onto the IO scheduler.
Changes:
- Install a global
RxJavaPlugins.setErrorHandlerduringConstructorIo.init()to handle/log undeliverable exceptions. - Configure Retrofit to use
RxJava2CallAdapterFactory.createWithScheduler(Schedulers.io())so subscriptions default to the IO scheduler. - Add unit tests that exercise the error handler against several edge-case exception types.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
library/src/main/java/io/constructor/core/ConstructorIo.kt |
Adds/installs a global RxJava error handler during SDK initialization. |
library/src/main/java/io/constructor/injection/module/NetworkModule.kt |
Switches Retrofit Rx adapter factory to use an IO scheduler by default. |
library/src/test/java/io/constructor/core/ConstructorIoRxErrorHandlerTest.kt |
Adds unit tests to ensure the handler doesn’t throw for key exception cases. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
You can also share your feedback on Copilot code review. Take the survey.
| RxJavaPlugins.setErrorHandler { throwable -> | ||
| var error = throwable | ||
| // Unwrap the actual cause from UndeliverableException | ||
| if (error is UndeliverableException) { | ||
| error = error.cause ?: error | ||
| } | ||
|
|
||
| // Network exceptions are expected during normal operation (timeout, no connectivity, etc.) | ||
| // Log them but don't crash the app | ||
| if (error is IOException || error is InterruptedException) { | ||
| e("Constructor.io: Non-fatal network error: ${error.javaClass.simpleName} - ${error.message}") | ||
| return@setErrorHandler | ||
| } | ||
|
|
||
| // For other unexpected exceptions, log them as errors | ||
| // but still don't crash - these shouldn't bring down the host app | ||
| e("Constructor.io: Undeliverable exception: ${error.javaClass.simpleName} - ${error.message}") |
There was a problem hiding this comment.
RxJavaPlugins.setErrorHandler can throw IllegalStateException if RxJava plugins have been locked down by the host app (e.g., via RxJavaPlugins.lockdown()), which would make ConstructorIo.init() crash. Consider guarding setErrorHandler with a try/catch and falling back to logging if the handler can't be installed.
| RxJavaPlugins.setErrorHandler { throwable -> | |
| var error = throwable | |
| // Unwrap the actual cause from UndeliverableException | |
| if (error is UndeliverableException) { | |
| error = error.cause ?: error | |
| } | |
| // Network exceptions are expected during normal operation (timeout, no connectivity, etc.) | |
| // Log them but don't crash the app | |
| if (error is IOException || error is InterruptedException) { | |
| e("Constructor.io: Non-fatal network error: ${error.javaClass.simpleName} - ${error.message}") | |
| return@setErrorHandler | |
| } | |
| // For other unexpected exceptions, log them as errors | |
| // but still don't crash - these shouldn't bring down the host app | |
| e("Constructor.io: Undeliverable exception: ${error.javaClass.simpleName} - ${error.message}") | |
| try { | |
| RxJavaPlugins.setErrorHandler { throwable -> | |
| var error = throwable | |
| // Unwrap the actual cause from UndeliverableException | |
| if (error is UndeliverableException) { | |
| error = error.cause ?: error | |
| } | |
| // Network exceptions are expected during normal operation (timeout, no connectivity, etc.) | |
| // Log them but don't crash the app | |
| if (error is IOException || error is InterruptedException) { | |
| e("Constructor.io: Non-fatal network error: ${error.javaClass.simpleName} - ${error.message}") | |
| return@setErrorHandler | |
| } | |
| // For other unexpected exceptions, log them as errors | |
| // but still don't crash - these shouldn't bring down the host app | |
| e("Constructor.io: Undeliverable exception: ${error.javaClass.simpleName} - ${error.message}") | |
| } | |
| } catch (ise: IllegalStateException) { | |
| // RxJava plugins may have been locked down by the host app; fall back to logging only. | |
| e("Constructor.io: Failed to set RxJava global error handler; plugins may be locked down: ${ise.message}") |
| // Network exceptions are expected during normal operation (timeout, no connectivity, etc.) | ||
| // Log them but don't crash the app | ||
| if (error is IOException || error is InterruptedException) { | ||
| e("Constructor.io: Non-fatal network error: ${error.javaClass.simpleName} - ${error.message}") | ||
| return@setErrorHandler | ||
| } | ||
|
|
||
| // For other unexpected exceptions, log them as errors | ||
| // but still don't crash - these shouldn't bring down the host app | ||
| e("Constructor.io: Undeliverable exception: ${error.javaClass.simpleName} - ${error.message}") | ||
| } |
There was a problem hiding this comment.
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.
| if (error is IOException || error is InterruptedException) { | ||
| e("Constructor.io: Non-fatal network error: ${error.javaClass.simpleName} - ${error.message}") | ||
| return@setErrorHandler | ||
| } | ||
|
|
||
| // For other unexpected exceptions, log them as errors | ||
| // but still don't crash - these shouldn't bring down the host app | ||
| e("Constructor.io: Undeliverable exception: ${error.javaClass.simpleName} - ${error.message}") |
There was a problem hiding this comment.
Logging here drops the throwable/stack trace (the imported e() helper only logs a message). For post-mortem debugging, consider logging the Throwable as well (e.g., Log.e(tag, msg, error) or adding an e(msg, throwable) overload) so crash reporters and logcat contain the full stack trace.
|
|
||
| // Network exceptions are expected during normal operation (timeout, no connectivity, etc.) | ||
| // Log them but don't crash the app | ||
| if (error is IOException || error is InterruptedException) { |
There was a problem hiding this comment.
When handling InterruptedException, it’s usually important to restore the thread’s interrupted status (Thread.currentThread().interrupt()) before returning; otherwise downstream code may miss the interrupt signal.
| if (error is IOException || error is InterruptedException) { | |
| if (error is IOException || error is InterruptedException) { | |
| if (error is InterruptedException) { | |
| Thread.currentThread().interrupt() | |
| } |
This comment has been minimized.
This comment has been minimized.
… set + add comments
This comment has been minimized.
This comment has been minimized.
Code Review SummaryPR [CDX-358] adds a global Detailed Feedback[File: The guard: if (RxJavaPlugins.getErrorHandler() != null) returnis not atomic — two threads calling [File: Marking [File: The [File: For truly unexpected exceptions (not 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")
}This is a reasonable approach, but note that on Android the default uncaught exception handler (set by the framework or crash reporters like Firebase Crashlytics) will terminate the process. The PR summary says "internal network errors should never bring down the consumer's application" — that goal holds for [File: When forwarding to the uncaught exception handler, there is no log statement: if (handler != null) {
handler.uncaughtException(Thread.currentThread(), error)
}If the app does not crash (e.g., a custom uncaught handler suppresses it), there will be no Constructor.io log entry. Consider adding an if (handler != null) {
e("Constructor.io: Forwarding undeliverable unexpected exception to uncaught handler: $error")
handler.uncaughtException(Thread.currentThread(), error)
}[File: [File: The test rule RxJavaPlugins.setIoSchedulerHandler(schedulerFunction)This means that in unit tests, [File: The test coverage is good:
One gap: there is no test verifying that a log message is emitted for [File: private var constructorIo = ConstructorIoSince private val constructorIo = ConstructorIoConclusionThis is a well-motivated, cleanly implemented fix for a real production issue. The PR description is thorough and the test coverage is solid. The main items worth addressing before merge are:
Overall the change is approvable. Good work addressing a subtle but impactful SDK stability issue. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| private var constructorIo = ConstructorIo | ||
|
|
esezen
left a comment
There was a problem hiding this comment.
Looking great. I left some comments
| * 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() { |
There was a problem hiding this comment.
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).
| private var constructorIo = ConstructorIo | ||
|
|
Summary:
RxJavaPlugins.setErrorHandlerto catch undeliverableRxJavaexceptions (e.g. network errors that occur after a stream is disposed) and log them instead of crashing the host appRetrofit'sRxJava2CallAdapterFactory.create()tocreateWithScheduler(Schedulers.io())to ensure network calls are consistently subscribed on the IO scheduler, reducing the likelihood of undeliverable exceptionsBackground:
OkHttpcan deliver exceptions toRxJavaafter a stream has already completed or been disposed. When no global error handler is set,RxJava's default behavior is to throw these as unhandled exceptions, which crashes the host app. This is especially problematic for an SDK where internal network errors should never bring down the consumer's application.Why
createWithScheduler(Schedulers.io())?With
RxJava2CallAdapterFactory.create(),Retrofitobservables don't have a default subscription scheduler, meaning the thread that subscribes is the thread that executes the network call. If a subscriber disposes whileOkHttpis mid-request on an uncontrolled thread, the resulting error has no downstream observer and becomes an undeliverable exception. By usingcreateWithScheduler(Schedulers.io()), allRetrofitcalls are consistently subscribed on theRxJavaIO scheduler, which works cooperatively withRxJava's disposal and error-handling mechanisms, significantly reducing the window for undeliverable exceptions.