diff --git a/src/main/kotlin/kr/galaxyhub/sc/auth/config/OAuth2WebClientConfig.kt b/src/main/kotlin/kr/galaxyhub/sc/auth/config/OAuth2WebClientConfig.kt index f65a421..fd4fd82 100644 --- a/src/main/kotlin/kr/galaxyhub/sc/auth/config/OAuth2WebClientConfig.kt +++ b/src/main/kotlin/kr/galaxyhub/sc/auth/config/OAuth2WebClientConfig.kt @@ -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 @@ -29,7 +30,8 @@ class OAuth2WebClientConfig( .build(), clientId = clientId, clientSecret = clientSecret, - redirectUri = redirectUri + redirectUri = redirectUri, + timeoutDuration = Duration.ofSeconds(5) ) } diff --git a/src/main/kotlin/kr/galaxyhub/sc/auth/infra/DiscordOAuth2Client.kt b/src/main/kotlin/kr/galaxyhub/sc/auth/infra/DiscordOAuth2Client.kt index e64ee39..769a50f 100644 --- a/src/main/kotlin/kr/galaxyhub/sc/auth/infra/DiscordOAuth2Client.kt +++ b/src/main/kotlin/kr/galaxyhub/sc/auth/infra/DiscordOAuth2Client.kt @@ -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 @@ -15,14 +17,18 @@ 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 { @@ -30,27 +36,17 @@ class DiscordOAuth2Client( .uri("/api/oauth2/token") .bodyValue(createAccessTokenBodyForm(code)) .retrieve() - .onStatus({ it.is4xxClientError || it.is5xxServerError }) { - throw handleAccessTokenError(it) - } + .onStatus({ it.isError }) { handleAccessTokenError(it) } .bodyToMono() + .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 { val multiValueMap = LinkedMultiValueMap() multiValueMap.add("client_id", clientId) @@ -61,16 +57,34 @@ class DiscordOAuth2Client( return multiValueMap } + private fun handleAccessTokenError(clientResponse: ClientResponse): Mono { + 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() + .handleConnectError("Discord OAuth2 서버와 연결 중 문제가 발생했습니다.") + .timeout(timeoutDuration, Mono.error { + log.info { TIMEOUT_MESSAGE } + InternalServerError(TIMEOUT_MESSAGE) + }) .block()!! .toUserInfo() } diff --git a/src/main/kotlin/kr/galaxyhub/sc/common/support/WebClientErrorHandler.kt b/src/main/kotlin/kr/galaxyhub/sc/common/support/WebClientErrorHandler.kt new file mode 100644 index 0000000..14540b0 --- /dev/null +++ b/src/main/kotlin/kr/galaxyhub/sc/common/support/WebClientErrorHandler.kt @@ -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 Mono.handleConnectError( + exMessage: String = "외부 서버와 연결 중 문제가 발생했습니다.", +): Mono { + return onErrorResume({ it !is GalaxyhubException }) { + log.error(it) { exMessage } + Mono.error(InternalServerError(exMessage)) + } +} diff --git a/src/main/kotlin/kr/galaxyhub/sc/translation/application/TranslationCommandService.kt b/src/main/kotlin/kr/galaxyhub/sc/translation/application/TranslationCommandService.kt index 79b247b..e6301f2 100644 --- a/src/main/kotlin/kr/galaxyhub/sc/translation/application/TranslationCommandService.kt +++ b/src/main/kotlin/kr/galaxyhub/sc/translation/application/TranslationCommandService.kt @@ -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 { diff --git a/src/main/kotlin/kr/galaxyhub/sc/translation/config/TranslatorClientConfig.kt b/src/main/kotlin/kr/galaxyhub/sc/translation/config/TranslatorClientConfig.kt index 3ae29df..68bcda6 100644 --- a/src/main/kotlin/kr/galaxyhub/sc/translation/config/TranslatorClientConfig.kt +++ b/src/main/kotlin/kr/galaxyhub/sc/translation/config/TranslatorClientConfig.kt @@ -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 @@ -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) ) } diff --git a/src/main/kotlin/kr/galaxyhub/sc/translation/infra/DeepLTranslatorClient.kt b/src/main/kotlin/kr/galaxyhub/sc/translation/infra/DeepLTranslatorClient.kt index 246a26d..d47ece0 100644 --- a/src/main/kotlin/kr/galaxyhub/sc/translation/infra/DeepLTranslatorClient.kt +++ b/src/main/kotlin/kr/galaxyhub/sc/translation/infra/DeepLTranslatorClient.kt @@ -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 @@ -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 { @@ -31,41 +33,35 @@ class DeepLTranslatorClient( .retrieve() .onStatus({ it.isError }) { log.info { "DeepL ErrorResponse=${it.bodyToMono().block()}" } // 에러 응답 확인용. 추후 불필요하면 삭제 - Mono.error(handleError(it)) + handleResponseError(it) } .bodyToMono() - .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 handleResponseError(clientResponse: ClientResponse): Mono { 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("번역기 서버에 일시적 문제가 발생했습니다.")) } } } diff --git a/src/test/kotlin/kr/galaxyhub/sc/auth/infra/DiscordOAuth2ClientTest.kt b/src/test/kotlin/kr/galaxyhub/sc/auth/infra/DiscordOAuth2ClientTest.kt index eaf90cc..d319cf1 100644 --- a/src/test/kotlin/kr/galaxyhub/sc/auth/infra/DiscordOAuth2ClientTest.kt +++ b/src/test/kotlin/kr/galaxyhub/sc/auth/infra/DiscordOAuth2ClientTest.kt @@ -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 @@ -13,7 +15,8 @@ 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() @@ -21,21 +24,23 @@ class DiscordOAuth2ClientTest : DescribeSpec({ .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 @@ -72,19 +77,51 @@ class DiscordOAuth2ClientTest : DescribeSpec({ ex shouldHaveMessage "Discord OAuth2 서버에 문제가 발생했습니다." } } + + context("외부 서버에 연결할 수 없으면") { + mockWebServer.shutdown() + + it("InternalServerError 예외를 던진다.") { + val ex = shouldThrow { 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 { delayClient.getAccessToken("code") } + ex shouldHaveMessage "Discord OAuth2 서버의 응답 시간이 초과되었습니다." + } + } } describe("getUserInfo") { + val response = DiscordUserInfoResponse( + id = "12345", + username = "seokjin8678", + email = "seokjin8678@email.com", + avatar = "avatar", + ) + context("외부 서버가 200 응답을 반환하면") { - val response = DiscordUserInfoResponse( - id = "12345", - username = "seokjin8678", - email = "seokjin8678@email.com", - avatar = "avatar", - ) mockWebServer.enqueue { statusCode(200) - body(objectMapper.writeValueAsString(response)) + body(response) } val actual = response.toUserInfo() @@ -116,6 +153,37 @@ class DiscordOAuth2ClientTest : DescribeSpec({ ex shouldHaveMessage "Discord OAuth2 서버에 문제가 발생했습니다." } } + + context("외부 서버에 연결할 수 없으면") { + mockWebServer.shutdown() + + it("InternalServerError 예외를 던진다.") { + val ex = shouldThrow { 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 { delayClient.getUserInfo("accessToken") } + ex shouldHaveMessage "Discord OAuth2 서버의 응답 시간이 초과되었습니다." + } + } } }) diff --git a/src/test/kotlin/kr/galaxyhub/sc/common/support/MockWebServerDsl.kt b/src/test/kotlin/kr/galaxyhub/sc/common/support/MockWebServerDsl.kt index 5438d31..064dd43 100644 --- a/src/test/kotlin/kr/galaxyhub/sc/common/support/MockWebServerDsl.kt +++ b/src/test/kotlin/kr/galaxyhub/sc/common/support/MockWebServerDsl.kt @@ -1,5 +1,7 @@ package kr.galaxyhub.sc.common.support +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import java.util.concurrent.TimeUnit import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import org.springframework.http.HttpHeaders @@ -10,23 +12,31 @@ class MockWebServerDsl { private var statusCode: Int? = null private var body: String? = null private var mediaType: String? = null + private var delay: Long? = null + private var timeUnit: TimeUnit? = null fun statusCode(statusCode: Int) { this.statusCode = statusCode } - fun body(body: String) { - this.body = body + fun body(body: Any) { + this.body = objectMapper.writeValueAsString(body) } fun mediaType(mediaType: MediaType) { this.mediaType = mediaType.toString() } + fun delay(delay: Long, timeUnit: TimeUnit) { + this.delay = delay + this.timeUnit = timeUnit + } + internal fun perform(mockWebServer: MockWebServer) { val response = MockResponse() statusCode?.also { response.setResponseCode(it) } body?.also { response.setBody(it) } + delay?.also { response.setBodyDelay(it, timeUnit!!) } if (mediaType != null) { response.addHeader(HttpHeaders.CONTENT_TYPE, mediaType!!) } else { @@ -34,6 +44,10 @@ class MockWebServerDsl { } mockWebServer.enqueue(response) } + + companion object { + val objectMapper = jacksonObjectMapper() + } } fun MockWebServer.enqueue(dsl: MockWebServerDsl.() -> Unit = {}) { diff --git a/src/test/kotlin/kr/galaxyhub/sc/translation/infra/DeepLTranslatorClientTest.kt b/src/test/kotlin/kr/galaxyhub/sc/translation/infra/DeepLTranslatorClientTest.kt index 8851265..c39d5e1 100644 --- a/src/test/kotlin/kr/galaxyhub/sc/translation/infra/DeepLTranslatorClientTest.kt +++ b/src/test/kotlin/kr/galaxyhub/sc/translation/infra/DeepLTranslatorClientTest.kt @@ -1,12 +1,13 @@ package kr.galaxyhub.sc.translation.infra -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import io.kotest.assertions.assertSoftly 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 @@ -20,35 +21,36 @@ class DeepLTranslatorClientTest : DescribeSpec({ isolationMode = IsolationMode.InstancePerLeaf - val objectMapper = jacksonObjectMapper() val mockWebServer = MockWebServer() val deepLTranslatorClient = DeepLTranslatorClient( webClient = WebClient.builder() .baseUrl("${mockWebServer.url("/")}") - .build() + .build(), + timeoutDuration = Duration.ofSeconds(10) ) describe("requestTranslate") { - context("외부 서버가 200 응답을 반환하면") { - val response = DeepLResponse( - listOf( - DeepLSentenceResponse( - detectedSourceLanguage = "EN", - text = "제목입니다." - ), - DeepLSentenceResponse( - detectedSourceLanguage = "EN", - text = "발췌입니다." - ), - DeepLSentenceResponse( - detectedSourceLanguage = "EN", - text = "내용입니다." - ) + val response = DeepLResponse( + listOf( + DeepLSentenceResponse( + detectedSourceLanguage = "EN", + text = "제목입니다." + ), + DeepLSentenceResponse( + detectedSourceLanguage = "EN", + text = "발췌입니다." + ), + DeepLSentenceResponse( + detectedSourceLanguage = "EN", + text = "내용입니다." ) ) + ) + + context("외부 서버가 200 응답을 반환하면") { mockWebServer.enqueue { statusCode(200) - body(objectMapper.writeValueAsString(response)) + body(response) } val expect = deepLTranslatorClient.requestTranslate(ContentFixture.create(), Language.KOREAN).block()!! @@ -107,7 +109,29 @@ class DeepLTranslatorClientTest : DescribeSpec({ it("InternalServerError 예외를 던진다.") { val ex = shouldThrow { expect.block() } - ex shouldHaveMessage "번역기 서버에 연결할 수 없습니다." + ex shouldHaveMessage "DeepL 서버와 연결 중 문제가 발생했습니다." + } + } + + context("외부 서버에 지연이 발생하면") { + val delayClient = DeepLTranslatorClient( + webClient = WebClient.builder() + .baseUrl("${mockWebServer.url("/")}") + .build(), + timeoutDuration = Duration.ofMillis(100) + ) + + mockWebServer.enqueue { + statusCode(200) + body(response) + delay(1, TimeUnit.SECONDS) + } + + val expect = delayClient.requestTranslate(ContentFixture.create(), Language.KOREAN) + + it("InternalServerError 예외를 던진다.") { + val ex = shouldThrow { expect.block() } + ex shouldHaveMessage "DeepL 서버의 응답 시간이 초과되었습니다." } } }