Skip to content

Commit 3b76107

Browse files
authored
Merge pull request #2626 from constantine2nd/develop
docfix/Rate limiting guard function underCallLimits
2 parents 1e59bd8 + c4e9869 commit 3b76107

File tree

1 file changed

+66
-23
lines changed

1 file changed

+66
-23
lines changed

obp-api/src/main/scala/code/api/util/RateLimitingUtil.scala

Lines changed: 66 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ object RateLimitingUtil extends MdcLoggable {
7474

7575
def useConsumerLimits = APIUtil.getPropsAsBoolValue("use_consumer_limits", false)
7676

77-
private def createUniqueKey(consumerKey: String, period: LimitCallPeriod) = consumerKey + RateLimitingPeriod.toString(period)
77+
private def createUniqueKey(consumerKey: String, period: LimitCallPeriod) = consumerKey + "_" + RateLimitingPeriod.toString(period)
7878

7979
private def underConsumerLimits(consumerKey: String, period: LimitCallPeriod, limit: Long): Boolean = {
8080
if (useConsumerLimits) {
@@ -173,18 +173,51 @@ object RateLimitingUtil extends MdcLoggable {
173173
}
174174

175175
/**
176-
* This function checks rate limiting for a Consumer.
177-
* It will check rate limiting per minute, hour, day, week and month.
178-
* In case any of the above is hit an error is thrown.
179-
* In case two or more limits are hit rate limit with lower period has precedence regarding the error message.
180-
* @param userAndCallContext is a Tuple (Box[User], Option[CallContext]) provided from getUserAndSessionContextFuture function
181-
* @return a Tuple (Box[User], Option[CallContext]) enriched with rate limiting header or an error.
176+
* Rate limiting guard that enforces API call limits for both authorized and anonymous access.
177+
*
178+
* This is the main rate limiting enforcement function that controls access to OBP API endpoints.
179+
* It operates in two modes depending on whether the caller is authenticated or anonymous.
180+
*
181+
* AUTHORIZED ACCESS (with valid consumer credentials):
182+
* - Enforces limits across 6 time periods: per second, minute, hour, day, week, and month
183+
* - Uses consumer_id as the rate limiting key (simplified for current implementation)
184+
* - Note: api_name, api_version, and bank_id may be added to the key in future versions
185+
* - Limits are defined in CallLimit configuration for each consumer
186+
* - Stores counters in Redis with TTL matching the time period
187+
* - Returns 429 status with appropriate error message when any limit is exceeded
188+
* - Lower period limits take precedence in error messages (e.g., per-second over per-minute)
189+
*
190+
* ANONYMOUS ACCESS (no consumer credentials):
191+
* - Only enforces per-hour limits (configurable via "user_consumer_limit_anonymous_access", default: 1000)
192+
* - Uses client IP address as the rate limiting key
193+
* - Designed to prevent abuse while allowing reasonable anonymous usage
194+
*
195+
* REDIS STORAGE MECHANISM:
196+
* - Keys format: {consumer_id}_{PERIOD} (e.g., "consumer123_PER_MINUTE")
197+
* - Values: current call count within the time window
198+
* - TTL: automatically expires keys when time period ends
199+
* - Atomic operations ensure thread-safe counter increments
200+
*
201+
* RATE LIMIT HEADERS:
202+
* - Sets X-Rate-Limit-Limit: maximum allowed requests for the period
203+
* - Sets X-Rate-Limit-Reset: seconds until the limit resets (TTL)
204+
* - Sets X-Rate-Limit-Remaining: requests remaining in current period
205+
*
206+
* ERROR HANDLING:
207+
* - Redis connectivity issues default to allowing the request (fail-open)
208+
* - Rate limiting can be globally disabled via "use_consumer_limits" property
209+
* - Malformed or missing limits default to unlimited access
210+
*
211+
* @param userAndCallContext Tuple containing (Box[User], Option[CallContext]) from authentication
212+
* @return Same tuple structure, either with updated rate limit headers or rate limit exceeded error
182213
*/
183214
def underCallLimits(userAndCallContext: (Box[User], Option[CallContext])): (Box[User], Option[CallContext]) = {
215+
// Configuration and helper functions
184216
def perHourLimitAnonymous = APIUtil.getPropsAsIntValue("user_consumer_limit_anonymous_access", 1000)
185217
def composeMsgAuthorizedAccess(period: LimitCallPeriod, limit: Long): String = TooManyRequests + s" We only allow $limit requests ${RateLimitingPeriod.humanReadable(period)} for this Consumer."
186218
def composeMsgAnonymousAccess(period: LimitCallPeriod, limit: Long): String = TooManyRequests + s" We only allow $limit requests ${RateLimitingPeriod.humanReadable(period)} for anonymous access."
187219

220+
// Helper function to set rate limit headers in successful responses
188221
def setXRateLimits(c: CallLimit, z: (Long, Long), period: LimitCallPeriod): Option[CallContext] = {
189222
val limit = period match {
190223
case PER_SECOND => c.per_second
@@ -199,6 +232,7 @@ object RateLimitingUtil extends MdcLoggable {
199232
.map(_.copy(xRateLimitReset = z._1))
200233
.map(_.copy(xRateLimitRemaining = limit - z._2))
201234
}
235+
// Helper function to set rate limit headers for anonymous access
202236
def setXRateLimitsAnonymous(id: String, z: (Long, Long), period: LimitCallPeriod): Option[CallContext] = {
203237
val limit = period match {
204238
case PER_HOUR => perHourLimitAnonymous
@@ -209,6 +243,7 @@ object RateLimitingUtil extends MdcLoggable {
209243
.map(_.copy(xRateLimitRemaining = limit - z._2))
210244
}
211245

246+
// Helper function to create rate limit exceeded response with remaining TTL for authorized users
212247
def exceededRateLimit(c: CallLimit, period: LimitCallPeriod): Option[CallContextLight] = {
213248
val remain = ttl(c.consumer_id, period)
214249
val limit = period match {
@@ -225,6 +260,7 @@ object RateLimitingUtil extends MdcLoggable {
225260
.map(_.copy(xRateLimitRemaining = 0)).map(_.toLight)
226261
}
227262

263+
// Helper function to create rate limit exceeded response for anonymous users
228264
def exceededRateLimitAnonymous(id: String, period: LimitCallPeriod): Option[CallContextLight] = {
229265
val remain = ttl(id, period)
230266
val limit = period match {
@@ -236,15 +272,14 @@ object RateLimitingUtil extends MdcLoggable {
236272
.map(_.copy(xRateLimitRemaining = 0)).map(_.toLight)
237273
}
238274

275+
// Main logic: check if we have a CallContext and determine access type
239276
userAndCallContext._2 match {
240277
case Some(cc) =>
241278
cc.rateLimiting match {
242-
case Some(rl) => // Authorized access
243-
val rateLimitingKey =
244-
rl.consumer_id +
245-
rl.api_name.getOrElse("") +
246-
rl.api_version.getOrElse("") +
247-
rl.bank_id.getOrElse("")
279+
case Some(rl) => // AUTHORIZED ACCESS - consumer has valid credentials and rate limits
280+
// Create rate limiting key for Redis storage using consumer_id
281+
val rateLimitingKey = rl.consumer_id
282+
// Check if current request would exceed any of the 6 rate limits
248283
val checkLimits = List(
249284
underConsumerLimits(rateLimitingKey, PER_SECOND, rl.per_second),
250285
underConsumerLimits(rateLimitingKey, PER_MINUTE, rl.per_minute),
@@ -253,6 +288,7 @@ object RateLimitingUtil extends MdcLoggable {
253288
underConsumerLimits(rateLimitingKey, PER_WEEK, rl.per_week),
254289
underConsumerLimits(rateLimitingKey, PER_MONTH, rl.per_month)
255290
)
291+
// Return 429 error for first exceeded limit (shorter periods take precedence)
256292
checkLimits match {
257293
case x1 :: x2 :: x3 :: x4 :: x5 :: x6 :: Nil if x1 == false =>
258294
(fullBoxOrException(Empty ~> APIFailureNewStyle(composeMsgAuthorizedAccess(PER_SECOND, rl.per_second), 429, exceededRateLimit(rl, PER_SECOND))), userAndCallContext._2)
@@ -267,14 +303,16 @@ object RateLimitingUtil extends MdcLoggable {
267303
case x1 :: x2 :: x3 :: x4 :: x5 :: x6 :: Nil if x6 == false =>
268304
(fullBoxOrException(Empty ~> APIFailureNewStyle(composeMsgAuthorizedAccess(PER_MONTH, rl.per_month), 429, exceededRateLimit(rl, PER_MONTH))), userAndCallContext._2)
269305
case _ =>
306+
// All limits passed - increment counters and set rate limit headers
270307
val incrementCounters = List (
271-
incrementConsumerCounters(rateLimitingKey, PER_SECOND, rl.per_second), // Responses other than the 429 status code MUST be stored by a cache.
272-
incrementConsumerCounters(rateLimitingKey, PER_MINUTE, rl.per_minute), // Responses other than the 429 status code MUST be stored by a cache.
273-
incrementConsumerCounters(rateLimitingKey, PER_HOUR, rl.per_hour), // Responses other than the 429 status code MUST be stored by a cache.
274-
incrementConsumerCounters(rateLimitingKey, PER_DAY, rl.per_day), // Responses other than the 429 status code MUST be stored by a cache.
275-
incrementConsumerCounters(rateLimitingKey, PER_WEEK, rl.per_week), // Responses other than the 429 status code MUST be stored by a cache.
276-
incrementConsumerCounters(rateLimitingKey, PER_MONTH, rl.per_month) // Responses other than the 429 status code MUST be stored by a cache.
308+
incrementConsumerCounters(rateLimitingKey, PER_SECOND, rl.per_second),
309+
incrementConsumerCounters(rateLimitingKey, PER_MINUTE, rl.per_minute),
310+
incrementConsumerCounters(rateLimitingKey, PER_HOUR, rl.per_hour),
311+
incrementConsumerCounters(rateLimitingKey, PER_DAY, rl.per_day),
312+
incrementConsumerCounters(rateLimitingKey, PER_WEEK, rl.per_week),
313+
incrementConsumerCounters(rateLimitingKey, PER_MONTH, rl.per_month)
277314
)
315+
// Set rate limit headers based on the most restrictive active period
278316
incrementCounters match {
279317
case first :: _ :: _ :: _ :: _ :: _ :: Nil if first._1 > 0 =>
280318
(userAndCallContext._1, setXRateLimits(rl, first, PER_SECOND))
@@ -292,17 +330,21 @@ object RateLimitingUtil extends MdcLoggable {
292330
(userAndCallContext._1, userAndCallContext._2)
293331
}
294332
}
295-
case None => // Anonymous access
333+
case None => // ANONYMOUS ACCESS - no consumer credentials, use IP-based limiting
334+
// Use client IP address as rate limiting key for anonymous access
296335
val consumerId = cc.ipAddress
336+
// Anonymous access only has per-hour limits to prevent abuse
297337
val checkLimits = List(
298338
underConsumerLimits(consumerId, PER_HOUR, perHourLimitAnonymous)
299339
)
300340
checkLimits match {
301-
case x1 :: Nil if x1 == false =>
341+
case x1 :: Nil if !x1 =>
342+
// Return 429 error if anonymous hourly limit exceeded
302343
(fullBoxOrException(Empty ~> APIFailureNewStyle(composeMsgAnonymousAccess(PER_HOUR, perHourLimitAnonymous), 429, exceededRateLimitAnonymous(consumerId, PER_HOUR))), userAndCallContext._2)
303344
case _ =>
345+
// Limit not exceeded - increment counter and set headers
304346
val incrementCounters = List (
305-
incrementConsumerCounters(consumerId, PER_HOUR, perHourLimitAnonymous), // Responses other than the 429 status code MUST be stored by a cache.
347+
incrementConsumerCounters(consumerId, PER_HOUR, perHourLimitAnonymous)
306348
)
307349
incrementCounters match {
308350
case x1 :: Nil if x1._1 > 0 =>
@@ -312,7 +354,8 @@ object RateLimitingUtil extends MdcLoggable {
312354
}
313355
}
314356
}
315-
case _ => (userAndCallContext._1, userAndCallContext._2)
357+
case _ => // No CallContext available - pass through without rate limiting
358+
(userAndCallContext._1, userAndCallContext._2)
316359
}
317360
}
318361

0 commit comments

Comments
 (0)