Skip to content

Commit

Permalink
feat: rate limiter using lettuce
Browse files Browse the repository at this point in the history
  • Loading branch information
KimDoubleB committed Oct 6, 2024
1 parent 17797ae commit 415214c
Show file tree
Hide file tree
Showing 8 changed files with 364 additions and 19 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ enum class ExceptionCode(
VOTE_PLACE_ID_INVALID(400, "투표 항목 데이터(Place Id)이 올바르지 않습니다."),
NOT_SUPPORT_AUTO_COMPLETE_URL(400, "자동입력 지원을 하지 않는 주소형식 입니다."),
DUPLICATED_REQUEST(400, "같은 리소스에 대한 중복요청입니다."),
REQUEST_LIMIT_EXCEEDED_IN_TIME_WINDOW(400, "타임 윈도우 내 한정된 요청 수 초과"),

UNAUTHORIZED(401, "인증된 토큰으로부터의 요청이 아닙니다."),
ROOM_PASSWORD_INVALID(401, "방 패스워드가 틀립니다."),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.piikii.common.exception

class ServiceException : RuntimeException {
var statusCode: Int
var defaultMessage: String
var detailMessage: String? = null

constructor(
exceptionCode: ExceptionCode,
detailMessage: String,
) : super("[$exceptionCode] $detailMessage") {
this.statusCode = exceptionCode.statusCode
this.defaultMessage = exceptionCode.defaultMessage
this.detailMessage = detailMessage
}

constructor(exceptionCode: ExceptionCode) :
super("[$exceptionCode] ${exceptionCode.defaultMessage}") {
this.statusCode = exceptionCode.statusCode
this.defaultMessage = exceptionCode.defaultMessage
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package com.piikii.input.http.aspect

import com.piikii.common.exception.ExceptionCode
import com.piikii.common.exception.ServiceException
import com.piikii.output.redis.RedisLockRepository
import org.aspectj.lang.ProceedingJoinPoint
import org.aspectj.lang.annotation.Around
import org.aspectj.lang.annotation.Aspect
import org.aspectj.lang.reflect.MethodSignature
import org.springframework.context.expression.MethodBasedEvaluationContext
import org.springframework.core.DefaultParameterNameDiscoverer
import org.springframework.expression.spel.standard.SpelExpressionParser
import org.springframework.stereotype.Component
import java.time.Duration
import java.time.Instant
import java.time.temporal.ChronoUnit

@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class LimitConcurrentRequest(
val key: String,
val maxRequestCount: Long = 1,
val timeUnit: ChronoUnit = ChronoUnit.SECONDS,
val amount: Long = 5,
)

@Aspect
@Component
class LimitConcurrentRequestAspect(
private val redisLockRepository: RedisLockRepository,
) {
private val parser = SpelExpressionParser()
private val parameterNameDiscoverer = DefaultParameterNameDiscoverer()

@Around("@annotation(RateLimiter)")
fun limitConcurrentRequest(joinPoint: ProceedingJoinPoint): Any? {
val signature = joinPoint.signature as MethodSignature
val method = signature.method
val annotation = method.getAnnotation(LimitConcurrentRequest::class.java)

val key = "req_concurrent_limit:${method.name}"
val epochSecond = Instant.now().epochSecond.toDouble()

val member = generateMember(
memberSuffix = epochSecond.toString(),
joinPoint = joinPoint,
signature = signature,
annotation = annotation
)

val ttl = Duration.of(annotation.amount, annotation.timeUnit)
redisLockRepository.removeOutOfWindow(key, epochSecond, ttl)
if (redisLockRepository.getCountRequestsInWindow(key) >= annotation.maxRequestCount) {
throw ServiceException(ExceptionCode.REQUEST_LIMIT_EXCEEDED_IN_TIME_WINDOW)
}

redisLockRepository.increaseRequestsInWindow(key, member, epochSecond, ttl)
return joinPoint.proceed()
}

/**
* 요청 인식용 Member parsing
* - using SpEL parsed value
*/
private fun generateMember(
memberSuffix: String,
joinPoint: ProceedingJoinPoint,
signature: MethodSignature,
annotation: LimitConcurrentRequest,
): String {
val method = signature.method
val expression = parser.parseExpression(annotation.key)
val context =
MethodBasedEvaluationContext(
joinPoint.target,
method,
joinPoint.args,
parameterNameDiscoverer,
)
val member = expression.getValue(context, String::class.java)
?: throw ServiceException(
ExceptionCode.NOT_FOUNDED,
"LimitConcurrentRequest member 추출에 실패했습니다 (method: ${method.name}, annotation key: ${annotation.key})",
)
return "$member:$memberSuffix"
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package com.piikii.input.http.aspect

import com.piikii.common.exception.ExceptionCode
import com.piikii.common.exception.ServiceException
import org.aspectj.lang.ProceedingJoinPoint
import org.aspectj.lang.annotation.Around
import org.aspectj.lang.annotation.Aspect
import org.aspectj.lang.reflect.MethodSignature
import org.springframework.context.expression.MethodBasedEvaluationContext
import org.springframework.core.DefaultParameterNameDiscoverer
import org.springframework.expression.spel.standard.SpelExpressionParser
import org.springframework.stereotype.Component
import java.time.temporal.ChronoUnit
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.TimeoutException

/**
* 중복요청 방지 제어 Annotation
*
* @property key key to determine duplicate requests (support SpEL)
* @property timeUnit time unit of timeout
* @property amount time amount of timeout
*/
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class PreventDuplicateAspectWithConcurrent(
val key: String,
val timeUnit: ChronoUnit = ChronoUnit.SECONDS,
val amount: Long = 5,
)

@Aspect
@Component
class PreventDuplicateAspectWithConcurrentAspect {
private val processingRequests = ConcurrentHashMap.newKeySet<String>()
private val parser = SpelExpressionParser()
private val parameterNameDiscoverer = DefaultParameterNameDiscoverer()
private val executor = Executors.newVirtualThreadPerTaskExecutor()

@Around("@annotation(PreventDuplicateRequest)")
fun preventDuplicateRequest(joinPoint: ProceedingJoinPoint): Any? {
val signature = joinPoint.signature as MethodSignature
val method = signature.method
val annotation = method.getAnnotation(PreventDuplicateRequest::class.java)
val key = generateKey(joinPoint, signature, annotation)

if (!processingRequests.add(key)) {
// if exists, throw Duplicated Request Exception
throw ServiceException(ExceptionCode.DUPLICATED_REQUEST)
}

val future = executor.submit<Any> { joinPoint.proceed() }
try {
return future.get(annotation.amount, TimeUnit.of(annotation.timeUnit))
} catch (e: TimeoutException) {
future.cancel(true)
throw ServiceException(ExceptionCode.REQUEST_TIMEOUT)
} finally {
processingRequests.remove(key)
}
}

/**
* 중복 요청으로 판단할 key 생성
* - method name + SpEL parsed value
*
* @param joinPoint
* @param signature
* @param annotation
* @return Key to determine duplicate requests
*/
private fun generateKey(
joinPoint: ProceedingJoinPoint,
signature: MethodSignature,
annotation: PreventDuplicateRequest,
): String {
val method = signature.method
val expression = parser.parseExpression(annotation.key)
val context =
MethodBasedEvaluationContext(
joinPoint.target,
method,
joinPoint.args,
parameterNameDiscoverer,
)
val parsedValue =
expression.getValue(context, String::class.java)
?: throw ServiceException(
ExceptionCode.NOT_FOUNDED,
"중복요청 계산에 사용될 key가 없습니다 (method: ${method.name}, annotation key: ${annotation.key})",
)
return "${method.name}:$parsedValue"
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
package com.piikii.input.http.aspect

import com.piikii.common.exception.ExceptionCode
import com.piikii.common.exception.PiikiiException
import com.piikii.common.exception.ServiceException
import com.piikii.output.redis.RedisLockRepository
import java.time.Duration
import org.aspectj.lang.ProceedingJoinPoint
import org.aspectj.lang.annotation.Around
import org.aspectj.lang.annotation.Aspect
Expand All @@ -12,22 +11,22 @@ import org.springframework.context.expression.MethodBasedEvaluationContext
import org.springframework.core.DefaultParameterNameDiscoverer
import org.springframework.expression.spel.standard.SpelExpressionParser
import org.springframework.stereotype.Component
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.TimeoutException
import java.time.Duration
import java.time.temporal.ChronoUnit

/**
* 중복요청 방지 제어 Annotation
*
* @property key key to determine duplicate requests (support SpEL)
* @property timeoutMillis default 7 seconds
* @property timeUnit time unit of timeout
* @property amount time amount of timeout
*/
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class PreventDuplicateRequest(
val key: String,
val timeoutMillis: Long = 5_000,
val timeUnit: ChronoUnit = ChronoUnit.SECONDS,
val amount: Long = 5,
)

@Aspect
Expand All @@ -45,9 +44,9 @@ class PreventDuplicateAspect(
val annotation = method.getAnnotation(PreventDuplicateRequest::class.java)
val key = generateKey(joinPoint, signature, annotation)

if (!redisLockRepository.lock(key, Duration.ofMillis(annotation.timeoutMillis))) {
if (!redisLockRepository.lock(key, Duration.of(annotation.amount, annotation.timeUnit))) {
// if exists, throw Duplicated Request Exception
throw PiikiiException(ExceptionCode.DUPLICATED_REQUEST)
throw ServiceException(ExceptionCode.DUPLICATED_REQUEST)
}

try {
Expand All @@ -59,7 +58,7 @@ class PreventDuplicateAspect(

/**
* 중복 요청으로 판단할 key 생성
* - method name + SpEL parsed value
* - req_duplicate + method name + SpEL parsed value
*
* @param joinPoint
* @param signature
Expand All @@ -82,10 +81,10 @@ class PreventDuplicateAspect(
)
val parsedValue =
expression.getValue(context, String::class.java)
?: throw PiikiiException(
?: throw ServiceException(
ExceptionCode.NOT_FOUNDED,
"중복요청 계산에 사용될 key가 없습니다 (method: ${method.name}, annotation key: ${annotation.key})",
"PreventDuplicateRequest key 추출에 실패했습니다 (method: ${method.name}, annotation key: ${annotation.key})",
)
return "${method.name}_$parsedValue"
return "req_duplicate:${method.name}:$parsedValue"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package com.piikii.input.http.aspect

import com.piikii.common.exception.ExceptionCode
import com.piikii.common.exception.ServiceException
import com.piikii.output.redis.RedisLockRepository
import org.aspectj.lang.ProceedingJoinPoint
import org.aspectj.lang.annotation.Around
import org.aspectj.lang.annotation.Aspect
import org.aspectj.lang.reflect.MethodSignature
import org.springframework.context.expression.MethodBasedEvaluationContext
import org.springframework.core.DefaultParameterNameDiscoverer
import org.springframework.expression.spel.standard.SpelExpressionParser
import org.springframework.stereotype.Component
import java.time.Duration
import java.time.Instant
import java.time.temporal.ChronoUnit

@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class RateLimiter(
val key: String,
val maxRequestCount: Long = 1,
val timeUnit: ChronoUnit = ChronoUnit.SECONDS,
val amount: Long = 5,
)

@Aspect
@Component
class RateLimiterAspect(
private val redisLockRepository: RedisLockRepository,
) {
private val parser = SpelExpressionParser()
private val parameterNameDiscoverer = DefaultParameterNameDiscoverer()

@Around("@annotation(RateLimiter)")
fun rateLimiter(joinPoint: ProceedingJoinPoint): Any? {
val signature = joinPoint.signature as MethodSignature
val method = signature.method
val annotation = method.getAnnotation(RateLimiter::class.java)

val key = "rate_limit:${method.name}"
val epochSecond = Instant.now().epochSecond.toDouble()

val member = generateMember(
memberSuffix = epochSecond.toString(),
joinPoint = joinPoint,
signature = signature,
annotation = annotation
)

val ttl = Duration.of(annotation.amount, annotation.timeUnit)
redisLockRepository.removeOutOfWindow(key, epochSecond, ttl)
if (redisLockRepository.getCountRequestsInWindow(key) >= annotation.maxRequestCount) {
throw ServiceException(ExceptionCode.REQUEST_LIMIT_EXCEEDED_IN_TIME_WINDOW)
}

redisLockRepository.increaseRequestsInWindow(key, member, epochSecond, ttl)
return joinPoint.proceed()
}

/**
* 요청 인식용 Member parsing
* - using SpEL parsed value
*/
private fun generateMember(
memberSuffix: String,
joinPoint: ProceedingJoinPoint,
signature: MethodSignature,
annotation: RateLimiter,
): String {
val method = signature.method
val expression = parser.parseExpression(annotation.key)
val context =
MethodBasedEvaluationContext(
joinPoint.target,
method,
joinPoint.args,
parameterNameDiscoverer,
)
val member = expression.getValue(context, String::class.java)
?: throw ServiceException(
ExceptionCode.NOT_FOUNDED,
"RateLimit member 추출에 실패했습니다 (method: ${method.name}, annotation key: ${annotation.key})",
)
return "$member:$memberSuffix"
}

}
Loading

0 comments on commit 415214c

Please sign in to comment.