blob: 7146759bd1be8f27d5b9705bec0f366895c78ebe [file] [log] [blame]
/*
* Copyright (C) 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package android.health.connect.ratelimiter;
import android.annotation.IntDef;
import android.health.connect.HealthConnectException;
import com.android.internal.annotations.GuardedBy;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.time.Duration;
import java.time.Instant;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.stream.Collectors;
/**
* Basic rate limiter that assigns a fixed request rate quota. If no quota has previously been noted
* (e.g. first request scenario), the full quota for each window will be immediately granted.
*
* @hide
*/
public final class RateLimiter {
// The maximum number of bytes a client can insert in one go.
public static final String CHUNK_SIZE_LIMIT_IN_BYTES = "chunk_size_limit_in_bytes";
// The maximum size in bytes of a single record a client can insert in one go.
public static final String RECORD_SIZE_LIMIT_IN_BYTES = "record_size_limit_in_bytes";
private static final int DEFAULT_API_CALL_COST = 1;
private static final Map<Integer, Map<Integer, Quota>> sUserIdToQuotasMap = new HashMap<>();
private static final ConcurrentMap<Integer, Integer> sLocks = new ConcurrentHashMap<>();
private static final Map<Integer, Float> QUOTA_BUCKET_TO_MAX_API_CALL_QUOTA_MAP =
new HashMap<>();
private static final Map<String, Integer> QUOTA_BUCKET_TO_MAX_MEMORY_QUOTA_MAP =
new HashMap<>();
private static final ReentrantReadWriteLock sLock = new ReentrantReadWriteLock();
@GuardedBy("sLock")
private static boolean sRateLimiterEnabled;
public static void tryAcquireApiCallQuota(
int uid, @QuotaCategory.Type int quotaCategory, boolean isInForeground) {
sLock.readLock().lock();
try {
if (!sRateLimiterEnabled) {
return;
}
} finally {
sLock.readLock().unlock();
}
if (quotaCategory == QuotaCategory.QUOTA_CATEGORY_UNDEFINED) {
throw new IllegalArgumentException("Quota category not defined.");
}
// Rate limiting not applicable.
if (quotaCategory == QuotaCategory.QUOTA_CATEGORY_UNMETERED) {
return;
}
synchronized (getLockObject(uid)) {
spendResourcesIfAvailable(
uid,
getAffectedQuotaBuckets(quotaCategory, isInForeground),
DEFAULT_API_CALL_COST);
}
}
public static void checkMaxChunkMemoryUsage(long memoryCost) {
sLock.readLock().lock();
try {
if (!sRateLimiterEnabled) {
return;
}
} finally {
sLock.readLock().unlock();
}
long memoryLimit = getConfiguredMaxApiMemoryQuota(CHUNK_SIZE_LIMIT_IN_BYTES);
if (memoryCost > memoryLimit) {
throw new HealthConnectException(
HealthConnectException.ERROR_RATE_LIMIT_EXCEEDED,
"Records chunk size exceeded the max chunk limit: "
+ memoryLimit
+ ", was: "
+ memoryCost);
}
}
public static void checkMaxRecordMemoryUsage(long memoryCost) {
sLock.readLock().lock();
try {
if (!sRateLimiterEnabled) {
return;
}
} finally {
sLock.readLock().unlock();
}
long memoryLimit = getConfiguredMaxApiMemoryQuota(RECORD_SIZE_LIMIT_IN_BYTES);
if (memoryCost > memoryLimit) {
throw new HealthConnectException(
HealthConnectException.ERROR_RATE_LIMIT_EXCEEDED,
"Record size exceeded the single record size limit: "
+ memoryLimit
+ ", was: "
+ memoryCost);
}
}
public static void clearCache() {
sUserIdToQuotasMap.clear();
}
public static void updateApiCallQuotaMap(
Map<Integer, Integer> quotaBucketToMaxApiCallQuotaMap) {
for (Integer key : quotaBucketToMaxApiCallQuotaMap.keySet()) {
QUOTA_BUCKET_TO_MAX_API_CALL_QUOTA_MAP.put(
key, (float) quotaBucketToMaxApiCallQuotaMap.get(key));
}
}
public static void updateMemoryQuotaMap(Map<String, Integer> quotaBucketToMaxMemoryQuotaMap) {
for (String key : quotaBucketToMaxMemoryQuotaMap.keySet()) {
QUOTA_BUCKET_TO_MAX_MEMORY_QUOTA_MAP.put(key, quotaBucketToMaxMemoryQuotaMap.get(key));
}
}
public static void updateEnableRateLimiterFlag(boolean enableRateLimiter) {
sLock.writeLock().lock();
try {
sRateLimiterEnabled = enableRateLimiter;
} finally {
sLock.writeLock().unlock();
}
}
private static Object getLockObject(int uid) {
sLocks.putIfAbsent(uid, uid);
return sLocks.get(uid);
}
private static void spendResourcesIfAvailable(int uid, List<Integer> quotaBuckets, int cost) {
Map<Integer, Float> quotaBucketToAvailableQuotaMap =
quotaBuckets.stream()
.collect(
Collectors.toMap(
quotaBucket -> quotaBucket,
quotaBucket ->
getAvailableQuota(
quotaBucket, getQuota(uid, quotaBucket))));
for (@QuotaBucket.Type int quotaBucket : quotaBuckets) {
hasSufficientQuota(quotaBucketToAvailableQuotaMap.get(quotaBucket), cost, quotaBucket);
}
for (@QuotaBucket.Type int quotaBucket : quotaBuckets) {
spendResources(uid, quotaBucket, quotaBucketToAvailableQuotaMap.get(quotaBucket), cost);
}
}
private static void spendResources(
int uid, @QuotaBucket.Type int quotaBucket, float availableQuota, int cost) {
sUserIdToQuotasMap
.get(uid)
.put(quotaBucket, new Quota(Instant.now(), availableQuota - cost));
}
private static void hasSufficientQuota(
float availableQuota, int cost, @QuotaBucket.Type int quotaBucket) {
if (availableQuota < cost) {
throw new RateLimiterException(
"API call quota exceeded, availableQuota: "
+ availableQuota
+ " requested: "
+ cost,
quotaBucket,
getConfiguredApiCallMaxQuota(quotaBucket));
}
}
private static float getAvailableQuota(@QuotaBucket.Type int quotaBucket, Quota quota) {
Instant lastUpdatedTime = quota.getLastUpdatedTime();
Instant currentTime = Instant.now();
Duration timeSinceLastQuotaSpend = Duration.between(lastUpdatedTime, currentTime);
Duration window = getWindowDuration(quotaBucket);
float accumulated =
timeSinceLastQuotaSpend.toMillis()
* (getConfiguredApiCallMaxQuota(quotaBucket) / (float) window.toMillis());
// Cannot accumulate more than the configured max quota.
return Math.min(
quota.getRemainingQuota() + accumulated, getConfiguredApiCallMaxQuota(quotaBucket));
}
private static Quota getQuota(int uid, @QuotaBucket.Type int quotaBucket) {
// Handles first request scenario.
if (!sUserIdToQuotasMap.containsKey(uid)) {
sUserIdToQuotasMap.put(uid, new HashMap<>());
}
Map<Integer, Quota> packageQuotas = sUserIdToQuotasMap.get(uid);
Quota quota = packageQuotas.get(quotaBucket);
if (quota == null) {
quota = getInitialQuota(quotaBucket);
}
return quota;
}
private static Quota getInitialQuota(@QuotaBucket.Type int bucket) {
return new Quota(Instant.now(), getConfiguredApiCallMaxQuota(bucket));
}
private static Duration getWindowDuration(@QuotaBucket.Type int quotaBucket) {
switch (quotaBucket) {
case QuotaBucket.QUOTA_BUCKET_WRITES_PER_24H_BACKGROUND:
case QuotaBucket.QUOTA_BUCKET_WRITES_PER_24H_FOREGROUND:
case QuotaBucket.QUOTA_BUCKET_READS_PER_24H_BACKGROUND:
case QuotaBucket.QUOTA_BUCKET_READS_PER_24H_FOREGROUND:
return Duration.ofHours(24);
case QuotaBucket.QUOTA_BUCKET_WRITES_PER_15M_BACKGROUND:
case QuotaBucket.QUOTA_BUCKET_READS_PER_15M_FOREGROUND:
case QuotaBucket.QUOTA_BUCKET_WRITES_PER_15M_FOREGROUND:
case QuotaBucket.QUOTA_BUCKET_READS_PER_15M_BACKGROUND:
return Duration.ofMinutes(15);
case QuotaBucket.QUOTA_BUCKET_UNDEFINED:
throw new IllegalArgumentException("Invalid quota bucket.");
}
throw new IllegalArgumentException("Invalid quota bucket.");
}
private static float getConfiguredApiCallMaxQuota(@QuotaBucket.Type int quotaBucket) {
if (!QUOTA_BUCKET_TO_MAX_API_CALL_QUOTA_MAP.containsKey(quotaBucket)) {
throw new IllegalArgumentException(
"Max quota not found for quotaBucket: " + quotaBucket);
}
return QUOTA_BUCKET_TO_MAX_API_CALL_QUOTA_MAP.get(quotaBucket);
}
private static int getConfiguredMaxApiMemoryQuota(String quotaBucket) {
if (!QUOTA_BUCKET_TO_MAX_MEMORY_QUOTA_MAP.containsKey(quotaBucket)) {
throw new IllegalArgumentException(
"Max quota not found for quotaBucket: " + quotaBucket);
}
return QUOTA_BUCKET_TO_MAX_MEMORY_QUOTA_MAP.get(quotaBucket);
}
private static List<Integer> getAffectedQuotaBuckets(
@QuotaCategory.Type int quotaCategory, boolean isInForeground) {
switch (quotaCategory) {
case QuotaCategory.QUOTA_CATEGORY_READ:
if (isInForeground) {
return List.of(
QuotaBucket.QUOTA_BUCKET_READS_PER_15M_FOREGROUND,
QuotaBucket.QUOTA_BUCKET_READS_PER_24H_FOREGROUND);
} else {
return List.of(
QuotaBucket.QUOTA_BUCKET_READS_PER_15M_BACKGROUND,
QuotaBucket.QUOTA_BUCKET_READS_PER_24H_BACKGROUND);
}
case QuotaCategory.QUOTA_CATEGORY_WRITE:
if (isInForeground) {
return List.of(
QuotaBucket.QUOTA_BUCKET_WRITES_PER_15M_FOREGROUND,
QuotaBucket.QUOTA_BUCKET_WRITES_PER_24H_FOREGROUND);
} else {
return List.of(
QuotaBucket.QUOTA_BUCKET_WRITES_PER_15M_BACKGROUND,
QuotaBucket.QUOTA_BUCKET_WRITES_PER_24H_BACKGROUND);
}
case QuotaCategory.QUOTA_CATEGORY_UNDEFINED:
case QuotaCategory.QUOTA_CATEGORY_UNMETERED:
throw new IllegalArgumentException("Invalid quota category.");
}
throw new IllegalArgumentException("Invalid quota category.");
}
public static final class QuotaBucket {
public static final int QUOTA_BUCKET_UNDEFINED = 0;
public static final int QUOTA_BUCKET_READS_PER_15M_FOREGROUND = 1;
public static final int QUOTA_BUCKET_READS_PER_24H_FOREGROUND = 2;
public static final int QUOTA_BUCKET_READS_PER_15M_BACKGROUND = 3;
public static final int QUOTA_BUCKET_READS_PER_24H_BACKGROUND = 4;
public static final int QUOTA_BUCKET_WRITES_PER_15M_FOREGROUND = 5;
public static final int QUOTA_BUCKET_WRITES_PER_24H_FOREGROUND = 6;
public static final int QUOTA_BUCKET_WRITES_PER_15M_BACKGROUND = 7;
public static final int QUOTA_BUCKET_WRITES_PER_24H_BACKGROUND = 8;
private QuotaBucket() {}
/** @hide */
@IntDef({
QUOTA_BUCKET_UNDEFINED,
QUOTA_BUCKET_READS_PER_15M_FOREGROUND,
QUOTA_BUCKET_READS_PER_24H_FOREGROUND,
QUOTA_BUCKET_READS_PER_15M_BACKGROUND,
QUOTA_BUCKET_READS_PER_24H_BACKGROUND,
QUOTA_BUCKET_WRITES_PER_15M_FOREGROUND,
QUOTA_BUCKET_WRITES_PER_24H_FOREGROUND,
QUOTA_BUCKET_WRITES_PER_15M_BACKGROUND,
QUOTA_BUCKET_WRITES_PER_24H_BACKGROUND,
})
@Retention(RetentionPolicy.SOURCE)
public @interface Type {}
}
public static final class QuotaCategory {
public static final int QUOTA_CATEGORY_UNDEFINED = 0;
public static final int QUOTA_CATEGORY_UNMETERED = 1;
public static final int QUOTA_CATEGORY_READ = 2;
public static final int QUOTA_CATEGORY_WRITE = 3;
private QuotaCategory() {}
/** @hide */
@IntDef({
QUOTA_CATEGORY_UNDEFINED,
QUOTA_CATEGORY_UNMETERED,
QUOTA_CATEGORY_READ,
QUOTA_CATEGORY_WRITE,
})
@Retention(RetentionPolicy.SOURCE)
public @interface Type {}
}
}