Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: WebClient 예외 핸들링 리팩터링, Timeout 예외 핸들링 추가 (#60) #61

Merged
merged 3 commits into from
Dec 30, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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