1 /* 2 * Copyright (C) 2023 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package android.health.connect.ratelimiter; 18 19 import android.annotation.IntDef; 20 import android.health.connect.HealthConnectException; 21 22 import com.android.internal.annotations.GuardedBy; 23 24 import java.lang.annotation.Retention; 25 import java.lang.annotation.RetentionPolicy; 26 import java.time.Duration; 27 import java.time.Instant; 28 import java.util.HashMap; 29 import java.util.List; 30 import java.util.Map; 31 import java.util.concurrent.ConcurrentHashMap; 32 import java.util.concurrent.ConcurrentMap; 33 import java.util.concurrent.locks.ReentrantReadWriteLock; 34 import java.util.stream.Collectors; 35 36 /** 37 * Basic rate limiter that assigns a fixed request rate quota. If no quota has previously been noted 38 * (e.g. first request scenario), the full quota for each window will be immediately granted. 39 * 40 * @hide 41 */ 42 public final class RateLimiter { 43 // The maximum number of bytes a client can insert in one go. 44 public static final String CHUNK_SIZE_LIMIT_IN_BYTES = "chunk_size_limit_in_bytes"; 45 // The maximum size in bytes of a single record a client can insert in one go. 46 public static final String RECORD_SIZE_LIMIT_IN_BYTES = "record_size_limit_in_bytes"; 47 private static final int DEFAULT_API_CALL_COST = 1; 48 private static final Map<Integer, Map<Integer, Quota>> sUserIdToQuotasMap = new HashMap<>(); 49 50 private static final ConcurrentMap<Integer, Integer> sLocks = new ConcurrentHashMap<>(); 51 private static final Map<Integer, Float> QUOTA_BUCKET_TO_MAX_API_CALL_QUOTA_MAP = 52 new HashMap<>(); 53 private static final Map<String, Integer> QUOTA_BUCKET_TO_MAX_MEMORY_QUOTA_MAP = 54 new HashMap<>(); 55 private static final ReentrantReadWriteLock sLock = new ReentrantReadWriteLock(); 56 57 @GuardedBy("sLock") 58 private static boolean sRateLimiterEnabled; 59 tryAcquireApiCallQuota( int uid, @QuotaCategory.Type int quotaCategory, boolean isInForeground)60 public static void tryAcquireApiCallQuota( 61 int uid, @QuotaCategory.Type int quotaCategory, boolean isInForeground) { 62 sLock.readLock().lock(); 63 try { 64 if (!sRateLimiterEnabled) { 65 return; 66 } 67 } finally { 68 sLock.readLock().unlock(); 69 } 70 if (quotaCategory == QuotaCategory.QUOTA_CATEGORY_UNDEFINED) { 71 throw new IllegalArgumentException("Quota category not defined."); 72 } 73 74 // Rate limiting not applicable. 75 if (quotaCategory == QuotaCategory.QUOTA_CATEGORY_UNMETERED) { 76 return; 77 } 78 synchronized (getLockObject(uid)) { 79 spendResourcesIfAvailable( 80 uid, 81 getAffectedQuotaBuckets(quotaCategory, isInForeground), 82 DEFAULT_API_CALL_COST); 83 } 84 } 85 checkMaxChunkMemoryUsage(long memoryCost)86 public static void checkMaxChunkMemoryUsage(long memoryCost) { 87 sLock.readLock().lock(); 88 try { 89 if (!sRateLimiterEnabled) { 90 return; 91 } 92 } finally { 93 sLock.readLock().unlock(); 94 } 95 long memoryLimit = getConfiguredMaxApiMemoryQuota(CHUNK_SIZE_LIMIT_IN_BYTES); 96 if (memoryCost > memoryLimit) { 97 throw new HealthConnectException( 98 HealthConnectException.ERROR_RATE_LIMIT_EXCEEDED, 99 "Records chunk size exceeded the max chunk limit: " 100 + memoryLimit 101 + ", was: " 102 + memoryCost); 103 } 104 } 105 checkMaxRecordMemoryUsage(long memoryCost)106 public static void checkMaxRecordMemoryUsage(long memoryCost) { 107 sLock.readLock().lock(); 108 try { 109 if (!sRateLimiterEnabled) { 110 return; 111 } 112 } finally { 113 sLock.readLock().unlock(); 114 } 115 long memoryLimit = getConfiguredMaxApiMemoryQuota(RECORD_SIZE_LIMIT_IN_BYTES); 116 if (memoryCost > memoryLimit) { 117 throw new HealthConnectException( 118 HealthConnectException.ERROR_RATE_LIMIT_EXCEEDED, 119 "Record size exceeded the single record size limit: " 120 + memoryLimit 121 + ", was: " 122 + memoryCost); 123 } 124 } 125 clearCache()126 public static void clearCache() { 127 sUserIdToQuotasMap.clear(); 128 } 129 updateApiCallQuotaMap( Map<Integer, Integer> quotaBucketToMaxApiCallQuotaMap)130 public static void updateApiCallQuotaMap( 131 Map<Integer, Integer> quotaBucketToMaxApiCallQuotaMap) { 132 133 for (Integer key : quotaBucketToMaxApiCallQuotaMap.keySet()) { 134 QUOTA_BUCKET_TO_MAX_API_CALL_QUOTA_MAP.put( 135 key, (float) quotaBucketToMaxApiCallQuotaMap.get(key)); 136 } 137 } 138 updateMemoryQuotaMap(Map<String, Integer> quotaBucketToMaxMemoryQuotaMap)139 public static void updateMemoryQuotaMap(Map<String, Integer> quotaBucketToMaxMemoryQuotaMap) { 140 for (String key : quotaBucketToMaxMemoryQuotaMap.keySet()) { 141 QUOTA_BUCKET_TO_MAX_MEMORY_QUOTA_MAP.put(key, quotaBucketToMaxMemoryQuotaMap.get(key)); 142 } 143 } 144 updateEnableRateLimiterFlag(boolean enableRateLimiter)145 public static void updateEnableRateLimiterFlag(boolean enableRateLimiter) { 146 sLock.writeLock().lock(); 147 try { 148 sRateLimiterEnabled = enableRateLimiter; 149 } finally { 150 sLock.writeLock().unlock(); 151 } 152 } 153 getLockObject(int uid)154 private static Object getLockObject(int uid) { 155 sLocks.putIfAbsent(uid, uid); 156 return sLocks.get(uid); 157 } 158 spendResourcesIfAvailable(int uid, List<Integer> quotaBuckets, int cost)159 private static void spendResourcesIfAvailable(int uid, List<Integer> quotaBuckets, int cost) { 160 Map<Integer, Float> quotaBucketToAvailableQuotaMap = 161 quotaBuckets.stream() 162 .collect( 163 Collectors.toMap( 164 quotaBucket -> quotaBucket, 165 quotaBucket -> 166 getAvailableQuota( 167 quotaBucket, getQuota(uid, quotaBucket)))); 168 for (@QuotaBucket.Type int quotaBucket : quotaBuckets) { 169 hasSufficientQuota(quotaBucketToAvailableQuotaMap.get(quotaBucket), cost, quotaBucket); 170 } 171 for (@QuotaBucket.Type int quotaBucket : quotaBuckets) { 172 spendResources(uid, quotaBucket, quotaBucketToAvailableQuotaMap.get(quotaBucket), cost); 173 } 174 } 175 spendResources( int uid, @QuotaBucket.Type int quotaBucket, float availableQuota, int cost)176 private static void spendResources( 177 int uid, @QuotaBucket.Type int quotaBucket, float availableQuota, int cost) { 178 sUserIdToQuotasMap 179 .get(uid) 180 .put(quotaBucket, new Quota(Instant.now(), availableQuota - cost)); 181 } 182 hasSufficientQuota( float availableQuota, int cost, @QuotaBucket.Type int quotaBucket)183 private static void hasSufficientQuota( 184 float availableQuota, int cost, @QuotaBucket.Type int quotaBucket) { 185 if (availableQuota < cost) { 186 throw new RateLimiterException( 187 "API call quota exceeded, availableQuota: " 188 + availableQuota 189 + " requested: " 190 + cost, 191 quotaBucket, 192 getConfiguredApiCallMaxQuota(quotaBucket)); 193 } 194 } 195 getAvailableQuota(@uotaBucket.Type int quotaBucket, Quota quota)196 private static float getAvailableQuota(@QuotaBucket.Type int quotaBucket, Quota quota) { 197 Instant lastUpdatedTime = quota.getLastUpdatedTime(); 198 Instant currentTime = Instant.now(); 199 Duration timeSinceLastQuotaSpend = Duration.between(lastUpdatedTime, currentTime); 200 Duration window = getWindowDuration(quotaBucket); 201 float accumulated = 202 timeSinceLastQuotaSpend.toMillis() 203 * (getConfiguredApiCallMaxQuota(quotaBucket) / (float) window.toMillis()); 204 // Cannot accumulate more than the configured max quota. 205 return Math.min( 206 quota.getRemainingQuota() + accumulated, getConfiguredApiCallMaxQuota(quotaBucket)); 207 } 208 getQuota(int uid, @QuotaBucket.Type int quotaBucket)209 private static Quota getQuota(int uid, @QuotaBucket.Type int quotaBucket) { 210 // Handles first request scenario. 211 if (!sUserIdToQuotasMap.containsKey(uid)) { 212 sUserIdToQuotasMap.put(uid, new HashMap<>()); 213 } 214 Map<Integer, Quota> packageQuotas = sUserIdToQuotasMap.get(uid); 215 Quota quota = packageQuotas.get(quotaBucket); 216 if (quota == null) { 217 quota = getInitialQuota(quotaBucket); 218 } 219 return quota; 220 } 221 getInitialQuota(@uotaBucket.Type int bucket)222 private static Quota getInitialQuota(@QuotaBucket.Type int bucket) { 223 return new Quota(Instant.now(), getConfiguredApiCallMaxQuota(bucket)); 224 } 225 getWindowDuration(@uotaBucket.Type int quotaBucket)226 private static Duration getWindowDuration(@QuotaBucket.Type int quotaBucket) { 227 switch (quotaBucket) { 228 case QuotaBucket.QUOTA_BUCKET_WRITES_PER_24H_BACKGROUND: 229 case QuotaBucket.QUOTA_BUCKET_WRITES_PER_24H_FOREGROUND: 230 case QuotaBucket.QUOTA_BUCKET_READS_PER_24H_BACKGROUND: 231 case QuotaBucket.QUOTA_BUCKET_READS_PER_24H_FOREGROUND: 232 return Duration.ofHours(24); 233 case QuotaBucket.QUOTA_BUCKET_WRITES_PER_15M_BACKGROUND: 234 case QuotaBucket.QUOTA_BUCKET_READS_PER_15M_FOREGROUND: 235 case QuotaBucket.QUOTA_BUCKET_WRITES_PER_15M_FOREGROUND: 236 case QuotaBucket.QUOTA_BUCKET_READS_PER_15M_BACKGROUND: 237 return Duration.ofMinutes(15); 238 case QuotaBucket.QUOTA_BUCKET_UNDEFINED: 239 throw new IllegalArgumentException("Invalid quota bucket."); 240 } 241 throw new IllegalArgumentException("Invalid quota bucket."); 242 } 243 getConfiguredApiCallMaxQuota(@uotaBucket.Type int quotaBucket)244 private static float getConfiguredApiCallMaxQuota(@QuotaBucket.Type int quotaBucket) { 245 if (!QUOTA_BUCKET_TO_MAX_API_CALL_QUOTA_MAP.containsKey(quotaBucket)) { 246 throw new IllegalArgumentException( 247 "Max quota not found for quotaBucket: " + quotaBucket); 248 } 249 return QUOTA_BUCKET_TO_MAX_API_CALL_QUOTA_MAP.get(quotaBucket); 250 } 251 getConfiguredMaxApiMemoryQuota(String quotaBucket)252 private static int getConfiguredMaxApiMemoryQuota(String quotaBucket) { 253 if (!QUOTA_BUCKET_TO_MAX_MEMORY_QUOTA_MAP.containsKey(quotaBucket)) { 254 throw new IllegalArgumentException( 255 "Max quota not found for quotaBucket: " + quotaBucket); 256 } 257 return QUOTA_BUCKET_TO_MAX_MEMORY_QUOTA_MAP.get(quotaBucket); 258 } 259 getAffectedQuotaBuckets( @uotaCategory.Type int quotaCategory, boolean isInForeground)260 private static List<Integer> getAffectedQuotaBuckets( 261 @QuotaCategory.Type int quotaCategory, boolean isInForeground) { 262 switch (quotaCategory) { 263 case QuotaCategory.QUOTA_CATEGORY_READ: 264 if (isInForeground) { 265 return List.of( 266 QuotaBucket.QUOTA_BUCKET_READS_PER_15M_FOREGROUND, 267 QuotaBucket.QUOTA_BUCKET_READS_PER_24H_FOREGROUND); 268 } else { 269 return List.of( 270 QuotaBucket.QUOTA_BUCKET_READS_PER_15M_BACKGROUND, 271 QuotaBucket.QUOTA_BUCKET_READS_PER_24H_BACKGROUND); 272 } 273 case QuotaCategory.QUOTA_CATEGORY_WRITE: 274 if (isInForeground) { 275 return List.of( 276 QuotaBucket.QUOTA_BUCKET_WRITES_PER_15M_FOREGROUND, 277 QuotaBucket.QUOTA_BUCKET_WRITES_PER_24H_FOREGROUND); 278 } else { 279 return List.of( 280 QuotaBucket.QUOTA_BUCKET_WRITES_PER_15M_BACKGROUND, 281 QuotaBucket.QUOTA_BUCKET_WRITES_PER_24H_BACKGROUND); 282 } 283 case QuotaCategory.QUOTA_CATEGORY_UNDEFINED: 284 case QuotaCategory.QUOTA_CATEGORY_UNMETERED: 285 throw new IllegalArgumentException("Invalid quota category."); 286 } 287 throw new IllegalArgumentException("Invalid quota category."); 288 } 289 290 public static final class QuotaBucket { 291 public static final int QUOTA_BUCKET_UNDEFINED = 0; 292 public static final int QUOTA_BUCKET_READS_PER_15M_FOREGROUND = 1; 293 public static final int QUOTA_BUCKET_READS_PER_24H_FOREGROUND = 2; 294 public static final int QUOTA_BUCKET_READS_PER_15M_BACKGROUND = 3; 295 public static final int QUOTA_BUCKET_READS_PER_24H_BACKGROUND = 4; 296 public static final int QUOTA_BUCKET_WRITES_PER_15M_FOREGROUND = 5; 297 public static final int QUOTA_BUCKET_WRITES_PER_24H_FOREGROUND = 6; 298 public static final int QUOTA_BUCKET_WRITES_PER_15M_BACKGROUND = 7; 299 public static final int QUOTA_BUCKET_WRITES_PER_24H_BACKGROUND = 8; 300 QuotaBucket()301 private QuotaBucket() {} 302 303 /** @hide */ 304 @IntDef({ 305 QUOTA_BUCKET_UNDEFINED, 306 QUOTA_BUCKET_READS_PER_15M_FOREGROUND, 307 QUOTA_BUCKET_READS_PER_24H_FOREGROUND, 308 QUOTA_BUCKET_READS_PER_15M_BACKGROUND, 309 QUOTA_BUCKET_READS_PER_24H_BACKGROUND, 310 QUOTA_BUCKET_WRITES_PER_15M_FOREGROUND, 311 QUOTA_BUCKET_WRITES_PER_24H_FOREGROUND, 312 QUOTA_BUCKET_WRITES_PER_15M_BACKGROUND, 313 QUOTA_BUCKET_WRITES_PER_24H_BACKGROUND, 314 }) 315 @Retention(RetentionPolicy.SOURCE) 316 public @interface Type {} 317 } 318 319 public static final class QuotaCategory { 320 public static final int QUOTA_CATEGORY_UNDEFINED = 0; 321 public static final int QUOTA_CATEGORY_UNMETERED = 1; 322 public static final int QUOTA_CATEGORY_READ = 2; 323 public static final int QUOTA_CATEGORY_WRITE = 3; 324 QuotaCategory()325 private QuotaCategory() {} 326 327 /** @hide */ 328 @IntDef({ 329 QUOTA_CATEGORY_UNDEFINED, 330 QUOTA_CATEGORY_UNMETERED, 331 QUOTA_CATEGORY_READ, 332 QUOTA_CATEGORY_WRITE, 333 }) 334 @Retention(RetentionPolicy.SOURCE) 335 public @interface Type {} 336 } 337 } 338