Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,10 @@ internal typealias RemoteConfigCallback = Pair<(RemoteConfigResponse) -> Unit, (
@OptIn(InternalRevenueCatAPI::class)
internal typealias WorkflowDetailCallback = Pair<(WorkflowDetailResponse) -> Unit, (PurchasesError) -> Unit>

internal typealias WorkflowsListCallback = Pair<(WorkflowsListResponse) -> Unit, (PurchasesError) -> Unit>
internal typealias WorkflowsListCallback = Pair<
(WorkflowsListResponse) -> Unit,
(PurchasesError, errorHandlingBehavior: GetWorkflowsErrorHandlingBehavior) -> Unit,
>

internal enum class PostReceiptErrorHandlingBehavior {
SHOULD_BE_MARKED_SYNCED,
Expand All @@ -119,6 +122,11 @@ internal enum class GetOfferingsErrorHandlingBehavior {
SHOULD_NOT_FALLBACK,
}

internal enum class GetWorkflowsErrorHandlingBehavior {
SHOULD_FALLBACK_TO_CACHED_WORKFLOWS,
SHOULD_NOT_FALLBACK,
}

@OptIn(InternalRevenueCatAPI::class)
@Suppress("TooManyFunctions")
internal class Backend(
Expand Down Expand Up @@ -1068,7 +1076,7 @@ internal class Backend(
appInBackground: Boolean,
type: String? = null,
onSuccess: (WorkflowsListResponse) -> Unit,
onError: (PurchasesError) -> Unit,
onError: (PurchasesError, GetWorkflowsErrorHandlingBehavior) -> Unit,
) {
val endpoint = Endpoint.GetWorkflows(appUserID, type)
val path = endpoint.getPath()
Expand All @@ -1089,7 +1097,7 @@ internal class Backend(
synchronized(this@Backend) {
workflowsListCallbacks.remove(cacheKey)
}?.forEach { (_, onErrorHandler) ->
onErrorHandler(error)
onErrorHandler(error, GetWorkflowsErrorHandlingBehavior.SHOULD_FALLBACK_TO_CACHED_WORKFLOWS)
}
}

Expand All @@ -1103,12 +1111,23 @@ internal class Backend(
WorkflowJsonParser.parseWorkflowsListResponse(result.payload),
)
} catch (e: SerializationException) {
onErrorHandler(e.toPurchasesError().also { errorLog(it) })
onErrorHandler(
e.toPurchasesError().also { errorLog(it) },
GetWorkflowsErrorHandlingBehavior.SHOULD_FALLBACK_TO_CACHED_WORKFLOWS,
)
} catch (e: IllegalArgumentException) {
onErrorHandler(e.toPurchasesError().also { errorLog(it) })
onErrorHandler(
e.toPurchasesError().also { errorLog(it) },
GetWorkflowsErrorHandlingBehavior.SHOULD_FALLBACK_TO_CACHED_WORKFLOWS,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On iOS I've generalized instead of duplicating the whole logic. But it's fine. We can always follow-up after releasing.

)
}
} else {
onErrorHandler(result.toPurchasesError().also { errorLog(it) })
val errorBehavior = if (RCHTTPStatusCodes.isServerError(result.responseCode)) {
GetWorkflowsErrorHandlingBehavior.SHOULD_FALLBACK_TO_CACHED_WORKFLOWS
} else {
GetWorkflowsErrorHandlingBehavior.SHOULD_NOT_FALLBACK
}
onErrorHandler(result.toPurchasesError().also { errorLog(it) }, errorBehavior)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import com.revenuecat.purchases.InternalRevenueCatAPI
import com.revenuecat.purchases.PurchasesError
import com.revenuecat.purchases.common.Backend
import com.revenuecat.purchases.common.Dispatcher
import com.revenuecat.purchases.common.GetWorkflowsErrorHandlingBehavior
import com.revenuecat.purchases.common.errorLog
import com.revenuecat.purchases.common.safeResume
import com.revenuecat.purchases.common.toPurchasesError
Expand Down Expand Up @@ -246,43 +247,60 @@ internal class WorkflowManager(
completePendingCallbacks(appUserID)
}
},
onError = { error ->
onError = { error, behavior ->
errorLog { "Failed to fetch workflows list: ${error.underlyingErrorMessage}" }
// Restore the in-memory cache from disk without rewriting it — disk already holds
// this payload.
val restoredFromDisk = workflowsCache.cachedWorkflowsListResponseFromDisk()?.let { response ->
val filtered = response.onlyWorkflowsWithOfferingId()
workflowsCache.cacheWorkflowsListInMemory(filtered, buildOfferingIdMap(filtered.workflows))
} != null
Comment thread
cursor[bot] marked this conversation as resolved.
// Mirror OfferingsManager.handleErrorFetchingOfferings: when there is no disk
// cache to fall back on, force the list stale so the next call retries. When a
// disk restore did succeed, cacheWorkflowsListInMemory already stamped a fresh
// timestamp, so we leave it alone — same as offerings leaving the cache fresh
// after createAndCacheOfferings runs on the disk-fallback path.
if (!restoredFromDisk) {
if (behavior == GetWorkflowsErrorHandlingBehavior.SHOULD_NOT_FALLBACK) {
// A 4xx means the server intentionally changed/removed these workflows. Don't
// resurrect a stale list from disk; just settle the callbacks so offerings
// delivery isn't stranded. Invalidate the timestamp so the next non-forced call
// retries rather than serving a still-fresh in-memory list — mirrors
// OfferingsManager.handleErrorFetchingOfferings calling forceCacheStale().
workflowsCache.invalidateWorkflowsListTimestamp()
}
val envelopes = workflowsCache.cachedWorkflowDetailEnvelopesFromDisk().orEmpty()
if (envelopes.isEmpty()) {
completePendingCallbacks(appUserID)

@facumenzella facumenzella Jun 9, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: 4xx leaves the timestamp stale, so we re-hit the backend every call while still serving the stale in-memory map. Before this PR the restore path re-stamped it, so we used to back off.

Want to keep retrying every call, or stamp it so a sticky 4xx backs off? Offerings does the same thing so either way is fine, just flagging.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's mimic what offerings does

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there was actually a divergence with offerings here. In offerings we call forceCacheStale on 4xx errors

} else {
scope.launch {
// Re-resolve persisted envelopes into the in-memory cache, mirroring the
// success-path prefetch loop. A failed re-resolve is logged and does not
// fail its siblings. completePendingCallbacks still fires exactly once.
coroutineScope {
envelopes.forEach { (workflowId, envelope) ->
launch { restoreWorkflowFromEnvelope(workflowId, envelope) }
}
}
completePendingCallbacks(appUserID)
}
restoreWorkflowsListFromDisk(appUserID)
}
},
)
}
}

/**
* Restores the workflows list and persisted detail envelopes from disk after a fallback-eligible
* fetch failure (transport error, 5xx, or malformed body), then settles pending callbacks. The
* in-memory cache is rewritten from disk without re-persisting it — disk already holds this payload.
* [completePendingCallbacks] always fires exactly once.
*/
private fun restoreWorkflowsListFromDisk(appUserID: String) {
val restoredFromDisk = workflowsCache.cachedWorkflowsListResponseFromDisk()?.let { response ->
val filtered = response.onlyWorkflowsWithOfferingId()
workflowsCache.cacheWorkflowsListInMemory(filtered, buildOfferingIdMap(filtered.workflows))
} != null
// Mirror OfferingsManager.handleErrorFetchingOfferings: when there is no disk cache to fall
// back on, force the list stale so the next call retries. When a disk restore did succeed,
// cacheWorkflowsListInMemory already stamped a fresh timestamp, so we leave it alone — same as
// offerings leaving the cache fresh after createAndCacheOfferings runs on the disk-fallback path.
if (!restoredFromDisk) {
workflowsCache.invalidateWorkflowsListTimestamp()
}
val envelopes = workflowsCache.cachedWorkflowDetailEnvelopesFromDisk().orEmpty()
if (envelopes.isEmpty()) {
completePendingCallbacks(appUserID)
} else {
scope.launch {
// Re-resolve persisted envelopes into the in-memory cache, mirroring the success-path
// prefetch loop. A failed re-resolve is logged and does not fail its siblings.
// completePendingCallbacks still fires exactly once.
coroutineScope {
envelopes.forEach { (workflowId, envelope) ->
launch { restoreWorkflowFromEnvelope(workflowId, envelope) }
}
}
completePendingCallbacks(appUserID)
}
}
}

/**
* Suspends until [getWorkflow] for [workflowId] resolves. Prefetch is best-effort, so a failure
* is logged and the coroutine resumes normally instead of throwing, keeping one failed prefetch
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import com.revenuecat.purchases.common.Backend
import com.revenuecat.purchases.common.BackendHelper
import com.revenuecat.purchases.common.Delay
import com.revenuecat.purchases.common.Dispatcher
import com.revenuecat.purchases.common.GetWorkflowsErrorHandlingBehavior
import com.revenuecat.purchases.common.HTTPClient
import com.revenuecat.purchases.common.SyncDispatcher
import com.revenuecat.purchases.common.networking.Endpoint
Expand All @@ -23,6 +24,7 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.io.IOException
import java.net.URL
import java.util.concurrent.CountDownLatch
import java.util.concurrent.LinkedBlockingQueue
Expand Down Expand Up @@ -227,7 +229,7 @@ class BackendWorkflowsTest {
assertThat(response.workflows[1].offeringId).isNull()
success = true
},
onError = { fail("unexpected error $it") },
onError = { e, _ -> fail("unexpected error $e") },
)
assertThat(success).isTrue()
}
Expand Down Expand Up @@ -256,7 +258,7 @@ class BackendWorkflowsTest {
assertThat(response.workflows[0].id).isEqualTo("wf_1")
success = true
},
onError = { fail("unexpected error $it") },
onError = { e, _ -> fail("unexpected error $e") },
)
assertThat(success).isTrue()
}
Expand All @@ -279,7 +281,7 @@ class BackendWorkflowsTest {
appUserID = appUserId,
appInBackground = false,
onSuccess = { fail("expected error") },
onError = { errorCode = it.code },
onError = { e, _ -> errorCode = e.code },
)
assertThat(errorCode).isNotNull
}
Expand Down Expand Up @@ -309,15 +311,15 @@ class BackendWorkflowsTest {
appUserID = appUserId,
appInBackground = false,
onSuccess = { resultLatch.countDown() },
onError = { fail("unexpected error $it") },
onError = { e, _ -> fail("unexpected error $e") },
)
assertThat(httpStarted.await(defaultTimeout, TimeUnit.MILLISECONDS)).isTrue()
repeat(2) {
asyncBackend.getWorkflows(
appUserID = appUserId,
appInBackground = false,
onSuccess = { resultLatch.countDown() },
onError = { fail("unexpected error $it") },
onError = { e, _ -> fail("unexpected error $e") },
)
}
httpProceed.countDown()
Expand Down Expand Up @@ -413,6 +415,98 @@ class BackendWorkflowsTest {
override fun isClosed() = false
}

@Test
fun `getWorkflows reports SHOULD_NOT_FALLBACK on 4xx`() {
every {
mockClient.performRequest(
baseURL = mockBaseURL,
endpoint = Endpoint.GetWorkflows(appUserId),
body = null,
postFieldsToSign = null,
requestHeaders = defaultAuthHeaders,
fallbackBaseURLs = emptyList(),
)
} returns httpResult(RCHTTPStatusCodes.NOT_FOUND, """{"error": "not found"}""")

var behavior: GetWorkflowsErrorHandlingBehavior? = null
backend.getWorkflows(
appUserID = appUserId,
appInBackground = false,
onSuccess = { fail("expected error") },
onError = { _, b -> behavior = b },
)
assertThat(behavior).isEqualTo(GetWorkflowsErrorHandlingBehavior.SHOULD_NOT_FALLBACK)
}

@Test
fun `getWorkflows reports SHOULD_FALLBACK on 5xx`() {
every {
mockClient.performRequest(
baseURL = mockBaseURL,
endpoint = Endpoint.GetWorkflows(appUserId),
body = null,
postFieldsToSign = null,
requestHeaders = defaultAuthHeaders,
fallbackBaseURLs = emptyList(),
)
} returns httpResult(RCHTTPStatusCodes.ERROR, """{"error": "boom"}""")

var behavior: GetWorkflowsErrorHandlingBehavior? = null
backend.getWorkflows(
appUserID = appUserId,
appInBackground = false,
onSuccess = { fail("expected error") },
onError = { _, b -> behavior = b },
)
assertThat(behavior).isEqualTo(GetWorkflowsErrorHandlingBehavior.SHOULD_FALLBACK_TO_CACHED_WORKFLOWS)
}

@Test
fun `getWorkflows reports SHOULD_FALLBACK on malformed body`() {
every {
mockClient.performRequest(
baseURL = mockBaseURL,
endpoint = Endpoint.GetWorkflows(appUserId),
body = null,
postFieldsToSign = null,
requestHeaders = defaultAuthHeaders,
fallbackBaseURLs = emptyList(),
)
} returns httpResult(RCHTTPStatusCodes.SUCCESS, "not valid json")

var behavior: GetWorkflowsErrorHandlingBehavior? = null
backend.getWorkflows(
appUserID = appUserId,
appInBackground = false,
onSuccess = { fail("expected error") },
onError = { _, b -> behavior = b },
)
assertThat(behavior).isEqualTo(GetWorkflowsErrorHandlingBehavior.SHOULD_FALLBACK_TO_CACHED_WORKFLOWS)
}

@Test
fun `getWorkflows reports SHOULD_FALLBACK on transport failure`() {
every {
mockClient.performRequest(
baseURL = mockBaseURL,
endpoint = Endpoint.GetWorkflows(appUserId),
body = null,
postFieldsToSign = null,
requestHeaders = defaultAuthHeaders,
fallbackBaseURLs = emptyList(),
)
} throws IOException("network down")

var behavior: GetWorkflowsErrorHandlingBehavior? = null
backend.getWorkflows(
appUserID = appUserId,
appInBackground = false,
onSuccess = { fail("expected error") },
onError = { _, b -> behavior = b },
)
assertThat(behavior).isEqualTo(GetWorkflowsErrorHandlingBehavior.SHOULD_FALLBACK_TO_CACHED_WORKFLOWS)
}

private fun httpResult(responseCode: Int, payload: String) = HTTPResult(
responseCode = responseCode,
payload = payload,
Expand Down
Loading