i'm building a multi-module jetpack-compose login/signup app. My backend is handled using Spring boot and the login/signup function returns a ResponseEntity.
The task: in my frontend i want to display different messages based on type of error. Current solution: i Have a network module where i use Retrofit to call backend. This is the files in netwrok module.
interface AuthApi {
@POST("/auth/register")
suspend fun register(
@Body request: RegisterRequest,
)
@POST("/auth/login")
suspend fun login(
@Body request: LoginRequest,
): TokenPair
@GET("authenticate")
suspend fun authenticate(
@Header("Authorization") token: String,
)
}
@Module
@InstallIn(SingletonComponent::class)
internal object NetworkModule {
@Provides
@Singleton
fun provideAuthApi(
okhttpCallFactory: dagger.Lazy<Call.Factory>,
): AuthApi {
return Retrofit.Builder()
.baseUrl(SPOTIFY_BASE_URL)
.callFactory { okhttpCallFactory.get().newCall(it) }
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(AuthApi::class.java)
}
Then this is my data module.
class AuthRepositoryImpl
@Inject
constructor(
private val api: AuthApi,
) : AuthRepository {
override suspend fun signIn(email: String, password: String): Result<TokenPair, DataError.Network> {
return try {
val user = api.login(LoginRequest(email, password))
Result.Success(user)
} catch (e: HttpException) {
when (e.code()) {
400 -> Result.Error(DataError.Network.BAD_REQUEST)
401 -> Result.Error(DataError.Network.UNAUTHORIZED)
409 -> Result.Error(DataError.Network.CONFLICT)
else -> Result.Error(DataError.Network.INTERNAL_SERVER_ERROR)
}
}
}
sealed interface DataError : Error {
enum class Network : DataError {
BAD_REQUEST,
CONFLICT,
UNAUTHORIZED,
INTERNAL_SERVER_ERROR,
}
}
sealed interface Result<out D, out E : RootError> {
data class Success<out D, out E : RootError>(val data: D) : Result<D, E>
data class Error<out D, out E : RootError>(val error: E) : Result<D, E>
}
My problem is that in data layer i'm using HttpException which come from Retrofit (i add retrofit core dependency in data module too as well as network module).which means i'm not benefiting the idea of multi-module.
I use Ktor in my app, but the solution should apply similarly to Retrofit too.
In a common module, I define a dedicated exception to represent HTTP-based failures:
class HttpException(val errorCode: Int, val description: String? = null) : NetworkException("HTTP error: $errorCode - Description:$description")
NetworkException
is just a wrapper over IOException
, allowing to group all network-related issues:
abstract class NetworkException(message: String? = null, cause: Throwable? = null) : IOException(message, cause)
Then, I model network responses using a sealed interface (similar to your Result
):
sealed interface NetworkResponse<out T> {
data class Success<T>(val data: T) : NetworkResponse<T>
data class Error(val throwable: NetworkException) : NetworkResponse<Nothing>
}
Here's the helper to safely execute network calls and map exceptions:
internal suspend inline fun <reified T> safeNetworkCallNetworkResponse(
crossinline call: suspend () -> T
): NetworkResponse<T> {
return runCatchingNonCancellation { call() }
.fold(
onSuccess = { data ->
NetworkResponse.Success(data)
},
onFailure = { error ->
AppLog.error("Network call failure", error)
NetworkResponse.Error(error.toNetworkException())
}
)
}
The toNetworkException
function maps different Throwable types into your domain-specific exceptions:
fun Throwable.toNetworkException(): NetworkException {
return when (this) {
// ... Here is your mapping where map the api HttpException to your core module HttpException (and other mapping if required)
}
}
Tip:
I use runCatchingNonCancellation
, which skips catching CancellationException. This ensures coroutines are properly cancelled when for cooperative cancellation behavior.
This method can be implemented like this:
inline fun <R> runCatchingNonCancellation(block: () -> R): Result<R> {
return try {
Result.success(block())
} catch (e: Throwable) {
if (e is CancellationException) throw e
Result.failure(e)
}
}