Skip to content

Commit 37f3006

Browse files
Update default subscription type and update auth interceptor to not handle all errors (#4)
2 parents 5d9b24d + 17b58d7 commit 37f3006

File tree

6 files changed

+80
-86
lines changed

6 files changed

+80
-86
lines changed

stream-android-core/src/main/java/io/getstream/android/core/api/StreamClient.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -259,10 +259,11 @@ fun StreamClient(
259259
httpBuilder.apply {
260260
addInterceptor(StreamOkHttpInterceptors.clientInfo(clientInfoHeader))
261261
addInterceptor(StreamOkHttpInterceptors.apiKey(apiKey))
262+
addInterceptor(StreamOkHttpInterceptors.connectionId(connectionIdHolder))
262263
addInterceptor(
263264
StreamOkHttpInterceptors.auth("jwt", tokenManager, compositeSerialization)
264265
)
265-
addInterceptor(StreamOkHttpInterceptors.connectionId(connectionIdHolder))
266+
addInterceptor(StreamOkHttpInterceptors.error(compositeSerialization))
266267
}
267268
}
268269
configuredInterceptors.forEach { httpBuilder.addInterceptor(it) }

stream-android-core/src/main/java/io/getstream/android/core/api/model/config/StreamClientSerializationConfig.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ package io.getstream.android.core.api.model.config
1717

1818
import io.getstream.android.core.annotations.StreamCoreApi
1919
import io.getstream.android.core.api.model.event.StreamClientWsEvent
20-
import io.getstream.android.core.api.serialization.StreamJsonSerialization
2120
import io.getstream.android.core.api.serialization.StreamEventSerialization
21+
import io.getstream.android.core.api.serialization.StreamJsonSerialization
2222

2323
/**
2424
* Configuration for serialization and deserialization in the Stream client.

stream-android-core/src/main/java/io/getstream/android/core/api/serialization/StreamEventSerialization.kt

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,10 +49,9 @@ interface StreamEventSerialization<T> {
4949
fun deserialize(raw: String): Result<T>
5050
}
5151

52-
53-
5452
/**
55-
* Creates a new [StreamEventSerialization] instance that can serialize [io.getstream.android.core.api.model.event.StreamClientWsEvent] objects.
53+
* Creates a new [StreamEventSerialization] instance that can serialize
54+
* [io.getstream.android.core.api.model.event.StreamClientWsEvent] objects.
5655
*
5756
* @param jsonParser The [StreamJsonSerialization] to use for JSON serialization and
5857
* deserialization.

stream-android-core/src/main/java/io/getstream/android/core/api/subscribe/StreamSubscriptionManager.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ interface StreamSubscriptionManager<T> {
4444
*
4545
* @property retention Controls how the manager retains the listener reference.
4646
*/
47-
data class Options(val retention: Retention = Retention.AUTO_REMOVE) {
47+
data class Options(val retention: Retention = Retention.KEEP_UNTIL_CANCELLED) {
4848
/** Retention policy for a subscribed listener. */
4949
enum class Retention {
5050
/**

stream-android-core/src/main/java/io/getstream/android/core/internal/http/interceptor/StreamAuthInterceptor.kt

Lines changed: 18 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -67,25 +67,25 @@ internal class StreamAuthInterceptor(
6767
val original = chain.request()
6868
val authed = original.withAuthHeaders(authType, token.rawValue)
6969

70-
val firstResponse = chain.proceed(authed)
71-
if (firstResponse.isSuccessful) {
72-
return firstResponse
70+
val first = chain.proceed(authed)
71+
if (first.isSuccessful) {
72+
return first
7373
}
7474

75-
val errorBody = firstResponse.peekBody(PEEK_ERROR_BYTES_MAX).string()
76-
val parsed = jsonParser.fromJson(errorBody, StreamEndpointErrorData::class.java)
75+
// Peek only; do NOT consume
76+
val peeked = first.peekBody(PEEK_ERROR_BYTES_MAX).string()
77+
val parsed = jsonParser.fromJson(peeked, StreamEndpointErrorData::class.java)
7778

78-
// Guard against infinite loops: retry at most once per request.
7979
val alreadyRetried = original.header(HEADER_RETRIED_ON_AUTH) == "present"
8080

8181
if (parsed.isSuccess) {
8282
val error = parsed.getOrEndpointException("Failed to parse error body.")
83-
if (!alreadyRetried && isTokenInvalidErrorCode(error.code)) {
84-
// Refresh and retry once.
85-
firstResponse.close()
86-
tokenManager
87-
.invalidate()
88-
.getOrEndpointException(message = "Failed to invalidate token")
83+
84+
// Only handle token errors here
85+
if (isTokenInvalidErrorCode(error.code) && !alreadyRetried) {
86+
// refresh & retry once
87+
first.close()
88+
tokenManager.invalidate().getOrEndpointException("Failed to invalidate token")
8989
val refreshed =
9090
runBlocking { tokenManager.refresh() }
9191
.getOrEndpointException("Failed to refresh token")
@@ -97,19 +97,15 @@ internal class StreamAuthInterceptor(
9797
.header(HEADER_RETRIED_ON_AUTH, "present")
9898
.build()
9999

100-
return chain.proceed(retried)
100+
return chain.proceed(retried) // pass result (ok or error) downstream
101101
}
102102

103-
// Non-token error or we already retried: surface a structured exception.
104-
firstResponse.close()
105-
throw StreamEndpointException("Failed request: ${original.url}", error, null)
103+
// Non-token error, or token error but we already retried:
104+
// pass the original failed response downstream; DO NOT throw here.
105+
return first
106106
} else {
107-
// Couldn’t parse error, still fail in a consistent way.
108-
firstResponse.close()
109-
throw StreamEndpointException(
110-
"Failed to serialize response error body: ${original.url}",
111-
null,
112-
)
107+
// Unknown/invalid error body → pass through
108+
return first
113109
}
114110
}
115111

stream-android-core/src/test/java/io/getstream/android/core/internal/http/interceptor/StreamAuthInterceptorTest.kt

Lines changed: 56 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ package io.getstream.android.core.internal.http.interceptor
1717

1818
import io.getstream.android.core.api.authentication.StreamTokenManager
1919
import io.getstream.android.core.api.model.exceptions.StreamEndpointErrorData
20-
import io.getstream.android.core.api.model.exceptions.StreamEndpointException
2120
import io.getstream.android.core.api.model.value.StreamToken
2221
import io.getstream.android.core.api.serialization.StreamJsonSerialization
2322
import io.mockk.MockKAnnotations
@@ -28,14 +27,15 @@ import io.mockk.impl.annotations.MockK
2827
import io.mockk.verify
2928
import java.util.concurrent.TimeUnit
3029
import kotlin.test.assertEquals
31-
import kotlin.test.assertFailsWith
3230
import kotlin.test.assertTrue
3331
import okhttp3.Interceptor
3432
import okhttp3.OkHttpClient
3533
import okhttp3.Request
3634
import okhttp3.mockwebserver.MockResponse
3735
import okhttp3.mockwebserver.MockWebServer
3836
import org.junit.After
37+
import org.junit.Assert.assertFalse
38+
import org.junit.Assert.assertNull
3939
import org.junit.Before
4040
import org.junit.Test
4141

@@ -187,105 +187,103 @@ class StreamAuthInterceptorTest {
187187
}
188188

189189
@Test
190-
fun `non-token error throws StreamEndpointException without retry`() {
191-
val token = streamToken("t1")
190+
fun `token error with alreadyRetried header passes through without retry`() {
191+
val token = streamToken("stale")
192192
coEvery { tokenManager.loadIfAbsent() } returns Result.success(token)
193193

194-
val nonTokenError = tokenErrorData(422)
194+
// Proper token error code handled by this interceptor
195+
val tokenError = tokenErrorData(40)
195196
every { json.fromJson(any(), StreamEndpointErrorData::class.java) } returns
196-
Result.success(nonTokenError)
197+
Result.success(tokenError)
197198

198199
val interceptor = StreamAuthInterceptor(tokenManager, json, authType = "jwt")
199200
val client = client(interceptor)
200201

201-
server.enqueue(MockResponse().setResponseCode(422).setBody("""{"error":"unprocessable"}"""))
202+
server.enqueue(MockResponse().setResponseCode(401).setBody("""{"error":"token invalid"}"""))
203+
204+
val url = server.url("/v1/protected")
202205

203-
val url = server.url("/v1/fail")
204-
val ex =
205-
assertFailsWith<StreamEndpointException> {
206-
client
207-
.newCall(Request.Builder().url(url).build())
208-
.execute()
209-
.use { /* force execution */ }
206+
client
207+
.newCall(
208+
Request.Builder()
209+
.url(url)
210+
.header("x-stream-retried-on-auth", "present") // simulate already retried
211+
.build()
212+
)
213+
.execute()
214+
.use { resp ->
215+
assertFalse(resp.isSuccessful) // pass-through, no exception here
216+
assertEquals(401, resp.code)
210217
}
211-
assertTrue(ex.message!!.contains("Failed request"))
212218

213-
// Consume the single (failed) request
214219
val first = server.takeRequest(2, TimeUnit.SECONDS)
215-
kotlin.test.assertNotNull(first, "Expected exactly one request to be sent")
216-
217-
// Assert no second request (i.e., no retry)
218-
val second = server.takeRequest(300, TimeUnit.MILLISECONDS)
219-
kotlin.test.assertNull(second, "Interceptor should not retry on non-token errors")
220+
kotlin.test.assertNotNull(first)
221+
kotlin.test.assertNull(server.takeRequest(300, TimeUnit.MILLISECONDS)) // no second try
220222

223+
// No refresh/invalidate when header indicates we already retried
221224
coVerify(exactly = 1) { tokenManager.loadIfAbsent() }
222-
io.mockk.verify(exactly = 0) { tokenManager.invalidate() }
225+
verify(exactly = 0) { tokenManager.invalidate() }
223226
coVerify(exactly = 0) { tokenManager.refresh() }
224227
}
225228

229+
/** Non-token error codes are NOT handled here; pass response through without retry. */
226230
@Test
227-
fun `unparseable error throws StreamEndpointException`() {
231+
fun `non-token error passes through without retry`() {
228232
val token = streamToken("t1")
229233
coEvery { tokenManager.loadIfAbsent() } returns Result.success(token)
230234

235+
// e.g., business error code that is not 40/41/42
236+
val nonTokenError = tokenErrorData(13)
231237
every { json.fromJson(any(), StreamEndpointErrorData::class.java) } returns
232-
Result.failure(IllegalStateException("parse error"))
238+
Result.success(nonTokenError)
233239

234240
val interceptor = StreamAuthInterceptor(tokenManager, json, authType = "jwt")
235241
val client = client(interceptor)
236242

237-
server.enqueue(MockResponse().setResponseCode(500).setBody("not-json"))
243+
server.enqueue(MockResponse().setResponseCode(422).setBody("""{"error":"validation"}"""))
238244

239-
val url = server.url("/v1/error")
240-
val ex =
241-
assertFailsWith<StreamEndpointException> {
242-
client.newCall(Request.Builder().url(url).build()).execute().use { /* consume */ }
243-
}
244-
assertTrue(ex.message!!.contains("Failed to serialize response error body"))
245+
val url = server.url("/v1/endpoint")
246+
client.newCall(Request.Builder().url(url).build()).execute().use { resp ->
247+
assertFalse(resp.isSuccessful) // still an error; just passed along
248+
assertEquals(422, resp.code)
249+
}
250+
251+
// No retry, no token refresh
252+
val req = server.takeRequest(2, TimeUnit.SECONDS)!!
253+
assertEquals("t1", req.getHeader("Authorization"))
254+
kotlin.test.assertNull(server.takeRequest(300, TimeUnit.MILLISECONDS))
245255

246-
coVerify(exactly = 1) { tokenManager.loadIfAbsent() }
247256
verify(exactly = 0) { tokenManager.invalidate() }
248257
coVerify(exactly = 0) { tokenManager.refresh() }
249258
}
250259

260+
/** If the error body cannot be parsed into StreamEndpointErrorData, pass through. */
251261
@Test
252-
fun `token error with alreadyRetried header does not retry again`() {
253-
val token = streamToken("stale")
262+
fun `unparsable error body passes through without retry`() {
263+
val token = streamToken("t1")
254264
coEvery { tokenManager.loadIfAbsent() } returns Result.success(token)
255-
every { tokenManager.invalidate() } returns Result.success(Unit)
256265

257-
val tokenError = tokenErrorData(401)
258266
every { json.fromJson(any(), StreamEndpointErrorData::class.java) } returns
259-
Result.success(tokenError)
267+
Result.failure(IllegalStateException("bad json"))
260268

261269
val interceptor = StreamAuthInterceptor(tokenManager, json, authType = "jwt")
262270
val client = client(interceptor)
263271

264-
server.enqueue(MockResponse().setResponseCode(401).setBody("""{"error":"token invalid"}"""))
272+
server.enqueue(MockResponse().setResponseCode(500).setBody("""<html>oops</html>"""))
265273

266-
val url = server.url("/v1/protected")
267-
val ex =
268-
assertFailsWith<StreamEndpointException> {
269-
client
270-
.newCall(
271-
Request.Builder()
272-
.url(url)
273-
.header(
274-
"x-stream-retried-on-auth",
275-
"present",
276-
) // simulate already retried
277-
.build()
278-
)
279-
.execute()
280-
.use { /* consume */ }
281-
}
282-
assertTrue(ex.message!!.contains("Failed request"))
274+
val url = server.url("/v1/boom")
275+
client.newCall(Request.Builder().url(url).build()).execute().use { resp ->
276+
assertFalse(resp.isSuccessful)
277+
assertEquals(500, resp.code)
278+
}
283279

284-
val first = server.takeRequest(2, TimeUnit.SECONDS)
285-
kotlin.test.assertNotNull(first)
280+
// Consume the single request we expect
281+
val first = server.takeRequest(2, TimeUnit.SECONDS)!!
282+
assertEquals("t1", first.getHeader("Authorization"))
283+
284+
// Now verify there's no retry
286285
kotlin.test.assertNull(server.takeRequest(300, TimeUnit.MILLISECONDS))
287286

288-
coVerify(exactly = 1) { tokenManager.loadIfAbsent() }
289287
verify(exactly = 0) { tokenManager.invalidate() }
290288
coVerify(exactly = 0) { tokenManager.refresh() }
291289
}

0 commit comments

Comments
 (0)