Skip to content
Merged
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.twix.network

import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.plugins.HttpTimeout

object UploadHttpClientProvider {
fun create(): HttpClient =
HttpClient(OkHttp) {
install(HttpTimeout) {
requestTimeoutMillis = 120_000
connectTimeoutMillis = 30_000
socketTimeoutMillis = 120_000
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ package com.twix.network.di
import com.twix.network.service.AuthService
import com.twix.network.service.GoalService
import com.twix.network.service.OnboardingService
import com.twix.network.service.PhotoLogService
import com.twix.network.service.createAuthService
import com.twix.network.service.createGoalService
import com.twix.network.service.createOnboardingService
import com.twix.network.service.createPhotoLogService
import de.jensklingenberg.ktorfit.Ktorfit
import org.koin.dsl.module

Expand All @@ -20,4 +22,7 @@ internal val apiServiceModule =
single<GoalService> {
get<Ktorfit>().createGoalService()
}
single<PhotoLogService> {
get<Ktorfit>().createPhotoLogService()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ package com.twix.network.di

import com.twix.network.BuildConfig
import com.twix.network.HttpClientProvider
import com.twix.network.UploadHttpClientProvider
import com.twix.network.upload.PresignedUploader
import de.jensklingenberg.ktorfit.Ktorfit
import io.ktor.client.HttpClient
import org.koin.core.qualifier.named
import org.koin.dsl.module

internal val httpClientModule =
Expand All @@ -25,4 +28,8 @@ internal val httpClientModule =
.httpClient(get<HttpClient>())
.build()
}

single<HttpClient>(named("uploadClient")) { UploadHttpClientProvider.create() }

single { PresignedUploader(get(named("uploadClient"))) }
}
37 changes: 37 additions & 0 deletions core/network/src/main/java/com/twix/network/execute/UploadCall.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.twix.network.execute

import com.twix.result.AppError
import com.twix.result.AppResult
import io.ktor.client.plugins.ResponseException
import io.ktor.client.statement.bodyAsText
import kotlinx.io.IOException
import kotlinx.serialization.SerializationException
import java.net.SocketTimeoutException
import kotlin.coroutines.cancellation.CancellationException

suspend inline fun <T> safeUploadCall(crossinline call: suspend () -> T): AppResult<T> =
try {
AppResult.Success(call())
} catch (e: CancellationException) {
throw e
} catch (e: ResponseException) {
val status = e.response.status.value
val raw = runCatching { e.response.bodyAsText() }.getOrNull()

AppResult.Error(
AppError.Http(
status = status,
code = null,
message = null,
rawBody = raw,
),
)
} catch (e: SocketTimeoutException) {
AppResult.Error(AppError.Timeout(e))
} catch (e: IOException) {
AppResult.Error(AppError.Network(e))
} catch (e: SerializationException) {
AppResult.Error(AppError.Serialization(e))
} catch (e: Throwable) {
AppResult.Error(AppError.Unknown(e))
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.twix.network.model.response.photo.mapper

import com.twix.domain.model.photo.PhotoLogUploadInfo
import com.twix.network.model.response.photo.model.PhotoLogUploadUrlResponse

fun PhotoLogUploadUrlResponse.toDomain(): PhotoLogUploadInfo =
PhotoLogUploadInfo(
uploadUrl = this.uploadUrl,
fileName = this.fileName,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.twix.network.model.response.photo.model

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
data class PhotoLogUploadUrlResponse(
@SerialName("uploadUrl") val uploadUrl: String,
@SerialName("fileName") val fileName: String,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.twix.network.service

import com.twix.network.model.response.photo.model.PhotoLogUploadUrlResponse
import de.jensklingenberg.ktorfit.http.GET
import de.jensklingenberg.ktorfit.http.Query

interface PhotoLogService {
@GET("api/v1/photologs/upload-url")
suspend fun getUploadUrl(
@Query("goalId") goalId: Long,
): PhotoLogUploadUrlResponse
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.twix.network.upload

import com.twix.network.execute.safeUploadCall
import com.twix.result.AppResult
import io.ktor.client.HttpClient
import io.ktor.client.request.put
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.contentType
import io.ktor.http.isSuccess

class PresignedUploader(
private val client: HttpClient,
) {
suspend fun upload(
uploadUrl: String,
bytes: ByteArray,
contentType: String = "image/jpeg",
): AppResult<Unit> =
safeUploadCall {
val response =
client.put(uploadUrl) {
contentType(ContentType.parse(contentType))
setBody(bytes)
}

if (!response.status.isSuccess()) {
throw IllegalStateException("S3 upload failed: ${response.status.value}")
}
}
Comment on lines +20 to +30
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Ktor HttpClient expectSuccess default behavior 2.x

💡 Result:

In Ktor HttpClient 2.x, expectSuccess is off by default—meaning Ktor does not automatically treat non-2xx HTTP statuses as failures (it won’t throw just because the status is 3xx/4xx/5xx). You must explicitly enable it. [1]

If you set expectSuccess = true (client-wide or per-request), Ktor enables its default response validation and will throw on non-2xx responses: RedirectResponseException (3xx), ClientRequestException (4xx), ServerResponseException (5xx). [1][2]

You can configure it:

  • Client-wide: HttpClient { expectSuccess = true } [2]
  • Per-request override: HttpRequestBuilder.expectSuccess = ... [3]

Sources: [1] [2] [3]


🏁 Script executed:

# Find build configuration files
fd -e "build.gradle" -e "build.gradle.kts" -e "pom.xml" | head -20

Repository: YAPP-Github/Twix-Android

Length of output: 50


🏁 Script executed:

# Search for Ktor version and HttpClient configuration
rg "ktor|HttpClient|expectSuccess" --type kotlin --type java -B 2 -A 2

Repository: YAPP-Github/Twix-Android

Length of output: 16294


🏁 Script executed:

cat -n core/network/src/main/java/com/twix/network/UploadHttpClientProvider.kt

Repository: YAPP-Github/Twix-Android

Length of output: 618


🏁 Script executed:

cat -n core/network/src/main/java/com/twix/network/execute/UploadCall.kt

Repository: YAPP-Github/Twix-Android

Length of output: 1579


업로드 클라이언트를 expectSuccess = true로 설정하여 에러 처리를 개선하세요.

Ktor 2.x는 기본적으로 expectSuccess = false이므로, 현재 코드의 isSuccess() 검사는 실제로 도달 가능합니다. 하지만 IllegalStateException을 throw하면 safeUploadCallThrowable 핸들러에 잡혀 HTTP 상태 정보가 손실됩니다.

메인 API 클라이언트(HttpClientProvider)는 이미 expectSuccess = true로 설정되어 있습니다. 업로드 클라이언트도 동일하게 설정하면 Ktor가 자동으로 ResponseException을 throw하며, safeUploadCall이 이를 잡아 AppError.Http로 매핑하므로 HTTP 상태 코드 정보가 보존됩니다.

✅ 개선 방안

UploadHttpClientProvider.kt에서:

 object UploadHttpClientProvider {
     fun create(): HttpClient =
         HttpClient(OkHttp) {
+            expectSuccess = true
             install(HttpTimeout) {
                 requestTimeoutMillis = 120_000
                 connectTimeoutMillis = 30_000
                 socketTimeoutMillis = 120_000
             }
         }
 }

그 후 PresignedUploader.kt에서 수동 검사 제거:

         safeUploadCall {
-            val response =
-                client.put(uploadUrl) {
-                    contentType(ContentType.parse(contentType))
-                    setBody(bytes)
-                }
-
-            if (!response.status.isSuccess()) {
-                throw IllegalStateException("S3 upload failed: ${response.status.value}")
-            }
+            client.put(uploadUrl) {
+                contentType(ContentType.parse(contentType))
+                setBody(bytes)
+            }
         }
🤖 Prompt for AI Agents
In `@core/network/src/main/java/com/twix/network/upload/PresignedUploader.kt`
around lines 20 - 30, The upload client currently handles non-2xx responses by
manually checking response.status in PresignedUploader.safeUploadCall which
causes thrown IllegalStateException to lose HTTP info; instead set expectSuccess
= true in the upload client provider (UploadHttpClientProvider) so Ktor throws
ResponseException that safeUploadCall can map to AppError.Http, and then remove
the manual status check in PresignedUploader (the client.put block) so you rely
on Ktor's automatic exception handling.

}
5 changes: 5 additions & 0 deletions data/src/main/java/com/twix/data/di/RepositoryModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ package com.twix.data.di
import com.twix.data.repository.DefaultAuthRepository
import com.twix.data.repository.DefaultGoalRepository
import com.twix.data.repository.DefaultOnboardingRepository
import com.twix.data.repository.DefaultPhotoLogRepository
import com.twix.domain.repository.AuthRepository
import com.twix.domain.repository.GoalRepository
import com.twix.domain.repository.OnBoardingRepository
import com.twix.domain.repository.PhotoLogRepository
import org.koin.dsl.module

internal val repositoryModule =
Expand All @@ -19,4 +21,7 @@ internal val repositoryModule =
single<AuthRepository> {
DefaultAuthRepository(get(), get())
}
single<PhotoLogRepository> {
DefaultPhotoLogRepository(get(), get())
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package com.twix.data.repository

import com.twix.domain.model.photo.PhotoLogUploadInfo
import com.twix.domain.repository.PhotoLogRepository
import com.twix.network.execute.safeApiCall
import com.twix.network.model.response.photo.mapper.toDomain
import com.twix.network.service.PhotoLogService
import com.twix.network.upload.PresignedUploader
import com.twix.result.AppResult

class DefaultPhotoLogRepository(
private val service: PhotoLogService,
private val uploader: PresignedUploader,
) : PhotoLogRepository {
override suspend fun getUploadUrl(goalId: Long): AppResult<PhotoLogUploadInfo> = safeApiCall { service.getUploadUrl(goalId).toDomain() }

override suspend fun uploadPhotoLogImage(
goalId: Long,
bytes: ByteArray,
contentType: String,
): AppResult<String> {
// 서버에서 presigned url 발급
val infoResult = getUploadUrl(goalId)
val info =
when (infoResult) {
is AppResult.Success -> infoResult.data
is AppResult.Error -> return infoResult
}

// S3로 직접 업로드
val uploadResult =
uploader.upload(
uploadUrl = info.uploadUrl,
bytes = bytes,
contentType = contentType,
)
if (uploadResult is AppResult.Error) return uploadResult

// fileName을 key로 사용함. 인증샷 등록 API에서 사용
return AppResult.Success(info.fileName)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.twix.domain.model.photo

data class PhotoLogUploadInfo(
val uploadUrl: String = "",
val fileName: String = "",
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.twix.domain.repository

import com.twix.domain.model.photo.PhotoLogUploadInfo
import com.twix.result.AppResult

interface PhotoLogRepository {
suspend fun getUploadUrl(goalId: Long): AppResult<PhotoLogUploadInfo>

suspend fun uploadPhotoLogImage(
goalId: Long,
bytes: ByteArray,
contentType: String,
): AppResult<String>
}