@@ -11,6 +11,13 @@ import io.getunleash.android.errors.ServerException
11
11
import io.getunleash.android.events.HeartbeatEvent
12
12
import io.getunleash.android.http.Throttler
13
13
import io.getunleash.android.unleashScope
14
+ import java.io.Closeable
15
+ import java.io.IOException
16
+ import java.util.concurrent.TimeUnit
17
+ import java.util.concurrent.atomic.AtomicReference
18
+ import kotlin.coroutines.CoroutineContext
19
+ import kotlin.coroutines.resume
20
+ import kotlin.coroutines.resumeWithException
14
21
import kotlinx.coroutines.Dispatchers
15
22
import kotlinx.coroutines.channels.BufferOverflow
16
23
import kotlinx.coroutines.flow.MutableSharedFlow
@@ -30,54 +37,55 @@ import okhttp3.OkHttpClient
30
37
import okhttp3.Request
31
38
import okhttp3.Response
32
39
import okhttp3.internal.closeQuietly
33
- import java.io.Closeable
34
- import java.io.IOException
35
- import java.util.concurrent.TimeUnit
36
- import java.util.concurrent.atomic.AtomicReference
37
- import kotlin.coroutines.CoroutineContext
38
- import kotlin.coroutines.resume
39
- import kotlin.coroutines.resumeWithException
40
40
41
41
/* *
42
- * Http Client for fetching data from Unleash Proxy.
43
- * By default creates an OkHttpClient with readTimeout set to 2 seconds and a cache of 10 MBs
44
- * @param httpClient - the http client to use for fetching toggles from Unleash proxy
42
+ * Http Client for fetching data from Unleash Proxy. By default creates an OkHttpClient with
43
+ * readTimeout set to 2 seconds and a cache of 10 MBs
44
+ * @param httpClient
45
+ * - the http client to use for fetching toggles from Unleash proxy
45
46
*/
46
47
open class UnleashFetcher (
47
- unleashConfig : UnleashConfig ,
48
- private val httpClient : OkHttpClient ,
49
- private val unleashContext : StateFlow <UnleashContext >,
48
+ unleashConfig : UnleashConfig ,
49
+ private val httpClient : OkHttpClient ,
50
+ private val unleashContext : StateFlow <UnleashContext >,
50
51
) : Closeable {
51
52
companion object {
52
53
private const val TAG = " UnleashFetcher"
53
54
}
54
-
55
+ @Volatile private var contextForLastFetch : UnleashContext ? = null
55
56
private val proxyUrl = unleashConfig.proxyUrl?.toHttpUrl()
56
- private val applicationHeaders = unleashConfig.getApplicationHeaders(unleashConfig.pollingStrategy)
57
+ private val applicationHeaders =
58
+ unleashConfig.getApplicationHeaders(unleashConfig.pollingStrategy)
57
59
private val appName = unleashConfig.appName
58
60
private var etag: String? = null
59
- private val featuresReceivedFlow = MutableSharedFlow <UnleashState >(
60
- replay = 1 ,
61
- onBufferOverflow = BufferOverflow .DROP_OLDEST
62
- )
63
- private val fetcherHeartbeatFlow = MutableSharedFlow <HeartbeatEvent >(
64
- extraBufferCapacity = 5 ,
65
- onBufferOverflow = BufferOverflow .DROP_OLDEST
66
- )
61
+ private val featuresReceivedFlow =
62
+ MutableSharedFlow <UnleashState >(
63
+ replay = 1 ,
64
+ onBufferOverflow = BufferOverflow .DROP_OLDEST
65
+ )
66
+ private val fetcherHeartbeatFlow =
67
+ MutableSharedFlow <HeartbeatEvent >(
68
+ extraBufferCapacity = 5 ,
69
+ onBufferOverflow = BufferOverflow .DROP_OLDEST
70
+ )
67
71
private val coroutineContextForContextChange: CoroutineContext = Dispatchers .IO
68
72
private val currentCall = AtomicReference <Call ?>(null )
69
73
private val throttler =
70
- Throttler (
71
- TimeUnit .MILLISECONDS .toSeconds(unleashConfig.pollingStrategy.interval),
72
- longestAcceptableIntervalSeconds = 300 ,
73
- proxyUrl.toString()
74
- )
74
+ Throttler (
75
+ TimeUnit .MILLISECONDS .toSeconds(unleashConfig.pollingStrategy.interval),
76
+ longestAcceptableIntervalSeconds = 300 ,
77
+ proxyUrl.toString()
78
+ )
75
79
76
80
fun getFeaturesReceivedFlow () = featuresReceivedFlow.asSharedFlow()
77
81
78
82
fun startWatchingContext () {
79
83
unleashScope.launch {
80
- unleashContext.distinctUntilChanged { old, new -> old != new }.collect {
84
+ unleashContext.collect {
85
+ if (it == contextForLastFetch) {
86
+ Log .d(TAG , " Context unchanged, skipping refresh toggles" )
87
+ return @collect
88
+ }
81
89
withContext(coroutineContextForContextChange) {
82
90
Log .d(TAG , " Unleash context changed: $it " )
83
91
refreshToggles()
@@ -89,7 +97,7 @@ open class UnleashFetcher(
89
97
suspend fun refreshToggles (): ToggleResponse {
90
98
if (throttler.performAction()) {
91
99
Log .d(TAG , " Refreshing toggles" )
92
- val response = refreshTogglesWithContext (unleashContext.value)
100
+ val response = doFetchToggles (unleashContext.value)
93
101
fetcherHeartbeatFlow.emit(HeartbeatEvent (response.status, response.error?.message))
94
102
return response
95
103
}
@@ -98,15 +106,28 @@ open class UnleashFetcher(
98
106
return ToggleResponse (Status .THROTTLED )
99
107
}
100
108
101
- internal suspend fun refreshTogglesWithContext (ctx : UnleashContext ): ToggleResponse {
109
+ suspend fun refreshTogglesWithContext (ctx : UnleashContext ): ToggleResponse {
110
+ if (throttler.performAction()) {
111
+ Log .d(TAG , " Refreshing toggles" )
112
+ val response = doFetchToggles(ctx)
113
+ fetcherHeartbeatFlow.emit(HeartbeatEvent (response.status, response.error?.message))
114
+ return response
115
+ }
116
+ Log .i(TAG , " Skipping refresh toggles due to throttling" )
117
+ fetcherHeartbeatFlow.emit(HeartbeatEvent (Status .THROTTLED ))
118
+ return ToggleResponse (Status .THROTTLED )
119
+ }
120
+
121
+ internal suspend fun doFetchToggles (ctx : UnleashContext ): ToggleResponse {
122
+ contextForLastFetch = ctx
102
123
val response = fetchToggles(ctx)
103
124
if (response.isSuccess()) {
104
125
105
- val toggles = response.config !! .toggles.groupBy { it.name }
106
- .mapValues { (_, v) -> v.first() }
126
+ val toggles =
127
+ response.config !! .toggles.groupBy { it.name } .mapValues { (_, v) -> v.first() }
107
128
Log .d(
108
- TAG ,
109
- " Fetched new state with ${toggles.size} toggles, emitting featuresReceivedFlow"
129
+ TAG ,
130
+ " Fetched new state with ${toggles.size} toggles, emitting featuresReceivedFlow"
110
131
)
111
132
featuresReceivedFlow.emit(UnleashState (ctx, toggles))
112
133
return ToggleResponse (response.status, toggles)
@@ -124,26 +145,31 @@ open class UnleashFetcher(
124
145
125
146
private suspend fun fetchToggles (ctx : UnleashContext ): FetchResponse {
126
147
if (proxyUrl == null ) {
127
- return FetchResponse (Status .FAILED , error = IllegalStateException (" Proxy URL is not set" ))
148
+ return FetchResponse (
149
+ Status .FAILED ,
150
+ error = IllegalStateException (" Proxy URL is not set" )
151
+ )
128
152
}
129
153
val contextUrl = buildContextUrl(ctx)
130
154
try {
131
- val request = Request .Builder ().url(contextUrl)
132
- .headers(applicationHeaders.toHeaders())
155
+ val request = Request .Builder ().url(contextUrl).headers(applicationHeaders.toHeaders())
133
156
if (etag != null ) {
134
157
request.header(" If-None-Match" , etag!! )
135
158
}
136
159
val call = this .httpClient.newCall(request.build())
137
160
val inFlightCall = currentCall.get()
138
161
if (! currentCall.compareAndSet(inFlightCall, call)) {
139
162
return FetchResponse (
140
- Status .FAILED ,
141
- error = IllegalStateException (" Failed to set new call while ${inFlightCall?.request()?.url} is in flight" )
163
+ Status .FAILED ,
164
+ error =
165
+ IllegalStateException (
166
+ " Failed to set new call while ${inFlightCall?.request()?.url} is in flight"
167
+ )
142
168
)
143
- } else if (inFlightCall != null && ! inFlightCall.isCanceled()) {
169
+ } else if (inFlightCall != null && ! inFlightCall.isCanceled() && ! inFlightCall.isExecuted() ) {
144
170
Log .d(
145
- TAG ,
146
- " Cancelling previous ${inFlightCall.request().method} ${inFlightCall.request().url} "
171
+ TAG ,
172
+ " Cancelling previous ${inFlightCall.request().method} ${inFlightCall.request().url} "
147
173
)
148
174
inFlightCall.cancel()
149
175
}
@@ -159,23 +185,21 @@ open class UnleashFetcher(
159
185
res.body?.use { b ->
160
186
try {
161
187
val proxyResponse: ProxyResponse =
162
- proxyResponseAdapter.fromJson(b.string())!!
188
+ proxyResponseAdapter.fromJson(b.string())!!
163
189
FetchResponse (Status .SUCCESS , proxyResponse)
164
190
} catch (e: Exception ) {
165
191
// If we fail to parse, just keep data
166
192
FetchResponse (Status .FAILED , error = e)
167
193
}
168
- } ? : FetchResponse (Status .FAILED , error = NoBodyException ())
194
+ }
195
+ ? : FetchResponse (Status .FAILED , error = NoBodyException ())
169
196
}
170
-
171
197
res.code == 304 -> {
172
198
FetchResponse (Status .NOT_MODIFIED )
173
199
}
174
-
175
200
res.code == 401 -> {
176
201
FetchResponse (Status .FAILED , error = NotAuthorizedException ())
177
202
}
178
-
179
203
else -> {
180
204
FetchResponse (Status .FAILED , error = ServerException (res.code))
181
205
}
@@ -188,31 +212,33 @@ open class UnleashFetcher(
188
212
189
213
private suspend fun Call.await (): Response {
190
214
return suspendCancellableCoroutine { continuation ->
191
- enqueue(object : Callback {
192
- override fun onResponse (call : Call , response : Response ) {
193
- continuation.resume(response)
194
- }
215
+ enqueue(
216
+ object : Callback {
217
+ override fun onResponse (call : Call , response : Response ) {
218
+ continuation.resume(response)
219
+ }
195
220
196
- override fun onFailure (call : Call , e : IOException ) {
197
- // Don't bother with resuming the continuation if it is already cancelled.
198
- if (continuation.isCancelled) return
199
- continuation.resumeWithException(e)
200
- }
201
- })
221
+ override fun onFailure (call : Call , e : IOException ) {
222
+ // Don't bother with resuming the continuation if it is already
223
+ // cancelled.
224
+ if (continuation.isCancelled) return
225
+ continuation.resumeWithException(e)
226
+ }
227
+ }
228
+ )
202
229
203
230
continuation.invokeOnCancellation {
204
231
try {
205
232
cancel()
206
233
} catch (ex: Throwable ) {
207
- // Ignore cancel exception
234
+ // Ignore cancel exception
208
235
}
209
236
}
210
237
}
211
238
}
212
239
213
240
private fun buildContextUrl (ctx : UnleashContext ): HttpUrl {
214
- var contextUrl = proxyUrl!! .newBuilder()
215
- .addQueryParameter(" appName" , appName)
241
+ var contextUrl = proxyUrl!! .newBuilder().addQueryParameter(" appName" , appName)
216
242
if (ctx.userId != null ) {
217
243
contextUrl.addQueryParameter(" userId" , ctx.userId)
218
244
}
0 commit comments