Skip to content

Commit 85166ce

Browse files
author
github-actions
committed
fix: Improved error handling
1 parent 9553fd4 commit 85166ce

4 files changed

Lines changed: 236 additions & 28 deletions

File tree

app/src/androidTest/java/com/extole/blackbox/sdk/ExtoleSdkTests.kt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ import kotlinx.coroutines.launch
1717
import kotlinx.coroutines.runBlocking
1818
import org.apache.commons.lang3.RandomStringUtils
1919
import org.assertj.core.api.Assertions.assertThat
20-
import org.assertj.core.api.Assertions.assertThatThrownBy
2120
import org.assertj.core.api.Assertions.fail
2221
import org.awaitility.Awaitility.await
2322
import org.awaitility.Duration

mobile-sdk/build.gradle

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,8 @@ android {
3131
defaultConfig {
3232
minSdkVersion 21
3333
targetSdkVersion 36
34-
versionCode 72
35-
versionName "1.0.75"
34+
versionCode 73
35+
versionName "1.0.76"
3636

3737
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
3838
consumerProguardFiles "consumer-rules.pro"
@@ -50,6 +50,11 @@ android {
5050
kotlinOptions {
5151
jvmTarget = '1.8'
5252
}
53+
testOptions {
54+
unitTests {
55+
includeAndroidResources = true
56+
}
57+
}
5358
}
5459

5560
dependencies {
@@ -63,6 +68,7 @@ dependencies {
6368
api 'com.orhanobut:logger:2.2.0'
6469

6570
testImplementation 'junit:junit:4.13.2'
71+
testImplementation 'org.robolectric:robolectric:4.14.1'
6672
androidTestImplementation 'org.assertj:assertj-core:3.21.0'
6773
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
6874
}

mobile-sdk/src/main/java/com/extole/android/sdk/impl/http/Endpoints.kt

Lines changed: 107 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,15 @@ package com.extole.android.sdk.impl.http
33
import com.extole.android.sdk.RestException
44
import com.extole.android.sdk.impl.ResponseEntity
55
import com.extole.android.sdk.impl.http.HttpRequest.HttpRequestException
6+
import org.json.JSONException
67
import org.json.JSONObject
78

9+
private const val HTTP_STATUS_MIN = 100
10+
private const val HTTP_STATUS_MAX = 599
11+
private const val ERROR_BODY_PREVIEW_LENGTH = 512
12+
private const val FALLBACK_TRANSPORT_ERROR_STATUS = "502"
13+
private const val FALLBACK_APPLICATION_ERROR_STATUS = "400"
14+
815
class Endpoints(
916
val accessToken: String?,
1017
val headers: Map<String, String> = emptyMap()
@@ -15,26 +22,26 @@ class Endpoints(
1522
body: JSONObject? = null
1623
): ResponseEntity<JSONObject> {
1724
try {
18-
var resultBody: String?
19-
if (body != null) {
20-
resultBody = httpRequest.send(body.toString()).body()
21-
} else {
22-
resultBody = httpRequest.body()
23-
}
24-
if (httpRequest.ok() || httpRequest.created() || httpRequest.noContent()) {
25-
return ResponseEntity(
26-
JSONObject(resultBody.ifBlank { "{}" }),
27-
httpRequest.headers(),
28-
httpRequest.code()
29-
)
30-
} else {
31-
throw handleException(httpRequest)
25+
val responseBody =
26+
if (body != null) httpRequest.send(body.toString()).body()
27+
else httpRequest.body()
28+
29+
when {
30+
httpRequest.ok() || httpRequest.created() || httpRequest.noContent() ->
31+
return ResponseEntity(
32+
JSONObject(responseBody.ifBlank { "{}" }),
33+
httpRequest.headers(),
34+
httpRequest.code()
35+
)
36+
37+
else ->
38+
throw restExceptionFromHttpErrorResponse(
39+
resolveHttpStatusCode(httpRequest),
40+
responseBody
41+
)
3242
}
3343
} catch (e: HttpRequestException) {
34-
throw RestException(
35-
"http_request_exception", "500", "http_request_exception", e.message
36-
?: "HttpRequestException", emptyMap(), e
37-
)
44+
throw restExceptionFromTransportFailure(resolveHttpStatusCode(httpRequest), e)
3845
}
3946
}
4047

@@ -46,14 +53,89 @@ class Endpoints(
4653
.headers(headers)
4754
}
4855

49-
private fun handleException(httpRequest: HttpRequest): RestException {
50-
val responseBody = JSONObject(httpRequest.body())
56+
private fun resolveHttpStatusCode(httpRequest: HttpRequest): String? =
57+
try {
58+
val code = httpRequest.code()
59+
if (code in HTTP_STATUS_MIN..HTTP_STATUS_MAX) code.toString() else null
60+
} catch (_: HttpRequestException) {
61+
null
62+
}
63+
}
64+
65+
internal fun restExceptionFromHttpErrorResponse(
66+
statusFromWire: String?,
67+
rawBody: String
68+
): RestException {
69+
val trimmedBody = rawBody.trim()
70+
if (trimmedBody.isEmpty()) {
5171
return RestException(
52-
responseBody.getString("unique_id"),
53-
responseBody.getString("http_status_code"),
54-
responseBody.getString("code"),
55-
responseBody.getString("message"),
56-
toMap(responseBody.getJSONObject("parameters"))
72+
uniqueId = "empty_error_body",
73+
httpStatusCode = statusFromWire ?: FALLBACK_APPLICATION_ERROR_STATUS,
74+
errorCode = "empty_error_body",
75+
message =
76+
if (statusFromWire != null) {
77+
"Empty response body for HTTP $statusFromWire"
78+
} else {
79+
"Empty error response body"
80+
},
81+
parameters = emptyMap()
82+
)
83+
}
84+
85+
return try {
86+
val json = JSONObject(trimmedBody)
87+
if (isStructuredRestError(json)) {
88+
val statusFromPayload = json.optString("http_status_code").trim()
89+
RestException(
90+
json.getString("unique_id"),
91+
statusFromPayload.ifBlank { statusFromWire ?: FALLBACK_APPLICATION_ERROR_STATUS },
92+
json.getString("code"),
93+
json.getString("message"),
94+
toMap(json.optJSONObject("parameters") ?: JSONObject())
95+
)
96+
} else {
97+
RestException(
98+
uniqueId = "unexpected_error_payload",
99+
httpStatusCode = statusFromWire ?: FALLBACK_APPLICATION_ERROR_STATUS,
100+
errorCode = "unexpected_error_payload",
101+
message = trimmedBody.take(ERROR_BODY_PREVIEW_LENGTH),
102+
parameters = emptyMap()
103+
)
104+
}
105+
} catch (_: JSONException) {
106+
RestException(
107+
uniqueId = "invalid_error_payload",
108+
httpStatusCode = statusFromWire ?: FALLBACK_APPLICATION_ERROR_STATUS,
109+
errorCode = "invalid_error_payload",
110+
message = trimmedBody.take(ERROR_BODY_PREVIEW_LENGTH),
111+
parameters = emptyMap()
57112
)
58113
}
59114
}
115+
116+
internal fun restExceptionFromTransportFailure(
117+
resolvedHttpStatusToken: String?,
118+
cause: HttpRequestException
119+
): RestException =
120+
RestException(
121+
uniqueId = "http_request_exception",
122+
httpStatusCode = resolvedHttpStatusToken ?: FALLBACK_TRANSPORT_ERROR_STATUS,
123+
errorCode = "http_request_exception",
124+
message = readableTransportMessage(cause),
125+
parameters = emptyMap()
126+
)
127+
128+
internal fun readableTransportMessage(e: HttpRequestException): String {
129+
val cause = e.cause
130+
val detail =
131+
cause?.message?.takeIf { it.isNotBlank() }
132+
?: e.message?.takeIf { it.isNotBlank() }
133+
?: "HttpRequestException"
134+
if (cause == null) return detail
135+
val type = cause.javaClass.simpleName
136+
val fqcnPrefix = "${cause.javaClass.name}:"
137+
return if (detail.startsWith(type) || detail.startsWith(fqcnPrefix)) detail else "$type: $detail"
138+
}
139+
140+
private fun isStructuredRestError(body: JSONObject): Boolean =
141+
body.has("unique_id") && body.has("code") && body.has("message")
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
package com.extole.android.sdk.impl.http
2+
3+
import org.junit.Assert.assertEquals
4+
import org.junit.Assert.assertTrue
5+
import org.junit.Test
6+
import org.junit.runner.RunWith
7+
import org.robolectric.RobolectricTestRunner
8+
import org.robolectric.annotation.Config
9+
import java.io.FileNotFoundException
10+
import java.io.IOException
11+
12+
@RunWith(RobolectricTestRunner::class)
13+
@Config(sdk = [34])
14+
class EndpointsErrorHandlingTest {
15+
16+
@Test
17+
fun structuredErrorParsesEnvelopeAndNoCreative() {
18+
val body =
19+
"""
20+
{"unique_id":"zr_1","http_status_code":"400","code":"no_creative","message":"Not available","parameters":{}}
21+
""".trimIndent()
22+
23+
val ex = restExceptionFromHttpErrorResponse("400", body)
24+
25+
assertEquals("zr_1", ex.uniqueId)
26+
assertEquals("400", ex.httpStatusCode)
27+
assertEquals("no_creative", ex.errorCode)
28+
assertEquals("Not available", ex.message)
29+
assertEquals(emptyMap<String, Any>(), ex.parameters)
30+
}
31+
32+
@Test
33+
fun structuredErrorFillsMissingHttpStatusCodeFromWire() {
34+
val body =
35+
"""
36+
{"unique_id":"uid","code":"rate_limited","message":"Slow down","parameters":{}}
37+
""".trimIndent()
38+
39+
val ex = restExceptionFromHttpErrorResponse("429", body)
40+
41+
assertEquals("429", ex.httpStatusCode)
42+
assertEquals("rate_limited", ex.errorCode)
43+
}
44+
45+
@Test
46+
fun structuredErrorMissingParametersProducesEmptyParametersMap() {
47+
val body =
48+
"""
49+
{"unique_id":"uid","http_status_code":"400","code":"no_creative","message":"x"}
50+
""".trimIndent()
51+
52+
val ex = restExceptionFromHttpErrorResponse("400", body)
53+
assertEquals(emptyMap<String, Any>(), ex.parameters)
54+
}
55+
56+
@Test
57+
fun emptyBodyUsesWireStatus() {
58+
val ex = restExceptionFromHttpErrorResponse("400", "")
59+
assertEquals("empty_error_body", ex.errorCode)
60+
assertEquals("400", ex.httpStatusCode)
61+
assertTrue(ex.message.contains("HTTP 400"))
62+
}
63+
64+
@Test
65+
fun emptyBodyFallbackStatusWhenWireUnknown() {
66+
val ex = restExceptionFromHttpErrorResponse(null, " ")
67+
assertEquals("400", ex.httpStatusCode)
68+
assertEquals("empty_error_body", ex.uniqueId)
69+
}
70+
71+
@Test
72+
fun malformedJsonProducesInvalidPayloadPlaceholder() {
73+
val ex = restExceptionFromHttpErrorResponse("503", "<html>Error</html>")
74+
assertEquals("invalid_error_payload", ex.errorCode)
75+
assertEquals("503", ex.httpStatusCode)
76+
assertTrue(ex.message.contains("<html>"))
77+
}
78+
79+
@Test
80+
fun jsonMissingEnvelopeProducesUnexpectedPayload() {
81+
val ex = restExceptionFromHttpErrorResponse(null, "{\"foo\":1}")
82+
assertEquals("unexpected_error_payload", ex.uniqueId)
83+
assertEquals("400", ex.httpStatusCode)
84+
assertEquals("{\"foo\":1}", ex.message)
85+
}
86+
87+
@Test
88+
fun transportFailureUsesResolvedHttpStatus() {
89+
val cause =
90+
HttpRequest.HttpRequestException(
91+
FileNotFoundException("https://example/api/v6/zones/z")
92+
)
93+
val ex = restExceptionFromTransportFailure("400", cause)
94+
assertEquals("400", ex.httpStatusCode)
95+
assertEquals("http_request_exception", ex.errorCode)
96+
assertTrue(ex.message.contains("FileNotFoundException"))
97+
assertTrue(ex.message.contains("https://example/api/v6/zones/z"))
98+
}
99+
100+
@Test
101+
fun transportFailureWithoutStatusUsesGatewayFallback() {
102+
val cause =
103+
HttpRequest.HttpRequestException(IOException("Connection reset"))
104+
105+
val ex = restExceptionFromTransportFailure(null, cause)
106+
assertEquals("502", ex.httpStatusCode)
107+
assertEquals("http_request_exception", ex.uniqueId)
108+
assertTrue(ex.message.contains("IOException"))
109+
}
110+
111+
@Test
112+
fun readableTransportMessageAddsExceptionTypeWhenMissingFromDetail() {
113+
val url = "https://program.example/api/v6/zones/marketing_experience"
114+
val ex =
115+
readableTransportMessage(
116+
HttpRequest.HttpRequestException(FileNotFoundException(url))
117+
)
118+
assertTrue(ex.contains("FileNotFoundException"))
119+
assertTrue(ex.contains(url))
120+
}
121+
}

0 commit comments

Comments
 (0)