Health Connect rate-limit RemoteException on API 33 and below, even on the first call


I use Health Connect inside a DataSapien SDK. On API 34 our read flow works fine. On API 33 and below, we get this immediately

android.os.RemoteException: Request rejected. Rate limited request quota has been exceeded. Please wait until quota has replenished before making further requests.

Setup: androidx.health.connect:connect-client:1.1.0, compileSdk 36, minSdk 26, Kotlin 2.1.0 / 2.2.21.

My Implementation:

Client creation:

fun getHealthConnectClient(): HealthConnectClient? {
    val ctx = context ?: return null
    when (HealthConnectClient.getSdkStatus(ctx)) {
        HealthConnectClient.SDK_UNAVAILABLE -> return null
        HealthConnectClient.SDK_UNAVAILABLE_PROVIDER_UPDATE_REQUIRED -> return null // opens Play Store
    }
    return HealthConnectClient.getOrCreate(ctx)
}

Before reads we check/request permissions:

val client = HealthConnectClient.getOrCreate(activity)
val granted = client.permissionController.getGrantedPermissions()
if (!granted.containsAll(requiredPermissions)) {
    permissionLauncher.launch(requiredPermissions)
}

Each collector also calls getGrantedPermissions() again right before readRecords().

Typical read (30 days, paginated):

do {
    val response = client.readRecords(
        ReadRecordsRequest(
            recordType = StepsRecord::class,
            timeRangeFilter = TimeRangeFilter.between(start, end), // ~30 days
            pageSize = 1000,
            pageToken = pageToken
        )
    )
    pageToken = response.pageToken
} while (pageToken != null)

My Questions:

  1. Why I get rate-limit error even on the first call on API 33 and below?

  2. Is quota stricter when Health Connect runs as the standalone app vs the Android 14 system service?

  3. What's a reasonable way to serialize/throttle Health Connect calls inside a shared SDK?

1
Jun 14 at 9:01 PM
User AvatarM D
#android#android-13#healthconnect#remoteexception

Accepted Answer

This is probably not the “first call” from Health Connect’s point of view.

In your flow you are doing at least these calls:

getGrantedPermissions() readRecords(...) readRecords(...) readRecords(...) ...

and you also said each collector calls getGrantedPermissions() again before readRecords(). If multiple collectors run at the same time, the SDK can easily create a burst of calls against the Health Connect provider before your “first read” happens.

So I would not check permissions before every collector/read/page. Check permissions once before starting the sync/read flow, cache the result for that flow, and only refresh it after a permission request result or when the app resumes and you explicitly want to re-check.

For example, structure the SDK around a single gateway/queue:

class HealthConnectGateway(
    private val client: HealthConnectClient
) {
    private val mutex = Mutex()

    suspend fun getGrantedPermissionsOnce(): Set<String> =
        mutex.withLock {
            client.permissionController.getGrantedPermissions()
        }

    suspend fun <T : Record> readRecordsThrottled(
        request: ReadRecordsRequest<T>
    ): ReadRecordsResponse<T> =
        mutex.withLock {
            retryRateLimited {
                client.readRecords(request)
            }
        }

    private suspend fun <R> retryRateLimited(
        block: suspend () -> R
    ): R {
        var delayMs = 1_000L

        repeat(5) { attempt ->
            try {
                return block()
            } catch (e: android.os.RemoteException) {
                if (!e.message.orEmpty().contains("Rate limited", ignoreCase = true)) {
                    throw e
                }

                if (attempt == 4) throw e

                delay(delayMs)
                delayMs = (delayMs * 2).coerceAtMost(60_000L)
            }
        }

        error("unreachable")
    }
}

The exact implementation can vary, but the important part is:

  • Do not run all collectors against Health Connect concurrently.

  • Do not call getGrantedPermissions() before every single read.

  • Do not immediately retry the whole 30-day read if one page fails.

  • Add backoff for rate-limit errors.

  • Prefer incremental sync/change-token style reads where possible, instead of repeatedly doing large raw reads.

For a 30-day sync across several record types, I would usually do something like:

check permissions once
for each record type:
    read pages sequentially
    persist progress
    wait/throttle between calls if needed

Regarding API 33 and below: Health Connect is provided by the standalone Health Connect app there, while on Android 14+ it is integrated into the platform. I would not rely on both implementations having identical behavior. Treat rate limiting as something that can happen on any version and make the SDK resilient to it.

So the practical answer is: centralize Health Connect access in your SDK, serialize/throttle calls, cache permission checks for the current flow, and handle rate-limit exceptions with backoff instead of letting each collector independently call getGrantedPermissions() and readRecords().

LMK if you still need help.

User Avatarleosteffen
Jun 15 at 2:05 AM
1