Skip to content

Commit

Permalink
feat: WebClient 예외 핸들링 리팩터링, Timeout 예외 핸들링 추가 (#60) (#61)
Browse files Browse the repository at this point in the history
  • Loading branch information
seokjin8678 authored Dec 30, 2023
1 parent d5d8c1a commit 3a86a87
Show file tree
Hide file tree
Showing 9 changed files with 215 additions and 79 deletions.
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package kr.galaxyhub.sc.auth.config

import java.time.Duration
import kr.galaxyhub.sc.auth.domain.OAuth2Client
import kr.galaxyhub.sc.auth.domain.OAuth2Clients
import kr.galaxyhub.sc.auth.infra.DiscordOAuth2Client
Expand Down Expand Up @@ -29,7 +30,8 @@ class OAuth2WebClientConfig(
.build(),
clientId = clientId,
clientSecret = clientSecret,
redirectUri = redirectUri
redirectUri = redirectUri,
timeoutDuration = Duration.ofSeconds(5)
)
}

Expand Down
52 changes: 33 additions & 19 deletions src/main/kotlin/kr/galaxyhub/sc/auth/infra/DiscordOAuth2Client.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ package kr.galaxyhub.sc.auth.infra
import com.fasterxml.jackson.databind.PropertyNamingStrategies
import com.fasterxml.jackson.databind.annotation.JsonNaming
import io.github.oshai.kotlinlogging.KotlinLogging
import java.time.Duration
import kr.galaxyhub.sc.auth.domain.OAuth2Client
import kr.galaxyhub.sc.common.exception.BadRequestException
import kr.galaxyhub.sc.common.exception.InternalServerError
import kr.galaxyhub.sc.common.support.handleConnectError
import kr.galaxyhub.sc.member.domain.SocialType
import kr.galaxyhub.sc.member.domain.UserInfo
import org.springframework.http.HttpHeaders
Expand All @@ -15,42 +17,36 @@ import org.springframework.util.MultiValueMap
import org.springframework.web.reactive.function.client.ClientResponse
import org.springframework.web.reactive.function.client.WebClient
import org.springframework.web.reactive.function.client.bodyToMono
import reactor.core.publisher.Mono

private val log = KotlinLogging.logger {}

private const val TIMEOUT_MESSAGE = "Discord OAuth2 서버의 응답 시간이 초과되었습니다."

class DiscordOAuth2Client(
private val webClient: WebClient,
private val clientId: String,
private val clientSecret: String,
private val redirectUri: String,
private val timeoutDuration: Duration,
) : OAuth2Client {

override fun getAccessToken(code: String): String {
return webClient.post()
.uri("/api/oauth2/token")
.bodyValue(createAccessTokenBodyForm(code))
.retrieve()
.onStatus({ it.is4xxClientError || it.is5xxServerError }) {
throw handleAccessTokenError(it)
}
.onStatus({ it.isError }) { handleAccessTokenError(it) }
.bodyToMono<DiscordAccessTokenResponse>()
.handleConnectError("Discord OAuth2 서버와 연결 중 문제가 발생했습니다.")
.timeout(timeoutDuration, Mono.error {
log.info { TIMEOUT_MESSAGE }
InternalServerError(TIMEOUT_MESSAGE)
})
.block()!!
.accessToken
}

private fun handleAccessTokenError(clientResponse: ClientResponse): Exception {
return when (clientResponse.statusCode()) {
HttpStatus.UNAUTHORIZED -> {
BadRequestException("잘못된 OAuth2 Authorization 코드입니다.")
}

else -> {
log.warn { "getAccessToken() 호출에서 ${clientResponse.statusCode()} 예외가 발생했습니다." }
InternalServerError("Discord OAuth2 서버에 문제가 발생했습니다.")
}
}
}

private fun createAccessTokenBodyForm(code: String): MultiValueMap<String, String> {
val multiValueMap = LinkedMultiValueMap<String, String>()
multiValueMap.add("client_id", clientId)
Expand All @@ -61,16 +57,34 @@ class DiscordOAuth2Client(
return multiValueMap
}

private fun <T> handleAccessTokenError(clientResponse: ClientResponse): Mono<T> {
return when (clientResponse.statusCode()) {
HttpStatus.UNAUTHORIZED -> {
Mono.error(BadRequestException("잘못된 OAuth2 Authorization 코드입니다."))
}

else -> {
log.error { "getAccessToken() 호출에서 ${clientResponse.statusCode()} 예외가 발생했습니다." }
Mono.error(InternalServerError("Discord OAuth2 서버에 문제가 발생했습니다."))
}
}
}

override fun getUserInfo(accessToken: String): UserInfo {
return webClient.get()
.uri("/api/users/@me")
.header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken")
.retrieve()
.onStatus({ it.is4xxClientError || it.is5xxServerError }) {
log.warn { "getUserInfo() 호출에서 ${it.statusCode()} 예외가 발생했습니다." }
throw InternalServerError("Discord OAuth2 서버에 문제가 발생했습니다.")
.onStatus({ it.isError }) {
log.error { "getUserInfo() 호출에서 ${it.statusCode()} 예외가 발생했습니다." }
Mono.error(InternalServerError("Discord OAuth2 서버에 문제가 발생했습니다."))
}
.bodyToMono<DiscordUserInfoResponse>()
.handleConnectError("Discord OAuth2 서버와 연결 중 문제가 발생했습니다.")
.timeout(timeoutDuration, Mono.error {
log.info { TIMEOUT_MESSAGE }
InternalServerError(TIMEOUT_MESSAGE)
})
.block()!!
.toUserInfo()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package kr.galaxyhub.sc.common.support

import io.github.oshai.kotlinlogging.KotlinLogging
import kr.galaxyhub.sc.common.exception.GalaxyhubException
import kr.galaxyhub.sc.common.exception.InternalServerError
import reactor.core.publisher.Mono

private val log = KotlinLogging.logger {}

fun <T> Mono<T>.handleConnectError(
exMessage: String = "외부 서버와 연결 중 문제가 발생했습니다.",
): Mono<T> {
return onErrorResume({ it !is GalaxyhubException }) {
log.error(it) { exMessage }
Mono.error(InternalServerError(exMessage))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ class TranslationCommandService(
val translatorClient = translatorClients.getClient(translatorProvider)
translatorClient.requestTranslate(content, targetLanguage)
.doOnError {
log.warn { "뉴스 번역 요청이 실패하였습니다. newsId=${newsId}, translateProgressionId=${translateProgression.id}" }
log.info { "뉴스 번역 요청이 실패하였습니다. newsId=${newsId}, translateProgressionId=${translateProgression.id} cause=${it.message}" }
eventPublisher.publishEvent(TranslatorFailureEvent(translateProgression.id, it.message))
}
.subscribe {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package kr.galaxyhub.sc.translation.config

import java.time.Duration
import kr.galaxyhub.sc.translation.domain.TranslatorClient
import kr.galaxyhub.sc.translation.domain.TranslatorClients
import kr.galaxyhub.sc.translation.infra.DeepLTranslatorClient
Expand All @@ -23,7 +24,7 @@ class TranslatorClientConfig(
webClient.mutate()
.baseUrl("https://api.deepl.com")
.defaultHeader(HttpHeaders.AUTHORIZATION, "DeepL-Auth-Key $deepLApiKey")
.build()
.build(), Duration.ofSeconds(5)
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@ package kr.galaxyhub.sc.translation.infra
import com.fasterxml.jackson.databind.PropertyNamingStrategies
import com.fasterxml.jackson.databind.annotation.JsonNaming
import io.github.oshai.kotlinlogging.KotlinLogging
import java.time.Duration
import java.util.UUID
import kr.galaxyhub.sc.common.exception.BadRequestException
import kr.galaxyhub.sc.common.exception.GalaxyhubException
import kr.galaxyhub.sc.common.exception.InternalServerError
import kr.galaxyhub.sc.common.support.handleConnectError
import kr.galaxyhub.sc.news.domain.Content
import kr.galaxyhub.sc.news.domain.Language
import kr.galaxyhub.sc.news.domain.NewsInformation
Expand All @@ -22,6 +23,7 @@ private val log = KotlinLogging.logger {}

class DeepLTranslatorClient(
private val webClient: WebClient,
private val timeoutDuration: Duration,
) : TranslatorClient {

override fun requestTranslate(content: Content, targetLanguage: Language): Mono<Content> {
Expand All @@ -31,41 +33,35 @@ class DeepLTranslatorClient(
.retrieve()
.onStatus({ it.isError }) {
log.info { "DeepL ErrorResponse=${it.bodyToMono<String>().block()}" } // 에러 응답 확인용. 추후 불필요하면 삭제
Mono.error(handleError(it))
handleResponseError(it)
}
.bodyToMono<DeepLResponse>()
.onErrorResume { Mono.error(handleConnectError(it)) }
.handleConnectError("DeepL 서버와 연결 중 문제가 발생했습니다.")
.timeout(timeoutDuration, Mono.error {
log.info { "DeepL 서버의 응답 시간이 초과되었습니다." }
InternalServerError("DeepL 서버의 응답 시간이 초과되었습니다.")
})
.map { it.toContent(content.newsId, targetLanguage) }
}

/**
* https://www.deepl.com/ko/docs-api/api-access/error-handling
*/
private fun handleError(clientResponse: ClientResponse): Exception {
private fun <T> handleResponseError(clientResponse: ClientResponse): Mono<T> {
val statusCode = clientResponse.statusCode()
return when (statusCode.value()) {
HttpStatus.TOO_MANY_REQUESTS.value() -> {
BadRequestException("단기간에 너무 많은 요청을 보냈습니다.")
Mono.error(BadRequestException("단기간에 너무 많은 요청을 보냈습니다."))
}

456 -> {
log.error { "DeepL 할당량이 초과되었습니다." }
InternalServerError("할당량이 초과되었습니다. 관리자에게 문의하세요.")
Mono.error(InternalServerError("할당량이 초과되었습니다. 관리자에게 문의하세요."))
}

else -> {
log.warn { "DeepL 서버에 일시적 문제가 발생했습니다." }
InternalServerError("번역기 서버에 일시적 문제가 발생했습니다.")
}
}
}

private fun handleConnectError(ex: Throwable): Exception {
return when (ex) {
is GalaxyhubException -> ex
else -> {
log.error(ex) { "DeepL 서버에 연결할 수 없습니다." }
InternalServerError("번역기 서버에 연결할 수 없습니다.")
Mono.error(InternalServerError("번역기 서버에 일시적 문제가 발생했습니다."))
}
}
}
Expand Down
104 changes: 86 additions & 18 deletions src/test/kotlin/kr/galaxyhub/sc/auth/infra/DiscordOAuth2ClientTest.kt
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package kr.galaxyhub.sc.auth.infra

import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.IsolationMode
import io.kotest.core.spec.style.DescribeSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.throwable.shouldHaveMessage
import java.time.Duration
import java.util.concurrent.TimeUnit
import kr.galaxyhub.sc.common.exception.BadRequestException
import kr.galaxyhub.sc.common.exception.InternalServerError
import kr.galaxyhub.sc.common.support.enqueue
Expand All @@ -13,29 +15,32 @@ import org.springframework.web.reactive.function.client.WebClient

class DiscordOAuth2ClientTest : DescribeSpec({

val objectMapper = jacksonObjectMapper()
isolationMode = IsolationMode.InstancePerLeaf

val mockWebServer = MockWebServer()
val discordOAuth2Client = DiscordOAuth2Client(
webClient = WebClient.builder()
.baseUrl("${mockWebServer.url("/")}")
.build(),
clientId = "client_id",
clientSecret = "client_secret",
redirectUri = "https://sc.galaxyhub.kr"
redirectUri = "https://sc.galaxyhub.kr",
timeoutDuration = Duration.ofSeconds(10)
)

describe("getAccessToken") {
val response = DiscordAccessTokenResponse(
accessToken = "123123",
tokenType = "Bearer",
expiresIn = 3000,
refreshToken = "321321",
scope = "email"
)

context("외부 서버가 200 응답을 반환하면") {
val response = DiscordAccessTokenResponse(
accessToken = "123123",
tokenType = "Bearer",
expiresIn = 3000,
refreshToken = "321321",
scope = "email"
)
mockWebServer.enqueue {
statusCode(200)
body(objectMapper.writeValueAsString(response))
body(response)
}

val actual = response.accessToken
Expand Down Expand Up @@ -72,19 +77,51 @@ class DiscordOAuth2ClientTest : DescribeSpec({
ex shouldHaveMessage "Discord OAuth2 서버에 문제가 발생했습니다."
}
}

context("외부 서버에 연결할 수 없으면") {
mockWebServer.shutdown()

it("InternalServerError 예외를 던진다.") {
val ex = shouldThrow<InternalServerError> { discordOAuth2Client.getAccessToken("code") }
ex shouldHaveMessage "Discord OAuth2 서버와 연결 중 문제가 발생했습니다."
}
}

context("외부 서버에 지연이 발생하면") {
val delayClient = DiscordOAuth2Client(
webClient = WebClient.builder()
.baseUrl("${mockWebServer.url("/")}")
.build(),
clientId = "client_id",
clientSecret = "client_secret",
redirectUri = "https://sc.galaxyhub.kr",
timeoutDuration = Duration.ofMillis(100)
)
mockWebServer.enqueue {
statusCode(200)
body(response)
delay(10, TimeUnit.SECONDS)
}

it("InternalServerError 예외를 던진다.") {
val ex = shouldThrow<InternalServerError> { delayClient.getAccessToken("code") }
ex shouldHaveMessage "Discord OAuth2 서버의 응답 시간이 초과되었습니다."
}
}
}

describe("getUserInfo") {
val response = DiscordUserInfoResponse(
id = "12345",
username = "seokjin8678",
email = "[email protected]",
avatar = "avatar",
)

context("외부 서버가 200 응답을 반환하면") {
val response = DiscordUserInfoResponse(
id = "12345",
username = "seokjin8678",
email = "[email protected]",
avatar = "avatar",
)
mockWebServer.enqueue {
statusCode(200)
body(objectMapper.writeValueAsString(response))
body(response)
}

val actual = response.toUserInfo()
Expand Down Expand Up @@ -116,6 +153,37 @@ class DiscordOAuth2ClientTest : DescribeSpec({
ex shouldHaveMessage "Discord OAuth2 서버에 문제가 발생했습니다."
}
}

context("외부 서버에 연결할 수 없으면") {
mockWebServer.shutdown()

it("InternalServerError 예외를 던진다.") {
val ex = shouldThrow<InternalServerError> { discordOAuth2Client.getUserInfo("accessToken") }
ex shouldHaveMessage "Discord OAuth2 서버와 연결 중 문제가 발생했습니다."
}
}

context("외부 서버에 지연이 발생하면") {
val delayClient = DiscordOAuth2Client(
webClient = WebClient.builder()
.baseUrl("${mockWebServer.url("/")}")
.build(),
clientId = "client_id",
clientSecret = "client_secret",
redirectUri = "https://sc.galaxyhub.kr",
timeoutDuration = Duration.ofMillis(100)
)
mockWebServer.enqueue {
statusCode(200)
body(response)
delay(10, TimeUnit.SECONDS)
}

it("InternalServerError 예외를 던진다.") {
val ex = shouldThrow<InternalServerError> { delayClient.getUserInfo("accessToken") }
ex shouldHaveMessage "Discord OAuth2 서버의 응답 시간이 초과되었습니다."
}
}
}
})

Loading

0 comments on commit 3a86a87

Please sign in to comment.