• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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