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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Full API documentation is available on [Github Pages](https://constructor-io.git

## 1. Install

Please follow the directions at [Jitpack.io](https://jitpack.io/#Constructor-io/constructorio-client-android/v2.36.0) to add the client to your project.
Please follow the directions at [Jitpack.io](https://jitpack.io/#Constructor-io/constructorio-client-android/v2.38.0-cdx-358-2) to add the client to your project.

## 2. Retrieve an API key

Expand Down
2 changes: 1 addition & 1 deletion library/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ android {
multiDexEnabled true
testInstrumentationRunner "${applicationId}.runner.RxAndroidJUnitRunner"
versionCode 1
versionName '2.36.0'
versionName '2.38.0-cdx-358-2'
buildConfigField("String", "CLIENT_VERSION", "\"cioand-${versionName}\"")
buildConfigField("String", "DEFAULT_ITEM_SECTION", "\"Products\"")
buildConfigField("String", "SERVICE_URL", "\"ac.cnstrc.com\"")
Expand Down
35 changes: 35 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,9 @@ import io.constructor.util.urlEncode
import io.reactivex.Completable
import io.reactivex.Observable
import io.reactivex.disposables.CompositeDisposable
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 @@ -98,6 +100,33 @@ object ConstructorIo {
trackSessionStart()
}

/**
* Sets up a global RxJava error handler to prevent crashes from undeliverable exceptions.
* This handles network errors (like SocketTimeoutException) that occur on OkHttp's
* background threads after the RxJava subscription can no longer receive them.
*/
private fun setupRxErrorHandler() {
RxJavaPlugins.setErrorHandler(createRxErrorHandler())
}

/**
* Creates the RxJava error handler function.
* Internal for testing purposes.
*/
internal fun createRxErrorHandler(): (Throwable) -> Unit = { throwable ->
// Unwrap the exception to get the actual cause
val error = throwable.cause ?: throwable

if (error is IOException || error is InterruptedException) {
// Network errors and interruptions are expected in normal operation
// Log as warning so crash reporting tools can track frequency
e("Non-fatal network error: ${error.message}")
} else {
// For unexpected errors, log as error level
e("Undeliverable exception: ${error.message}")
}
}

/**
* Initializes the client
*
Expand All @@ -110,11 +139,17 @@ object ConstructorIo {
}
this.context = context.applicationContext

// Setup RxJava error handler to prevent crashes from undeliverable network exceptions
if (constructorIoConfig.suppressNetworkExceptions) {
setupRxErrorHandler()
}

configMemoryHolder = component.configMemoryHolder()
configMemoryHolder.autocompleteResultCount = constructorIoConfig.autocompleteResultCount
configMemoryHolder.testCellParams = constructorIoConfig.testCells
configMemoryHolder.segments = constructorIoConfig.segments
configMemoryHolder.defaultAnalyticsTags = constructorIoConfig.defaultAnalyticsTags
configMemoryHolder.suppressNetworkExceptions = constructorIoConfig.suppressNetworkExceptions

preferenceHelper = component.preferenceHelper()
preferenceHelper.apiKey = constructorIoConfig.apiKey
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import io.constructor.BuildConfig
* @property servicePort The port to use (for testing purposes only, defaults to 443)
* @property serviceScheme The scheme to use (for testing purposes only, defaults to HTTPS)
* @property defaultAnalyticsTags Additional analytics tags to pass. Will be merged with analytics tags passed on the request level
* @property suppressNetworkExceptions When true, catches network exceptions (e.g., SocketTimeoutException, IOException) that would otherwise crash the app and handles them gracefully. For tracking/analytics endpoints, failures are silently ignored. For other endpoints, a synthetic error response is returned. Defaults to false.
*/
data class ConstructorIoConfig(
val apiKey: String,
Expand All @@ -24,5 +25,6 @@ data class ConstructorIoConfig(
val defaultItemSection: String = BuildConfig.DEFAULT_ITEM_SECTION,
val servicePort: Int = BuildConfig.SERVICE_PORT,
val serviceScheme: String = BuildConfig.SERVICE_SCHEME,
val defaultAnalyticsTags: Map<String, String> = emptyMap()
)
val defaultAnalyticsTags: Map<String, String> = emptyMap(),
val suppressNetworkExceptions: Boolean = false
)
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,18 @@ import io.constructor.core.Constants
import io.constructor.data.local.PreferencesHelper
import io.constructor.data.memory.ConfigMemoryHolder
import io.constructor.data.remote.ApiPaths
import io.constructor.util.e
import okhttp3.HttpUrl
import okhttp3.Interceptor
import okhttp3.Protocol
import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody
import java.io.IOException
import java.io.InterruptedIOException
import java.net.SocketException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import javax.net.ssl.SSLException

/**
* @suppress
Expand Down Expand Up @@ -96,6 +105,38 @@ class RequestInterceptor(
}

val newRequest = newRequestBuilder.url(builder.build()).build();
return chain.proceed(newRequest)

// If suppressNetworkExceptions is disabled, use default behavior (let exceptions propagate)
if (!configMemoryHolder.suppressNetworkExceptions) {
return chain.proceed(newRequest)
}

return try {
chain.proceed(newRequest)
} catch (e: Exception) {
// Handle network exceptions that would otherwise crash the app when they occur
// on OkHttp's background threads. These exceptions can bypass RxJava's error
// handling since they may occur in OkHttp's thread pool context.
when (e) {
is SocketTimeoutException,
is InterruptedIOException,
is SocketException,
is UnknownHostException,
is SSLException,
is IOException -> {
e("Network error intercepted: ${e.javaClass.simpleName} - ${e.message}")
// Return a synthetic error response to prevent crashes while
// preserving error semantics for the app's error handling
Response.Builder()
.request(newRequest)
.protocol(Protocol.HTTP_1_1)
.code(599) // Network connect timeout error
.message("Network Error: ${e.message ?: "Unknown"}")
.body("".toResponseBody(null))
.build()
}
else -> throw e // Re-throw unexpected non-network exceptions
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,6 @@ class ConfigMemoryHolder @Inject constructor() {
var userId: String? = null

var defaultAnalyticsTags: Map<String, String>? = null

var suppressNetworkExceptions: Boolean = false
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ class ConstructorIoAutocompleteTest {
every { configMemoryHolder.userId } returns "player-one"
every { configMemoryHolder.testCellParams } returns emptyList()
every { configMemoryHolder.segments } returns emptyList()
every { configMemoryHolder.suppressNetworkExceptions } returns false

val config = ConstructorIoConfig("dummyKey")
val dataManager = createTestDataManager(preferencesHelper, configMemoryHolder)
Expand All @@ -68,7 +69,7 @@ class ConstructorIoAutocompleteTest {
}
val request = mockServer.takeRequest()
val path =
"/autocomplete/titanic?key=golden-key&i=guido-the-guid&ui=player-one&s=79&c=cioand-2.36.0&_dt="
"/autocomplete/titanic?key=golden-key&i=guido-the-guid&ui=player-one&s=79&c=cioand-2.38.0-cdx-358-2&_dt="
assert(request.path!!.startsWith(path))
}

Expand All @@ -87,7 +88,7 @@ class ConstructorIoAutocompleteTest {
}
val request = mockServer.takeRequest()
val path =
"/autocomplete/titanic?filters%5BstoreLocation%5D=CA&key=golden-key&i=guido-the-guid&ui=player-one&s=79&c=cioand-2.36.0&_dt="
"/autocomplete/titanic?filters%5BstoreLocation%5D=CA&key=golden-key&i=guido-the-guid&ui=player-one&s=79&c=cioand-2.38.0-cdx-358-2&_dt="
assert(request.path!!.startsWith(path))
}

Expand All @@ -110,7 +111,7 @@ class ConstructorIoAutocompleteTest {
}
val request = mockServer.takeRequest()
val path =
"/autocomplete/titanic?filters%5BstoreLocation%5D=CA&filters%5BSearch%20Suggestions%5D%5BstoreLocation%5D=US&filters%5BProducts%5D%5Bbrand%5D=Top%20Brand&key=golden-key&i=guido-the-guid&ui=player-one&s=79&c=cioand-2.36.0&_dt="
"/autocomplete/titanic?filters%5BstoreLocation%5D=CA&filters%5BSearch%20Suggestions%5D%5BstoreLocation%5D=US&filters%5BProducts%5D%5Bbrand%5D=Top%20Brand&key=golden-key&i=guido-the-guid&ui=player-one&s=79&c=cioand-2.38.0-cdx-358-2&_dt="
assert(request.path!!.startsWith(path))
}

Expand All @@ -126,7 +127,7 @@ class ConstructorIoAutocompleteTest {
}
val request = mockServer.takeRequest()
val path =
"/autocomplete/titanic?filters%5Bgroup_id%5D=101&key=golden-key&i=guido-the-guid&ui=player-one&s=79&c=cioand-2.36.0&_dt="
"/autocomplete/titanic?filters%5Bgroup_id%5D=101&key=golden-key&i=guido-the-guid&ui=player-one&s=79&c=cioand-2.38.0-cdx-358-2&_dt="
assert(request.path!!.startsWith(path))
}

Expand All @@ -140,7 +141,7 @@ class ConstructorIoAutocompleteTest {
}
val request = mockServer.takeRequest()
val path =
"/autocomplete/titanic?key=golden-key&i=guido-the-guid&ui=player-one&s=79&c=cioand-2.36.0&_dt="
"/autocomplete/titanic?key=golden-key&i=guido-the-guid&ui=player-one&s=79&c=cioand-2.38.0-cdx-358-2&_dt="
assert(request.path!!.startsWith(path))
}

Expand All @@ -156,7 +157,7 @@ class ConstructorIoAutocompleteTest {
}
val request = mockServer.takeRequest()
val path =
"/autocomplete/titanic?key=golden-key&i=guido-the-guid&ui=player-one&s=79&c=cioand-2.36.0&_dt="
"/autocomplete/titanic?key=golden-key&i=guido-the-guid&ui=player-one&s=79&c=cioand-2.38.0-cdx-358-2&_dt="
assert(request.path!!.startsWith(path))
}

Expand All @@ -172,7 +173,7 @@ class ConstructorIoAutocompleteTest {
}
val request = mockServer.takeRequest()
val path =
"/autocomplete/titanic?key=golden-key&i=guido-the-guid&ui=player-one&s=79&c=cioand-2.36.0&_dt="
"/autocomplete/titanic?key=golden-key&i=guido-the-guid&ui=player-one&s=79&c=cioand-2.38.0-cdx-358-2&_dt="
assert(request.path!!.startsWith(path))
}

Expand All @@ -189,7 +190,7 @@ class ConstructorIoAutocompleteTest {
).test()
val request = mockServer.takeRequest()
val path =
"/autocomplete/bbq?fmt_options%5Bhidden_fields%5D=hiddenField1&fmt_options%5Bhidden_fields%5D=hiddenField2&key=golden-key&i=guido-the-guid&ui=player-one&s=79&c=cioand-2.36.0&_dt="
"/autocomplete/bbq?fmt_options%5Bhidden_fields%5D=hiddenField1&fmt_options%5Bhidden_fields%5D=hiddenField2&key=golden-key&i=guido-the-guid&ui=player-one&s=79&c=cioand-2.38.0-cdx-358-2&_dt="
assert(request.path!!.startsWith(path))
}

Expand All @@ -201,7 +202,7 @@ class ConstructorIoAutocompleteTest {
val observer = constructorIo.getAutocompleteResults("2% cheese").test()
val request = mockServer.takeRequest()
val path =
"/autocomplete/2%25%20cheese?key=golden-key&i=guido-the-guid&ui=player-one&s=79&c=cioand-2.36.0&_dt="
"/autocomplete/2%25%20cheese?key=golden-key&i=guido-the-guid&ui=player-one&s=79&c=cioand-2.38.0-cdx-358-2&_dt="
assert(request.path!!.startsWith(path))
}

Expand All @@ -224,7 +225,7 @@ class ConstructorIoAutocompleteTest {
}
val request = mockServer.takeRequest()
val path =
"/autocomplete/titanic?filters%5BstoreLocation%5D=CA&filters%5Bgroup_id%5D=101&key=golden-key&i=guido-the-guid&ui=player-one&s=79&c=cioand-2.36.0&_dt="
"/autocomplete/titanic?filters%5BstoreLocation%5D=CA&filters%5Bgroup_id%5D=101&key=golden-key&i=guido-the-guid&ui=player-one&s=79&c=cioand-2.38.0-cdx-358-2&_dt="
assert(request.path!!.startsWith(path))
}

Expand All @@ -247,7 +248,7 @@ class ConstructorIoAutocompleteTest {
}
val request = mockServer.takeRequest()
val path =
"/autocomplete/titanic?num_results_Products=5&num_results_Search%20Suggestions=10&key=golden-key&i=guido-the-guid&ui=player-one&s=79&c=cioand-2.36.0&_dt="
"/autocomplete/titanic?num_results_Products=5&num_results_Search%20Suggestions=10&key=golden-key&i=guido-the-guid&ui=player-one&s=79&c=cioand-2.38.0-cdx-358-2&_dt="
assert(request.path!!.startsWith(path))
}

Expand Down Expand Up @@ -281,7 +282,7 @@ class ConstructorIoAutocompleteTest {
"i" to "guido-the-guid",
"ui" to "player-one",
"s" to "79",
"c" to "cioand-2.36.0",
"c" to "cioand-2.38.0-cdx-358-2",
"_dt" to "1"
)
assertThat(queryParameterNames).containsExactlyInAnyOrderElementsOf(queryParams.keys)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ class ConstructorIoIntegrationQuizTest {
every { configMemoryHolder.defaultAnalyticsTags } returns mapOf("appVersion" to "123", "appPlatform" to "Android")
every { configMemoryHolder.testCellParams } returns emptyList()
every { configMemoryHolder.segments } returns emptyList()
every { configMemoryHolder.suppressNetworkExceptions } returns false

val config = ConstructorIoConfig("ZqXaOfXuBWD4s3XzCI1q")
val dataManager = createTestDataManager(preferencesHelper, configMemoryHolder)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ class ConstructorIoIntegrationTest {
every { configMemoryHolder.userId } returns "player-three"
every { configMemoryHolder.testCellParams } returns emptyList()
every { configMemoryHolder.segments } returns emptyList()
every { configMemoryHolder.suppressNetworkExceptions } returns false

val config = ConstructorIoConfig("ZqXaOfXuBWD4s3XzCI1q")
val dataManager = createTestDataManager(preferencesHelper, configMemoryHolder)
Expand Down
Loading