From 856f61dca26f8972565ca57530b4d6bf7153019b Mon Sep 17 00:00:00 2001 From: juhwankim-dev Date: Mon, 21 Sep 2026 01:38:15 +0900 Subject: [PATCH 1/6] =?UTF-8?q?NR-172=20401=20=EC=9D=91=EB=8B=B5=EC=9D=84?= =?UTF-8?q?=20Unauthorized=20=EB=A1=9C=20=EB=A7=A4=ED=95=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 401 은 매핑이 없어 UnknownError 로 떨어졌다. 리프레시 토큰이 거절된 것인지 네트워크 오류인지 구분할 수 없어 AuthAuthenticator 가 실패 원인에 따라 다르게 대응할 수 없었다. HttpError.Unauthorized 를 추가하고 401 을 매핑한다. Co-Authored-By: Claude Opus 5 --- .../nextroom/data/network/ResultCallAdapter.kt | 1 + .../java/com/nextroom/nextroom/domain/model/Result.kt | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/data/src/main/java/com/nextroom/nextroom/data/network/ResultCallAdapter.kt b/data/src/main/java/com/nextroom/nextroom/data/network/ResultCallAdapter.kt index fd113c96..2219d072 100644 --- a/data/src/main/java/com/nextroom/nextroom/data/network/ResultCallAdapter.kt +++ b/data/src/main/java/com/nextroom/nextroom/data/network/ResultCallAdapter.kt @@ -69,6 +69,7 @@ private class ApiResultCall( val message = errorBody?.getString("message") ?: "" when (code) { "400" -> Result.Failure.HttpError.BadRequest(message) + "401" -> Result.Failure.HttpError.Unauthorized(message) "403" -> Result.Failure.HttpError.Forbidden(message) "404" -> Result.Failure.HttpError.NotFound(message) "409" -> Result.Failure.HttpError.Conflict(message) diff --git a/domain/src/main/java/com/nextroom/nextroom/domain/model/Result.kt b/domain/src/main/java/com/nextroom/nextroom/domain/model/Result.kt index 109b321b..d031b338 100644 --- a/domain/src/main/java/com/nextroom/nextroom/domain/model/Result.kt +++ b/domain/src/main/java/com/nextroom/nextroom/domain/model/Result.kt @@ -24,6 +24,16 @@ sealed interface Result { override val code = 400 } + /** + * ## 인증 실패 + * + * 액세스 토큰이 만료됐거나, 리프레시 토큰이 더 이상 유효하지 않은 경우. + * 서버가 세션을 거절했다는 뜻이므로 재시도로는 회복되지 않는다. + */ + data class Unauthorized(override val message: String) : HttpError { + override val code = 401 + } + /** * ## 접근 권한 에러 * From 488251c8c0cc3117ef94d7d291aba122700a88b9 Mon Sep 17 00:00:00 2001 From: juhwankim-dev Date: Mon, 21 Sep 2026 01:39:02 +0900 Subject: [PATCH 2/6] =?UTF-8?q?NR-172=20=ED=86=A0=ED=81=B0=20=EC=9E=AC?= =?UTF-8?q?=EB=B0=9C=EA=B8=89=20=EC=9D=BC=EC=8B=9C=EC=A0=81=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=20=EC=8B=9C=20=EB=A1=9C=EA=B7=B8=EC=95=84=EC=9B=83?= =?UTF-8?q?=EB=90=98=EB=8D=98=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refreshToken 이 어떤 이유로든 실패하면 무조건 logout 하고 있었다. 네트워크 끊김이나 서버 장애처럼 리프레시 토큰이 멀쩡한 경우에도 잠깐의 오류로 재로그인을 요구하게 된다. 서버가 세션을 거절한 401/400 에만 로그아웃하고, 그 외에는 해당 요청만 실패시켜 다음 요청에서 다시 재발급을 시도하게 둔다. Co-Authored-By: Claude Opus 5 --- .../data/network/AuthAuthenticator.kt | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/data/src/main/java/com/nextroom/nextroom/data/network/AuthAuthenticator.kt b/data/src/main/java/com/nextroom/nextroom/data/network/AuthAuthenticator.kt index 9d98ca03..4b880d88 100644 --- a/data/src/main/java/com/nextroom/nextroom/data/network/AuthAuthenticator.kt +++ b/data/src/main/java/com/nextroom/nextroom/data/network/AuthAuthenticator.kt @@ -2,6 +2,7 @@ package com.nextroom.nextroom.data.network import com.nextroom.nextroom.data.datasource.AuthDataSource import com.nextroom.nextroom.data.datasource.TokenDataSource +import com.nextroom.nextroom.domain.model.Result import com.nextroom.nextroom.domain.model.mapOnSuccess import com.nextroom.nextroom.domain.model.onFailure import com.nextroom.nextroom.domain.model.onSuccess @@ -20,7 +21,7 @@ class AuthAuthenticator @Inject constructor( private val apiService: ApiService, ) : Authenticator { override fun authenticate(route: Route?, response: Response): Request? { - Timber.tag("MANGBAAM-AuthAuthenticator(authenticate)").d("Access Token EXPIRED!! try refresh...") + Timber.tag("AuthAuthenticator").d("Access Token EXPIRED!! try refresh...") return runBlocking { val tokenRequest: TokenRefreshRequest = run { val (access, refresh) = tokenDataSource.getTokenPair() @@ -29,15 +30,30 @@ class AuthAuthenticator @Inject constructor( apiService.refreshToken(tokenRequest) .onSuccess { newToken -> tokenDataSource.saveTokens(newToken.data.accessToken, newToken.data.refreshToken) } .mapOnSuccess { newToken -> - Timber.tag("MANGBAAM-AuthAuthenticator").d("Refresh Token Success: $newToken") + Timber.tag("AuthAuthenticator").d("Refresh Token Success: $newToken") return@mapOnSuccess response.request.newBuilder() .header("Authorization", "Bearer ${newToken.data.accessToken}") .build() - }.onFailure { - Timber.tag("MANGBAAM-AuthAuthenticator").d("Refresh Token EXPIRED!!: $it") - authDataSource.logout() - authDataSource.emitRefreshTokenExpired() + }.onFailure { failure -> + if (failure.isSessionRejected()) { + Timber.tag("AuthAuthenticator").d("Refresh Token REJECTED!!: $failure") + authDataSource.logout() + authDataSource.emitRefreshTokenExpired() + } else { + Timber.tag("AuthAuthenticator").d("Refresh 일시적 실패, 세션 유지: $failure") + } }.getOrNull } } + + /** + * 서버가 세션 자체를 거절했는지 여부. + * + * 401/400 은 리프레시 토큰이 더 이상 유효하지 않다는 서버의 답이므로 로그아웃한다. + * 반면 네트워크 오류나 서버 장애는 리프레시 토큰이 멀쩡한데 잠깐 닿지 못한 것뿐이다. + * 이 경우까지 로그아웃하면 잠깐의 끊김 때문에 매번 재로그인을 요구하게 되므로, + * 해당 요청만 실패시키고 다음 요청에서 다시 재발급을 시도하게 둔다. + */ + private fun Result.Failure.isSessionRejected(): Boolean = + this is Result.Failure.HttpError && (code == 401 || code == 400) } From 8c5fb29dd34cf25f7f09671ed11b6cf493a14bc6 Mon Sep 17 00:00:00 2001 From: juhwankim-dev Date: Mon, 21 Sep 2026 01:39:11 +0900 Subject: [PATCH 3/6] =?UTF-8?q?NR-172=20=ED=86=A0=ED=81=B0=20=EC=9E=AC?= =?UTF-8?q?=EB=B0=9C=EA=B8=89=20=EB=8F=99=EC=8B=9C=20=EC=9A=94=EC=B2=AD=20?= =?UTF-8?q?=EC=A7=81=EB=A0=AC=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 액세스 토큰 만료 시점에 요청이 여러 개 떠 있으면 모두 401 을 받고 각자 재발급을 요청한다. 서버가 리프레시 토큰을 한 번 쓰고 폐기하므로 먼저 도착한 하나만 성공하고 나머지는 무효해진 토큰으로 401 을 받아, 재발급에 성공했는데도 세션이 날아갔다. Mutex 로 재발급을 한 번에 하나만 수행하고, 잠금을 기다리는 동안 다른 요청이 이미 갱신했다면 재발급 없이 새 토큰으로 재시도만 한다. 로그아웃 직후처럼 리프레시 토큰이 없으면 재발급하지 않는다. Co-Authored-By: Claude Opus 5 --- .../data/network/AuthAuthenticator.kt | 74 ++++++++++++++----- 1 file changed, 56 insertions(+), 18 deletions(-) diff --git a/data/src/main/java/com/nextroom/nextroom/data/network/AuthAuthenticator.kt b/data/src/main/java/com/nextroom/nextroom/data/network/AuthAuthenticator.kt index 4b880d88..1c954a07 100644 --- a/data/src/main/java/com/nextroom/nextroom/data/network/AuthAuthenticator.kt +++ b/data/src/main/java/com/nextroom/nextroom/data/network/AuthAuthenticator.kt @@ -8,6 +8,8 @@ import com.nextroom.nextroom.domain.model.onFailure import com.nextroom.nextroom.domain.model.onSuccess import com.nextroom.nextroom.domain.request.TokenRefreshRequest import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import okhttp3.Authenticator import okhttp3.Request import okhttp3.Response @@ -20,32 +22,63 @@ class AuthAuthenticator @Inject constructor( private val authDataSource: AuthDataSource, private val apiService: ApiService, ) : Authenticator { + + /** + * 재발급을 한 번에 하나만 수행하기 위한 잠금. + * + * 액세스 토큰이 만료된 시점에 요청이 여러 개 떠 있으면 모두 401 을 받고 + * 각자 [authenticate] 로 들어온다. 잠금이 없으면 모두 같은 리프레시 토큰으로 + * 재발급을 요청하는데, 서버가 리프레시 토큰을 한 번 쓰고 폐기하는 방식이면 + * 먼저 도착한 하나만 성공하고 나머지는 이미 무효해진 토큰을 내밀어 401 을 받는다. + * 그 결과 재발급에 성공했는데도 세션이 날아간다. + */ + private val refreshMutex = Mutex() + override fun authenticate(route: Route?, response: Response): Request? { Timber.tag("AuthAuthenticator").d("Access Token EXPIRED!! try refresh...") return runBlocking { - val tokenRequest: TokenRefreshRequest = run { + refreshMutex.withLock { val (access, refresh) = tokenDataSource.getTokenPair() - TokenRefreshRequest(accessToken = access, refreshToken = refresh) + + // 잠금을 기다리는 동안 다른 요청이 이미 갱신했다면 재발급 없이 새 토큰으로 재시도만 한다. + if (access.isNotEmpty() && access != response.usedAccessToken()) { + Timber.tag("AuthAuthenticator").d("이미 갱신된 토큰이 있어 재발급을 생략한다") + return@withLock response.retryWith(access) + } + + // 로그아웃 직후처럼 리프레시 토큰이 없으면 재발급할 것이 없다. + if (refresh.isEmpty()) { + Timber.tag("AuthAuthenticator").d("리프레시 토큰이 없어 재발급하지 않는다") + return@withLock null + } + + apiService.refreshToken(TokenRefreshRequest(accessToken = access, refreshToken = refresh)) + .onSuccess { newToken -> tokenDataSource.saveTokens(newToken.data.accessToken, newToken.data.refreshToken) } + .mapOnSuccess { newToken -> + Timber.tag("AuthAuthenticator").d("Refresh Token Success: $newToken") + return@mapOnSuccess response.retryWith(newToken.data.accessToken) + }.onFailure { failure -> + if (failure.isSessionRejected()) { + Timber.tag("AuthAuthenticator").d("Refresh Token REJECTED!!: $failure") + authDataSource.logout() + authDataSource.emitRefreshTokenExpired() + } else { + Timber.tag("AuthAuthenticator").d("Refresh 일시적 실패, 세션 유지: $failure") + } + }.getOrNull } - apiService.refreshToken(tokenRequest) - .onSuccess { newToken -> tokenDataSource.saveTokens(newToken.data.accessToken, newToken.data.refreshToken) } - .mapOnSuccess { newToken -> - Timber.tag("AuthAuthenticator").d("Refresh Token Success: $newToken") - return@mapOnSuccess response.request.newBuilder() - .header("Authorization", "Bearer ${newToken.data.accessToken}") - .build() - }.onFailure { failure -> - if (failure.isSessionRejected()) { - Timber.tag("AuthAuthenticator").d("Refresh Token REJECTED!!: $failure") - authDataSource.logout() - authDataSource.emitRefreshTokenExpired() - } else { - Timber.tag("AuthAuthenticator").d("Refresh 일시적 실패, 세션 유지: $failure") - } - }.getOrNull } } + /** 401 을 받은 요청이 실제로 달고 나갔던 액세스 토큰. */ + private fun Response.usedAccessToken(): String? = + request.header(AUTHORIZATION)?.removePrefix(BEARER_PREFIX) + + private fun Response.retryWith(accessToken: String): Request = + request.newBuilder() + .header(AUTHORIZATION, "$BEARER_PREFIX$accessToken") + .build() + /** * 서버가 세션 자체를 거절했는지 여부. * @@ -56,4 +89,9 @@ class AuthAuthenticator @Inject constructor( */ private fun Result.Failure.isSessionRejected(): Boolean = this is Result.Failure.HttpError && (code == 401 || code == 400) + + companion object { + private const val AUTHORIZATION = "Authorization" + private const val BEARER_PREFIX = "Bearer " + } } From f8eda83a142bb61d163bad02cfee950d66203378 Mon Sep 17 00:00:00 2001 From: juhwankim-dev Date: Mon, 21 Sep 2026 01:56:08 +0900 Subject: [PATCH 4/6] =?UTF-8?q?NR-172=20=ED=86=A0=ED=81=B0=20=EC=9E=AC?= =?UTF-8?q?=EB=B0=9C=EA=B8=89=20=EB=A1=9C=EA=B7=B8=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=20=EA=B0=92=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 --- .../com/nextroom/nextroom/data/network/AuthAuthenticator.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data/src/main/java/com/nextroom/nextroom/data/network/AuthAuthenticator.kt b/data/src/main/java/com/nextroom/nextroom/data/network/AuthAuthenticator.kt index 1c954a07..91356ec1 100644 --- a/data/src/main/java/com/nextroom/nextroom/data/network/AuthAuthenticator.kt +++ b/data/src/main/java/com/nextroom/nextroom/data/network/AuthAuthenticator.kt @@ -55,7 +55,7 @@ class AuthAuthenticator @Inject constructor( apiService.refreshToken(TokenRefreshRequest(accessToken = access, refreshToken = refresh)) .onSuccess { newToken -> tokenDataSource.saveTokens(newToken.data.accessToken, newToken.data.refreshToken) } .mapOnSuccess { newToken -> - Timber.tag("AuthAuthenticator").d("Refresh Token Success: $newToken") + Timber.tag("AuthAuthenticator").d("Refresh Token Success (expiresIn=${newToken.data.accessTokenExpiresIn})") return@mapOnSuccess response.retryWith(newToken.data.accessToken) }.onFailure { failure -> if (failure.isSessionRejected()) { From a723d7f760d2d0d09f737710b201c913bcda6c21 Mon Sep 17 00:00:00 2001 From: juhwankim-dev Date: Thu, 24 Sep 2026 19:21:12 +0900 Subject: [PATCH 5/6] =?UTF-8?q?NR-172=20=EC=84=B8=EC=85=98=20=EA=B1=B0?= =?UTF-8?q?=EC=A0=88=20=ED=8C=90=EC=A0=95=EC=9D=84=20401=20=EB=A1=9C=20?= =?UTF-8?q?=ED=95=9C=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 재발급 실패 시 400 도 세션 거절로 보고 로그아웃하고 있었다. 서버는 리프레시 토큰이 유효하지 않으면 INVALID_REFRESH_TOKEN 을 401 로 응답하고, 400 은 요청 본문을 읽지 못했을 때만 내려온다. 세션과 무관한 400 에 로그아웃하면 재로그인 없이는 회복할 수 없으므로 401 에만 로그아웃한다. 앞선 커밋(488251c)의 "401/400 에만 로그아웃" 설명은 이 커밋으로 대체된다. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016jjvbpYErstycWhkxoX8Xh --- .../nextroom/data/network/AuthAuthenticator.kt | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/data/src/main/java/com/nextroom/nextroom/data/network/AuthAuthenticator.kt b/data/src/main/java/com/nextroom/nextroom/data/network/AuthAuthenticator.kt index 91356ec1..c1509bb1 100644 --- a/data/src/main/java/com/nextroom/nextroom/data/network/AuthAuthenticator.kt +++ b/data/src/main/java/com/nextroom/nextroom/data/network/AuthAuthenticator.kt @@ -82,13 +82,14 @@ class AuthAuthenticator @Inject constructor( /** * 서버가 세션 자체를 거절했는지 여부. * - * 401/400 은 리프레시 토큰이 더 이상 유효하지 않다는 서버의 답이므로 로그아웃한다. - * 반면 네트워크 오류나 서버 장애는 리프레시 토큰이 멀쩡한데 잠깐 닿지 못한 것뿐이다. - * 이 경우까지 로그아웃하면 잠깐의 끊김 때문에 매번 재로그인을 요구하게 되므로, - * 해당 요청만 실패시키고 다음 요청에서 다시 재발급을 시도하게 둔다. + * 서버는 리프레시 토큰이 유효하지 않을 때만 401 을 준다(만료/서명 불일치/폐기 포함). + * 400 은 요청 본문을 읽지 못했다는 뜻이고 5xx 는 서버 장애라, 둘 다 리프레시 토큰은 + * 멀쩡한데 잠깐 실패한 것뿐이다. 네트워크 오류도 마찬가지다. 이 경우까지 로그아웃하면 + * 일시적 오류 때문에 매번 재로그인을 요구하게 되므로, 해당 요청만 실패시키고 + * 다음 요청에서 다시 재발급을 시도하게 둔다. */ private fun Result.Failure.isSessionRejected(): Boolean = - this is Result.Failure.HttpError && (code == 401 || code == 400) + this is Result.Failure.HttpError && code == 401 companion object { private const val AUTHORIZATION = "Authorization" From 78a9f77b78690a87c025f7a7b700347b5a6aa16a Mon Sep 17 00:00:00 2001 From: juhwankim-dev Date: Thu, 24 Sep 2026 21:02:17 +0900 Subject: [PATCH 6/6] =?UTF-8?q?NR-172=20401=20=ED=8C=90=EC=A0=95=EC=9D=84?= =?UTF-8?q?=20=EC=9D=91=EB=8B=B5=20=EB=B3=B8=EB=AC=B8=20code=20=EB=8C=80?= =?UTF-8?q?=EC=8B=A0=20HTTP=20=EC=83=81=ED=83=9C=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 에러 매핑이 본문의 code 필드를 HTTP 상태보다 먼저 사용했다. 본문이 JSON 으로 파싱되기만 하면 HTTP 상태는 보지 않으므로, 401 인데 본문 code 가 다르면 Unauthorized 가 만들어지지 않아 리프레시 토큰이 거절돼도 로그아웃하지 않고, 반대로 401 이 아닌 응답의 본문 code 가 401 이면 멀쩡한 세션을 로그아웃시킨다. AuthAuthenticator 의 세션 거절 판정이 이 값 하나에 달려 있어 경계를 서버 본문 규약에 맡길 이유가 없다. 401 만 HTTP 상태로 판단하고 나머지 코드 매핑은 그대로 둔다. 같은 자리의 getInt/getString 은 키가 없으면 JSONException 을 던지는데 try/catch 는 JSONObject 생성까지만 감싸고 있어 예외가 콜백 스레드로 전파된다. 게이트웨이가 내는 {"message":"..."} 처럼 유효한 JSON 이지만 code 가 없는 본문에서 실제로 터진다. optInt/optString 으로 바꾸고 키가 없으면 HTTP 상태로 되돌린다. 에러 분기는 toFailure 로 분리했고, when 비교를 문자열에서 HttpURLConnection 상수 비교로 바꿨다. 매핑되지 않은 코드의 예외 메시지도 실제 상황과 맞지 않던 "body is null" 대신 코드와 HTTP 상태를 남긴다. Co-Authored-By: Claude Opus 5 --- .../data/network/ResultCallAdapter.kt | 57 ++++++++++++------- 1 file changed, 35 insertions(+), 22 deletions(-) diff --git a/data/src/main/java/com/nextroom/nextroom/data/network/ResultCallAdapter.kt b/data/src/main/java/com/nextroom/nextroom/data/network/ResultCallAdapter.kt index 2219d072..af07ae0b 100644 --- a/data/src/main/java/com/nextroom/nextroom/data/network/ResultCallAdapter.kt +++ b/data/src/main/java/com/nextroom/nextroom/data/network/ResultCallAdapter.kt @@ -10,6 +10,7 @@ import retrofit2.Callback import retrofit2.Response import java.io.IOException import java.lang.reflect.Type +import java.net.HttpURLConnection import java.net.UnknownHostException internal class ResultCallAdapter( @@ -60,28 +61,40 @@ private class ApiResultCall( } } } else { - val errorBody = try { - errorBody()?.string()?.let { JSONObject(it) } - } catch (_: Exception) { - null - } - val code = (errorBody?.getInt("code") ?: code()).toString() - val message = errorBody?.getString("message") ?: "" - when (code) { - "400" -> Result.Failure.HttpError.BadRequest(message) - "401" -> Result.Failure.HttpError.Unauthorized(message) - "403" -> Result.Failure.HttpError.Forbidden(message) - "404" -> Result.Failure.HttpError.NotFound(message) - "409" -> Result.Failure.HttpError.Conflict(message) - "500" -> Result.Failure.HttpError.ServerError(message) - else -> Result.Failure.UnknownError( - IllegalStateException( - "Response code is ${code()} but body is null." + - "If you expect response body to be null then define your API method as returning Unit:\n" + - "@POST fun postSomething(): Result", - ), - ) - } + toFailure() + } + } + + private fun Response.toFailure(): Result { + val errorBody = try { + errorBody()?.string()?.let { JSONObject(it) } + } catch (_: Exception) { + null + } + val message = errorBody?.optString("message").orEmpty() + + /* + * 401 은 세션 거절 판정의 기준이므로 본문 code 가 아니라 HTTP 상태로 판단한다. + * 본문 code 는 서버가 정하는 값이고, 게이트웨이가 앱을 거치지 않고 직접 내는 401 은 + * 애초에 앱의 응답 형식을 따르지 않는다. 이 경계가 틀리면 리프레시 토큰이 거절됐는데 + * 로그아웃하지 않거나, 멀쩡한 세션을 로그아웃시킨다. + */ + if (code() == HttpURLConnection.HTTP_UNAUTHORIZED) { + return Result.Failure.HttpError.Unauthorized(message) + } + + // optInt 는 키가 없으면 0 을 주므로 그때는 HTTP 상태로 되돌린다. getInt 는 예외를 던진다. + val code = errorBody?.optInt("code")?.takeIf { it != 0 } ?: code() + return when (code) { + HttpURLConnection.HTTP_BAD_REQUEST -> Result.Failure.HttpError.BadRequest(message) + HttpURLConnection.HTTP_UNAUTHORIZED -> Result.Failure.HttpError.Unauthorized(message) + HttpURLConnection.HTTP_FORBIDDEN -> Result.Failure.HttpError.Forbidden(message) + HttpURLConnection.HTTP_NOT_FOUND -> Result.Failure.HttpError.NotFound(message) + HttpURLConnection.HTTP_CONFLICT -> Result.Failure.HttpError.Conflict(message) + HttpURLConnection.HTTP_INTERNAL_ERROR -> Result.Failure.HttpError.ServerError(message) + else -> Result.Failure.UnknownError( + IllegalStateException("Unhandled error code $code (HTTP ${code()}): $message"), + ) } } },