Skip to content

Commit 2c4a243

Browse files
tonideroclaude
andauthored
refactor(remote-config): /v1/config path, app_user_id body, refresh dedupe (#3648)
### Description 3 main changes here: - Changed to use `/v1/config` instead of `/v2/config` - Add `app_user_id` to request body for config - Avoid performing multiple requests simultaneously (or even with the backend dedupe, avoid the parsing and storage multiple times) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes the live remote-config URL and request contract and ties sync to the current app user ID; incorrect wiring could miss or mis-scope config until the next refresh. > > **Overview** > Aligns remote config sync with the **`/v1/config`** API: the endpoint path moves from **`/v2/config`**, and **`Backend.getRemoteConfig`** / **`RemoteConfigManager.refreshRemoteConfig`** now take **`appUserID`** and send **`app_user_id`** in the POST body (including first-run requests without a manifest). > > **`RemoteConfigManager`** adds an in-flight guard so overlapping **`refreshRemoteConfig`** calls are skipped and the same response is not parsed or written to disk multiple times when the backend still fans out callbacks for deduped HTTP. The lock clears on success, error, and 204. > > Tests and docs/comments are updated for the new path, body fields, and dedupe behavior. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit e817f23. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 02930fd commit 2c4a243

9 files changed

Lines changed: 136 additions & 31 deletions

File tree

purchases/src/main/kotlin/com/revenuecat/purchases/common/Backend.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1313,6 +1313,7 @@ internal class Backend(
13131313
@Suppress("LongParameterList")
13141314
fun getRemoteConfig(
13151315
appInBackground: Boolean,
1316+
appUserID: String,
13161317
domain: String,
13171318
manifest: String?,
13181319
prefetchedBlobs: List<String>,
@@ -1321,7 +1322,9 @@ internal class Backend(
13211322
) {
13221323
val endpoint = Endpoint.GetRemoteConfig
13231324
val path = endpoint.getPath()
1324-
val cacheKey = BackgroundAwareCallbackCacheKey(listOf(path), appInBackground)
1325+
// Include the app user in the key: the path is static but the request body carries app_user_id, so
1326+
// concurrent calls for different users must not be deduped onto a single shared request.
1327+
val cacheKey = BackgroundAwareCallbackCacheKey(listOf(path, appUserID), appInBackground)
13251328

13261329
val overrideURL = BuildConfig.REMOTE_CONFIG_BASE_URL
13271330
.takeIf { it.isNotEmpty() && appConfig.isDebugBuild }
@@ -1330,6 +1333,7 @@ internal class Backend(
13301333
val fallbackBaseURLs = if (overrideURL != null) emptyList() else appConfig.fallbackBaseURLs
13311334
// The manifest is an opaque token replayed verbatim; omitted on the first run when there is none.
13321335
val body = buildMap<String, Any?> {
1336+
put(APP_USER_ID, appUserID)
13331337
put("domain", domain)
13341338
manifest?.let { put("manifest", it) }
13351339
put("prefetched_blobs", prefetchedBlobs)

purchases/src/main/kotlin/com/revenuecat/purchases/common/networking/Endpoint.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ internal sealed class Endpoint(
106106
}
107107

108108
object GetRemoteConfig : Endpoint(
109-
pathTemplate = "/v2/config",
109+
pathTemplate = "/v1/config",
110110
name = "remote_config",
111111
) {
112112
override fun getPath(useFallback: Boolean) = pathTemplate

purchases/src/main/kotlin/com/revenuecat/purchases/common/remoteconfig/RemoteConfigDiskCache.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import java.io.File
1010
import java.io.IOException
1111

1212
/**
13-
* The sync bookkeeping the SDK persists between `/v2/config` calls.
13+
* The sync bookkeeping the SDK persists between `/v1/config` calls.
1414
*
1515
* [manifest] is the **opaque** server token, stored verbatim and replayed on the next request; the SDK never
1616
* parses it. [activeTopics] is the last response's full active-topic-name set (used to detect removed topics).

purchases/src/main/kotlin/com/revenuecat/purchases/common/remoteconfig/RemoteConfigManager.kt

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,46 +4,63 @@ import com.revenuecat.purchases.InternalRevenueCatAPI
44
import com.revenuecat.purchases.common.Backend
55
import com.revenuecat.purchases.common.DateProvider
66
import com.revenuecat.purchases.common.DefaultDateProvider
7+
import com.revenuecat.purchases.common.debugLog
78
import com.revenuecat.purchases.common.errorLog
89
import kotlinx.serialization.SerializationException
10+
import java.util.concurrent.atomic.AtomicBoolean
911

1012
/**
11-
* Orchestrates a single `/v2/config` sync: replays the persisted opaque manifest, then on `204` keeps the cache
13+
* Orchestrates a single `/v1/config` sync: replays the persisted opaque manifest, then on `204` keeps the cache
1214
* untouched and on `200` persists the fresh server manifest plus the resolved per-topic blob refs.
1315
*
1416
* The manifest is opaque (stored and replayed verbatim); the active-topic set and removed-topic detection come
1517
* from the response's [RemoteConfiguration.activeTopics]. Blob extraction (Phase 3), topic-handler dispatch
1618
* (Phase 4) and lifecycle wiring (Phase 7) are not done here yet; this manager currently only owns manifest
1719
* replay and persistence.
20+
*
21+
* Overlapping refreshes are deduped: only one [refreshRemoteConfig] runs at a time. A call made while one is
22+
* already in flight is skipped (the backend collapses concurrent requests but still fires every callback, which
23+
* would otherwise parse and persist the same response more than once).
1824
*/
1925
@OptIn(InternalRevenueCatAPI::class)
2026
internal class RemoteConfigManager(
2127
private val backend: Backend,
2228
private val diskCache: RemoteConfigDiskCache,
2329
private val dateProvider: DateProvider = DefaultDateProvider(),
2430
) {
25-
fun refreshRemoteConfig(appInBackground: Boolean) {
31+
private val isRefreshing = AtomicBoolean(false)
32+
33+
fun refreshRemoteConfig(appInBackground: Boolean, appUserID: String) {
34+
if (isRefreshing.getAndSet(true)) {
35+
debugLog { "Remote config refresh already in progress. Skipping." }
36+
return
37+
}
2638
val persisted = diskCache.read()
2739
backend.getRemoteConfig(
2840
appInBackground = appInBackground,
41+
appUserID = appUserID,
2942
domain = persisted?.domain ?: DEFAULT_DOMAIN,
3043
// Opaque manifest replayed verbatim; null on the first run when nothing is persisted yet.
3144
manifest = persisted?.manifest,
3245
// No blob store yet (Phase 3 reports the refs actually cached); the SDK holds nothing here.
3346
prefetchedBlobs = emptyList(),
3447
onSuccess = { container, _ ->
35-
if (container == null) {
36-
// 204: nothing changed, keep the cache.
37-
return@getRemoteConfig
38-
}
48+
// Hold the in-flight guard until persistence completes so a concurrent refresh can't read the
49+
// disk cache mid-write and merge against a stale snapshot.
3950
try {
40-
val response = RemoteConfiguration.parse(container.config.data)
41-
persist(previous = persisted, response = response)
51+
if (container != null) {
52+
val response = RemoteConfiguration.parse(container.config.data)
53+
persist(previous = persisted, response = response)
54+
}
55+
// container == null is a 204: nothing changed, keep the cache.
4256
} catch (e: SerializationException) {
4357
errorLog(e) { "Failed to parse remote config response. Keeping the cached configuration." }
58+
} finally {
59+
isRefreshing.set(false)
4460
}
4561
},
4662
onError = { error ->
63+
isRefreshing.set(false)
4764
errorLog(error)
4865
},
4966
)

purchases/src/main/kotlin/com/revenuecat/purchases/common/remoteconfig/RemoteConfiguration.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import kotlinx.serialization.json.jsonObject
1717
import java.nio.ByteBuffer
1818

1919
/**
20-
* The `/v2/config` configuration response. This is the JSON payload carried in element 0 (the config
20+
* The `/v1/config` configuration response. This is the JSON payload carried in element 0 (the config
2121
* element) of the binary [com.revenuecat.purchases.common.networking.RCContainer], and is also the plain
2222
* JSON fallback body when the SDK does not request the binary format.
2323
*

purchases/src/test/java/com/revenuecat/purchases/common/backend/BackendGetRemoteConfigTest.kt

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ class BackendGetRemoteConfigTest {
3636
private val mockBaseURL = URL("http://mock-api-test.revenuecat.com/")
3737

3838
private val testDomain = "app"
39+
private val testAppUserID = "test-app-user-id"
3940
private val testManifest = "v1.1710000100.sources:etag1"
4041
private val testPrefetchedBlobs = listOf("blobRefA")
4142

@@ -90,6 +91,7 @@ class BackendGetRemoteConfigTest {
9091
var verification: VerificationResult? = null
9192
backend.getRemoteConfig(
9293
appInBackground = false,
94+
appUserID = testAppUserID,
9395
domain = testDomain,
9496
manifest = testManifest,
9597
prefetchedBlobs = testPrefetchedBlobs,
@@ -115,6 +117,7 @@ class BackendGetRemoteConfigTest {
115117
var obtainedError: PurchasesError? = null
116118
backend.getRemoteConfig(
117119
appInBackground = false,
120+
appUserID = testAppUserID,
118121
domain = testDomain,
119122
manifest = testManifest,
120123
prefetchedBlobs = testPrefetchedBlobs,
@@ -131,6 +134,7 @@ class BackendGetRemoteConfigTest {
131134
var obtainedError: PurchasesError? = null
132135
backend.getRemoteConfig(
133136
appInBackground = false,
137+
appUserID = testAppUserID,
134138
domain = testDomain,
135139
manifest = testManifest,
136140
prefetchedBlobs = testPrefetchedBlobs,
@@ -153,6 +157,7 @@ class BackendGetRemoteConfigTest {
153157
var verification: VerificationResult? = null
154158
backend.getRemoteConfig(
155159
appInBackground = false,
160+
appUserID = testAppUserID,
156161
domain = testDomain,
157162
manifest = testManifest,
158163
prefetchedBlobs = testPrefetchedBlobs,
@@ -170,12 +175,13 @@ class BackendGetRemoteConfigTest {
170175
}
171176

172177
@Test
173-
fun `getRemoteConfig sends domain, opaque manifest and prefetched_blobs as the request body`() {
178+
fun `getRemoteConfig sends app_user_id, domain, opaque manifest and prefetched_blobs as the request body`() {
174179
val bodySlot = mutableListOf<Map<String, Any?>?>()
175180
mockNoContentRequest(bodySlot)
176181

177182
backend.getRemoteConfig(
178183
appInBackground = false,
184+
appUserID = testAppUserID,
179185
domain = testDomain,
180186
manifest = testManifest,
181187
prefetchedBlobs = testPrefetchedBlobs,
@@ -185,7 +191,8 @@ class BackendGetRemoteConfigTest {
185191

186192
val body = bodySlot.firstOrNull()
187193
assertThat(body).isNotNull
188-
assertThat(body?.keys).containsExactlyInAnyOrder("domain", "manifest", "prefetched_blobs")
194+
assertThat(body?.keys).containsExactlyInAnyOrder("app_user_id", "domain", "manifest", "prefetched_blobs")
195+
assertThat(body?.get("app_user_id")).isEqualTo(testAppUserID)
189196
assertThat(body?.get("domain")).isEqualTo("app")
190197
// The manifest is replayed verbatim as the opaque string the SDK received.
191198
assertThat(body?.get("manifest")).isEqualTo(testManifest)
@@ -199,6 +206,7 @@ class BackendGetRemoteConfigTest {
199206

200207
backend.getRemoteConfig(
201208
appInBackground = false,
209+
appUserID = testAppUserID,
202210
domain = testDomain,
203211
manifest = null,
204212
prefetchedBlobs = emptyList(),
@@ -207,7 +215,7 @@ class BackendGetRemoteConfigTest {
207215
)
208216

209217
val body = bodySlot.firstOrNull()
210-
assertThat(body?.keys).containsExactlyInAnyOrder("domain", "prefetched_blobs")
218+
assertThat(body?.keys).containsExactlyInAnyOrder("app_user_id", "domain", "prefetched_blobs")
211219
assertThat(body).doesNotContainKey("manifest")
212220
}
213221

@@ -220,6 +228,7 @@ class BackendGetRemoteConfigTest {
220228
var obtainedError: PurchasesError? = null
221229
backend.getRemoteConfig(
222230
appInBackground = false,
231+
appUserID = testAppUserID,
223232
domain = testDomain,
224233
manifest = testManifest,
225234
prefetchedBlobs = testPrefetchedBlobs,
@@ -235,6 +244,7 @@ class BackendGetRemoteConfigTest {
235244
val lock = CountDownLatch(2)
236245
asyncBackend.getRemoteConfig(
237246
appInBackground = false,
247+
appUserID = testAppUserID,
238248
domain = testDomain,
239249
manifest = testManifest,
240250
prefetchedBlobs = testPrefetchedBlobs,
@@ -243,6 +253,7 @@ class BackendGetRemoteConfigTest {
243253
)
244254
asyncBackend.getRemoteConfig(
245255
appInBackground = false,
256+
appUserID = testAppUserID,
246257
domain = testDomain,
247258
manifest = testManifest,
248259
prefetchedBlobs = testPrefetchedBlobs,
@@ -262,6 +273,42 @@ class BackendGetRemoteConfigTest {
262273
}
263274
}
264275

276+
@Test
277+
fun `getRemoteConfig does not dedup concurrent calls for different app user ids`() {
278+
mockHttpResult(payload = HTTPResult.Payload.RCFormat(buildContainer()), delayMs = 200)
279+
val lock = CountDownLatch(2)
280+
asyncBackend.getRemoteConfig(
281+
appInBackground = false,
282+
appUserID = "user-a",
283+
domain = testDomain,
284+
manifest = testManifest,
285+
prefetchedBlobs = testPrefetchedBlobs,
286+
onSuccess = { _, _ -> lock.countDown() },
287+
onError = { fail("Expected success. Got error: $it") },
288+
)
289+
asyncBackend.getRemoteConfig(
290+
appInBackground = false,
291+
appUserID = "user-b",
292+
domain = testDomain,
293+
manifest = testManifest,
294+
prefetchedBlobs = testPrefetchedBlobs,
295+
onSuccess = { _, _ -> lock.countDown() },
296+
onError = { fail("Expected success. Got error: $it") },
297+
)
298+
lock.await(5.seconds.inWholeSeconds, TimeUnit.SECONDS)
299+
assertThat(lock.count).isEqualTo(0)
300+
// Different users must each get their own request rather than sharing the first caller's response.
301+
verify(exactly = 2) {
302+
httpClient.performRequest(
303+
mockBaseURL,
304+
Endpoint.GetRemoteConfig,
305+
body = any(),
306+
postFieldsToSign = null,
307+
requestHeaders = any(),
308+
)
309+
}
310+
}
311+
265312
private fun mockNoContentRequest(bodySlot: MutableList<Map<String, Any?>?>) {
266313
every {
267314
httpClient.performRequest(

purchases/src/test/java/com/revenuecat/purchases/common/networking/EndpointTest.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ class EndpointTest {
192192
@Test
193193
fun `GetRemoteConfig has correct path`() {
194194
val endpoint = Endpoint.GetRemoteConfig
195-
val expectedPath = "/v2/config"
195+
val expectedPath = "/v1/config"
196196
assertThat(endpoint.getPath()).isEqualTo(expectedPath)
197197
}
198198

purchases/src/test/java/com/revenuecat/purchases/common/networking/RCContainerTestData.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ internal object RCContainerTestData {
1818
const val FIXTURE_DIR = "rc_container"
1919

2020
/**
21-
* A representative `/v2/config` payload, modeled on `get_remote_config_success.json`. Stored as
21+
* A representative `/v1/config` payload, modeled on `get_remote_config_success.json`. Stored as
2222
* the config element (element 0) of most fixtures so they resemble real wire data.
2323
*/
2424
// language=json

0 commit comments

Comments
 (0)