Skip to content

Commit

Permalink
Merge pull request #74 from prgrms-web-devcourse-final-project/QUZ-12…
Browse files Browse the repository at this point in the history
…2-gateway-rate-limit

[QUZ-122] gateway rate limit, circuit breaker 패턴 적용
  • Loading branch information
HMWG authored Dec 8, 2024
2 parents 72fa259 + eb68c43 commit 176b8b9
Show file tree
Hide file tree
Showing 9 changed files with 383 additions and 45 deletions.
8 changes: 6 additions & 2 deletions gateway-service/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,12 @@ dependencies {
implementation("org.springframework.boot:spring-boot-starter-security")
testImplementation("org.springframework.security:spring-security-test")

//redis
implementation("org.springframework.boot:spring-boot-starter-data-redis")
// Redis
implementation("org.springframework.boot:spring-boot-starter-data-redis-reactive")

// Circuit Breaker
implementation("org.springframework.cloud:spring-cloud-starter-circuitbreaker-reactor-resilience4j:3.1.0")

}

tasks.named<BootJar>("bootJar") {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.grepp.quizy.api

import com.grepp.quizy.exception.CustomCircuitBreakerException
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController

@RestController
@RequestMapping("/fallback")
class GatewayFallbackController {
@GetMapping("/user")
fun userServiceFallback() {
throw CustomCircuitBreakerException.UserServiceUnavailableException
}

@GetMapping("/quiz")
fun quizServiceFallback() {
throw CustomCircuitBreakerException.QuizServiceUnavailableException
}

@GetMapping("/game")
fun gameServiceFallback() {
throw CustomCircuitBreakerException.GameServiceUnavailableException
}

@GetMapping("/ws")
fun webSocketServiceFallback() {
throw CustomCircuitBreakerException.WsUnavailableException
}

@GetMapping("/matching")
fun matchingServiceFallback() {
throw CustomCircuitBreakerException.MatchingServiceUnavailableException
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package com.grepp.quizy.config

import com.grepp.quizy.jwt.JwtProvider
import com.grepp.quizy.user.api.global.util.CookieUtils
import org.springframework.cloud.gateway.filter.ratelimit.KeyResolver
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.http.HttpHeaders
import org.springframework.http.server.reactive.ServerHttpRequest
import reactor.core.publisher.Mono

@Configuration
class RateLimiterConfig(
private val jwtProvider: JwtProvider,
) {

@Bean
fun userKeyResolver(): KeyResolver {
return KeyResolver { exchange ->
// JWT 토큰에서 사용자 ID를 추출하여 rate limit key로 사용
val token = extractToken(exchange.request)

if (token != null) {
try {
val userId = jwtProvider.getUserIdFromToken(token)
Mono.just(userId.value.toString())
} catch (e: Exception) {
// 토큰이 유효하지 않은 경우 IP 주소를 key로 사용
Mono.just(exchange.request.remoteAddress?.address?.hostAddress ?: "anonymous")
}

} else {
// 토큰이 없는 경우 IP 주소를 key로 사용
Mono.just(exchange.request.remoteAddress?.address?.hostAddress ?: "anonymous")
}
}
}

private fun extractToken(request: ServerHttpRequest): String? {
return if (request.headers.containsKey(HttpHeaders.AUTHORIZATION)) {
resolveToken(request)
} else {
CookieUtils.getCookieValue(request, "refreshToken") ?: ""
}
}

private fun resolveToken(request: ServerHttpRequest): String? {
val authHeader = request.headers[HttpHeaders.AUTHORIZATION]?.get(0) ?: ""
return if (authHeader.startsWith("Bearer ")) {
authHeader.substring(7)
} else {
null
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.grepp.quizy.exception

import com.grepp.quizy.common.exception.BaseErrorCode
import com.grepp.quizy.common.exception.ErrorReason
import org.springframework.http.HttpStatus

enum class CircuitBreakerErrorCode(
private val status: Int,
private val errorCode: String,
private val message: String,
) : BaseErrorCode {

GAME_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE.value(), "CIRCUIT_BREAKER_503", "게임 서비스를 이용할 수 없습니다."),
MATCHING_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE.value(), "CIRCUIT_BREAKER_503", "매칭 서비스를 이용할 수 없습니다."),
QUIZ_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE.value(), "CIRCUIT_BREAKER_503", "퀴즈 서비스를 이용할 수 없습니다."),
USER_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE.value(), "CIRCUIT_BREAKER_503", "사용자 서비스를 이용할 수 없습니다."),
WS_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE.value(), "CIRCUIT_BREAKER_503", "웹소켓 서비스를 이용할 수 없습니다."),
;

override val errorReason: ErrorReason
get() = ErrorReason(status, errorCode, message)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package com.grepp.quizy.exception

import com.grepp.quizy.common.exception.WebException

sealed class CustomCircuitBreakerException(errorCode: CircuitBreakerErrorCode) : WebException(errorCode) {
data object GameServiceUnavailableException :
CustomCircuitBreakerException(CircuitBreakerErrorCode.GAME_UNAVAILABLE) {
private fun readResolve(): Any = GameServiceUnavailableException

val EXCEPTION: CustomCircuitBreakerException = GameServiceUnavailableException
}

data object UserServiceUnavailableException :
CustomCircuitBreakerException(CircuitBreakerErrorCode.USER_UNAVAILABLE) {
private fun readResolve(): Any = UserServiceUnavailableException

val EXCEPTION: CustomCircuitBreakerException = UserServiceUnavailableException
}

data object QuizServiceUnavailableException :
CustomCircuitBreakerException(CircuitBreakerErrorCode.QUIZ_UNAVAILABLE) {
private fun readResolve(): Any = QuizServiceUnavailableException

val EXCEPTION: CustomCircuitBreakerException = QuizServiceUnavailableException
}

data object MatchingServiceUnavailableException :
CustomCircuitBreakerException(CircuitBreakerErrorCode.MATCHING_UNAVAILABLE) {
private fun readResolve(): Any = MatchingServiceUnavailableException

val EXCEPTION: CustomCircuitBreakerException = MatchingServiceUnavailableException
}

data object WsUnavailableException :
CustomCircuitBreakerException(CircuitBreakerErrorCode.WS_UNAVAILABLE) {
private fun readResolve(): Any = WsUnavailableException

val EXCEPTION: CustomCircuitBreakerException = WsUnavailableException
}
}


Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import com.grepp.quizy.jwt.JwtValidator
import com.grepp.quizy.user.RedisTokenRepository
import com.grepp.quizy.user.UserId
import com.grepp.quizy.user.api.global.util.CookieUtils
import com.grepp.quizy.web.UserClient
import com.grepp.quizy.webclient.UserClient
import org.slf4j.LoggerFactory
import org.springframework.cloud.gateway.filter.GatewayFilterChain
import org.springframework.cloud.gateway.filter.GlobalFilter
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.grepp.quizy.webclient

import reactor.core.publisher.Mono

interface UserClient {
fun validateUser(userId: Long): Mono<Unit>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.grepp.quizy.webclient

import com.grepp.quizy.exception.CustomJwtException
import com.grepp.quizy.user.RedisTokenRepository
import com.grepp.quizy.user.UserId
import org.springframework.beans.factory.annotation.Value
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Component
import org.springframework.web.reactive.function.client.WebClient
import reactor.core.publisher.Mono

@Component
class UserClientImpl(
private val webClient: WebClient,
private val redisTokenRepository: RedisTokenRepository
) : UserClient {

@Value("\${service.user.url}")
private lateinit var BASE_URL: String


override fun validateUser(userId: Long): Mono<Unit> {
if (redisTokenRepository.isExistUser(UserId(userId))) {
return Mono.just(Unit)
}

return webClient.get()
.uri("$BASE_URL/api/internal/user/validate/$userId")
.retrieve()
.toEntity(Unit::class.java)
.handle<Unit> { response, sink ->
when (response.statusCode) {
HttpStatus.OK -> sink.next(Unit)
HttpStatus.UNAUTHORIZED -> sink.error(CustomJwtException.JwtUnknownException)
else -> sink.error(CustomJwtException.NotExistUserException)
}
}
}
}
Loading

0 comments on commit 176b8b9

Please sign in to comment.