구글 사전 출시 보고서에서 보고된 에러 중의 하나로, api 응답으로 200이 아닌 401을 받았을 때 발생하는 것으로 보였다. 하지만 401발생 -> 해당 에러 발생은 아니고, 에러가 오면 로깅을 하거나 인터셉팅을 하는 과정에서 뭔가 스트림 오류가 발생한 것 같았다. 그래서 몰랐던 개념과 그 원인을 기록하고자 했다.
Exception java.lang.IllegalStateException: closed
at okio.RealBufferedSource.request (RealBufferedSource.kt:207)
at okhttp3.logging.HttpLoggingInterceptor.intercept (HttpLoggingInterceptor.kt:247)
at okhttp3.internal.http.RealInterceptorChain.proceed (RealInterceptorChain.kt:109)
at com.hmoa.core_network.di.ServiceModule.provideHeaderInterceptor$lambda$1 (ServiceModule.kt:87)
at com.hmoa.core_network.di.ServiceModule.$r8$lambda$MSkSlZfKxfCZpWG5iUZTSMr7WDs
at com.hmoa.core_network.di.ServiceModule$$ExternalSyntheticLambda0.intercept
at okhttp3.internal.http.RealInterceptorChain.proceed (RealInterceptorChain.kt:109)
at okhttp3.internal.connection.RealCall.getResponseWithInterceptorChain$okhttp (RealCall.kt:201)
at okhttp3.internal.connection.RealCall$AsyncCall.run (RealCall.kt:517)
at java.util.concurrent.ThreadPoolExecutor.runWorker (ThreadPoolExecutor.java:1167)
at java.util.concurrent.ThreadPoolExecutor$Worker.run (ThreadPoolExecutor.java:641)
at java.lang.Thread.run (Thread.java:920)
밑의 그림과 같이 api 요청과 응답 과정에서 인터셉터는 OkHttp 클라이언트에서 Http요청과 응답을 가로채고, 수정하거나 로깅할 수 있는 기능을 제공한다.
이 과정에서 스트림은 HTTP요청 및 응답의 본문 데이터를 읽고 쓰기 위해 사용된다.
스트림은 연속적인 데이터와 흐름을 처리하는데 사용된다.
HTTP응답 본문은 네트워크를 통해 전달되는 큰 데이터 덩어리일 수 있기 때문에 스트림을 통해 데이터를 조금씩 읽어 들이는 방식이 사용된다. 하지만 스트림은 한 번 읽으면 재사용할 수 없기 때문에, 스트림을 두 번 읽으려고 하면 이미 닫힌 상태에서 접근하려는 시도가 되어 오류가 발생할 수 있다.
인터셉터는 HTTP요청 및 응답을 가로채서 필요한 작업을 수행할 수 있는 기능을 제공한다. 이 과정에서 스트림이 사용된다.
HttpLoggingInterceptor는 요청 및 응답의 내용을 로그에 기록하기 위해 스트림을 읽는다. 이 때 스트림을 잘못관리해서 발생한 오류가 위의 오류라고 한다.
Exception java.lang.IllegalStateException: closed
class AuthAuthenticator @Inject constructor(
private val tokenManager: TokenManager,
private val refreshTokenManager: RefreshTokenManager
) : okhttp3.Authenticator {
private var isAvailableToSendNewRequest = false
private lateinit var newRequest: Request
override fun authenticate(route: Route?, response: Response): Request? {
val rememberedToken = runBlocking {
tokenManager.getRememberedToken().first()
}
if (rememberedToken == null) {
response.close()
return null
}
CoroutineScope(Dispatchers.IO).launch {
refreshTokenManager.refreshTokens(RememberedLoginRequestDto(rememberedToken))
.suspendOnError {
if (this.response.code() == 401) {
isAvailableToSendNewRequest = false
Log.e("AuthAuthenticator", "토큰 리프레싱 실패")
}
}
.suspendOnSuccess {
if (this.response.body() != null) {
val refreshedAuthToken = this.response.body()!!.authToken
val refreshedRememberToken = this.response.body()!!.rememberedToken
refreshTokenManager.saveRefreshTokens(refreshedAuthToken, refreshedRememberToken)
newRequest = response.request.addRefreshAuthToken(refreshedAuthToken)
isAvailableToSendNewRequest = true
Log.d("AuthAuthenticator", "토큰 리프레싱 성공")
}
}
}
if (isAvailableToSendNewRequest) {
isAvailableToSendNewRequest = false
return newRequest
}
return null
}
fun Request.addRefreshAuthToken(token: String?): Request {
return this.newBuilder().header("X-AUTH-TOKEN", "${token}").build()
}
}
authenticate메소드는 동기적으로 실행되고, 코루틴의 완료를 기다리지 않기 때문에 내가 개발한 의도대로 작업이 완료되지 않을 가능성이 있다.
예를 들어 authenticate 메소드 내에서 코루틴을 통한 비동기 작업이 완료되지 않았는데 새로운 요청을 생성하려고 하면, 유효하지 않은 토큰을 사용하게 되어 다시 인증 오류가 발생할 수 있다.
isAvailableToSendNewRequest 플래그로 상태를 관리해왔는데, 앞서 말했듯이 이 플래그 값 할당이 비동기 스코프 안에 있기 때문에 상태변수 값이 잘못 관리될 가능성이 크다.
this.response.body()를 무려 3번이나 사용했다. 조건문을 검색했을 때 이미 닫힌 거나 다름없었다.
if (this.response.body() != null) {
val refreshedAuthToken = this.response.body()!!.authToken
val refreshedRememberToken = this.response.body()!!.rememberedToken
refreshTokenManager.saveRefreshTokens(refreshedAuthToken, refreshedRememberToken)
newRequest = response.request.addRefreshAuthToken(refreshedAuthToken)
isAvailableToSendNewRequest = true
Log.d("AuthAuthenticator", "토큰 리프레싱 성공")
}
1.response.body()를 1번만 사용(이번 에러의 직접적인 원인)
2.불필요한 상태관리를 제거(상태관리 플래그 isAvailableToSendNewRequest를 없앰)
3.타이밍이 맞지 않는 비동기 코드 제거(CoroutinScope.launch{}에서 runBlocking으로 변경
class AuthAuthenticator @Inject constructor(
private val tokenManager: TokenManager,
private val refreshTokenManager: RefreshTokenManager
) : okhttp3.Authenticator {
override fun authenticate(route: Route?, response: Response): Request? {
val rememberedToken = runBlocking {
tokenManager.getRememberedToken().first()
}
if (rememberedToken == null) {
response.close()
return null
}
var newRequest: Request? = null
runBlocking {
refreshTokenManager.refreshTokens(RememberedLoginRequestDto(rememberedToken))
.suspendOnError {
if (this.response.code() == 401) {
Log.e("AuthAuthenticator", "토큰 리프레싱 실패")
}
}
.suspendOnSuccess {
val responseBody = this.response.body()
if (responseBody != null) {
val refreshedAuthToken = responseBody.authToken
val refreshedRememberToken = responseBody.rememberedToken
refreshTokenManager.saveRefreshTokens(refreshedAuthToken, refreshedRememberToken)
newRequest = response.request.addRefreshAuthToken(refreshedAuthToken)
Log.d("AuthAuthenticator", "토큰 리프레싱 성공")
}
}
}
response.close()
return newRequest
}
fun Request.addRefreshAuthToken(token: String?): Request {
return this.newBuilder().header("X-AUTH-TOKEN", "${token}").build()
}
}
@Singleton
@Provides
fun provideHeaderInterceptor(tokenManager: TokenManager): Interceptor {
val token = Coroutine(Dispatcher.IO).async {
tokenManager.getAuthToken().onEmpty { }.collectLatest {
it
}
}
return Interceptor { chain ->
with(chain) {
val newRequest = request().newBuilder()
.header("X-AUTH-TOKEN", "${token}")
.build()
proceed(newRequest)
}
}
}
token을 받는 부분은 비동기고, interceptor는 동기적으로 작동한다. 따라서 token값을 저장소에서 받아왔는데 이미 interceptor는 새로운 요청을 만들어서 보내면 스트림이 닫혀버릴 수 있다.
https://jgeun97.tistory.com/278
https://m.blog.naver.com/bluecrossing/221463228194
https://stackoverflow.com/questions/60671465/retrofit-java-lang-illegalstateexception-closed