Setup base con Hilt — el punto de partida

// build.gradle (app)
dependencies {
    implementation("com.squareup.retrofit2:retrofit:2.11.0")
    implementation("com.squareup.retrofit2:converter-gson:2.11.0")
    // O kotlinx.serialization:
    implementation("com.jakewharton.retrofit2:retrofit2-kotlinx-serialization-converter:1.0.0")
    implementation("com.squareup.okhttp3:okhttp:4.12.0")
    implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
    implementation("com.squareup.okhttp3:mockwebserver:4.12.0") // solo en tests
}

// NetworkModule.kt — la configuración centralizada
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {

    @Provides @Singleton
    fun provideOkHttpClient(
        authInterceptor: AuthInterceptor,
        loggingInterceptor: HttpLoggingInterceptor
    ): OkHttpClient = OkHttpClient.Builder()
        .addInterceptor(authInterceptor)      // agrega el token a cada request
        .addInterceptor(loggingInterceptor)   // loggea en debug
        .connectTimeout(30, TimeUnit.SECONDS)
        .readTimeout(30, TimeUnit.SECONDS)
        .writeTimeout(30, TimeUnit.SECONDS)
        .build()

    @Provides @Singleton
    fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit = Retrofit.Builder()
        .baseUrl("https://api.miapp.com/")
        .client(okHttpClient)
        .addConverterFactory(GsonConverterFactory.create())
        .build()

    @Provides @Singleton
    fun provideProductoApi(retrofit: Retrofit): ProductoApi =
        retrofit.create(ProductoApi::class.java)
}

Interceptors — el mecanismo central de OkHttp

Un interceptor es una clase que intercepta cada request (o response) y puede leerlo, modificarlo o reemplazarlo. OkHttp tiene dos tipos:

  • Application interceptors (addInterceptor): ven el request original antes de que OkHttp lo procese. Si hay un redirect, solo ven el request inicial. Son los más usados.
  • Network interceptors (addNetworkInterceptor): ven cada request que realmente viaja por la red, incluyendo redirects y requests de la caché. Útiles para logging detallado.
// La interfaz que implementan todos los interceptors:
class MiInterceptor : Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        val requestOriginal = chain.request()

        // Modificar el request antes de enviarlo:
        val requestModificado = requestOriginal.newBuilder()
            .header("X-App-Version", BuildConfig.VERSION_NAME)
            .build()

        // Dejar que el request continúe (SIEMPRE hay que llamar a proceed):
        val response = chain.proceed(requestModificado)

        // Modificar o inspeccionar la response:
        if (response.code == 503) {
            // Loggear o hacer algo especial
        }

        return response  // siempre retornar la response
    }
}

Logging interceptor — solo en debug

@Provides @Singleton
fun provideLoggingInterceptor(): HttpLoggingInterceptor =
    HttpLoggingInterceptor().apply {
        level = if (BuildConfig.DEBUG) {
            HttpLoggingInterceptor.Level.BODY   // loggea headers + body completo
        } else {
            HttpLoggingInterceptor.Level.NONE   // nada en producción
        }
    }

// Los niveles disponibles:
// NONE    → sin logs
// BASIC   → método, URL y código de respuesta
// HEADERS → BASIC + todos los headers
// BODY    → HEADERS + body completo del request y la response

// NUNCA usar BODY en producción — loggea tokens, passwords y datos sensibles
// En debug es invaluable para entender qué se envía y recibe

BODY en producción es un riesgo de seguridad realSi olvidás cambiar el nivel a NONE en producción, todos los tokens de sesión, datos del usuario y respuestas de la API van al logcat — visible con adb logcat para cualquiera con acceso al dispositivo. Siempre condicionarlo a BuildConfig.DEBUG.

Auth interceptor — agregar el token a cada request

// AuthInterceptor agrega el token de autenticación a todos los requests
@Singleton
class AuthInterceptor @Inject constructor(
    private val tokenRepository: TokenRepository
) : Interceptor {

    override fun intercept(chain: Interceptor.Chain): Response {
        val token = tokenRepository.getAccessToken()

        // Si no hay token (usuario no autenticado), dejar pasar el request sin modificar
        if (token == null) return chain.proceed(chain.request())

        val request = chain.request().newBuilder()
            .header("Authorization", "Bearer $token")
            .build()

        return chain.proceed(request)
    }
}

// TokenRepository — responsable del storage del token
@Singleton
class TokenRepository @Inject constructor(
    private val encryptedPrefs: EncryptedSharedPreferences
) {
    companion object {
        private const val KEY_ACCESS_TOKEN = "access_token"
        private const val KEY_REFRESH_TOKEN = "refresh_token"
    }

    fun getAccessToken(): String? = encryptedPrefs.getString(KEY_ACCESS_TOKEN, null)
    fun getRefreshToken(): String? = encryptedPrefs.getString(KEY_REFRESH_TOKEN, null)

    fun guardarTokens(accessToken: String, refreshToken: String) {
        encryptedPrefs.edit()
            .putString(KEY_ACCESS_TOKEN, accessToken)
            .putString(KEY_REFRESH_TOKEN, refreshToken)
            .apply()
    }

    fun limpiarTokens() {
        encryptedPrefs.edit()
            .remove(KEY_ACCESS_TOKEN)
            .remove(KEY_REFRESH_TOKEN)
            .apply()
    }
}

Token refresh automático con Authenticator

Cuando el access token expira, el servidor responde con 401. En lugar de manejar esto manualmente en cada repositorio, OkHttp tiene un mecanismo específico para este caso: el Authenticator. Se llama automáticamente cuando la response es 401 y permite renovar el token y reintentar el request transparentemente.

// TokenAuthenticator — se activa SOLO en respuestas 401
@Singleton
class TokenAuthenticator @Inject constructor(
    private val tokenRepository: TokenRepository,
    private val authApi: AuthApi   // interfaz Retrofit para el endpoint de refresh
) : Authenticator {

    // OkHttp llama a authenticate() cuando recibe un 401
    // Retornar null → cancelar el request (sin reintentar)
    // Retornar un Request → reintentar con el nuevo request
    override fun authenticate(route: Route?, response: Response): Request? {
        // Verificar que no es un loop (el refresh también falló con 401)
        if (response.request.header("Authorization") != null
            && responseCount(response) >= 2) {
            // Ya intentamos con un token nuevo y también falló → sesión expirada
            tokenRepository.limpiarTokens()
            return null  // forzar logout
        }

        val refreshToken = tokenRepository.getRefreshToken()
            ?: return null  // no hay refresh token → logout

        return try {
            // Llamada SINCRÓNICA al endpoint de refresh
            // (Authenticator corre en un hilo de background de OkHttp)
            val nuevoToken = authApi.refreshToken(refreshToken).execute()

            if (nuevoToken.isSuccessful) {
                val body = nuevoToken.body() ?: return null
                tokenRepository.guardarTokens(body.accessToken, body.refreshToken)

                // Reintentar el request original con el nuevo access token
                response.request.newBuilder()
                    .header("Authorization", "Bearer ${body.accessToken}")
                    .build()
            } else {
                // El refresh también falló → tokens inválidos → logout
                tokenRepository.limpiarTokens()
                null
            }
        } catch (e: IOException) {
            null  // error de red durante el refresh → no reintentar
        }
    }

    // Contar cuántas veces se intentó autenticar este request
    private fun responseCount(response: Response): Int {
        var count = 1
        var prior = response.priorResponse
        while (prior != null) {
            count++
            prior = prior.priorResponse
        }
        return count
    }
}

// Registrar en OkHttpClient — authenticator es diferente de interceptor:
@Provides @Singleton
fun provideOkHttpClient(
    authInterceptor: AuthInterceptor,
    tokenAuthenticator: TokenAuthenticator,
    loggingInterceptor: HttpLoggingInterceptor
): OkHttpClient = OkHttpClient.Builder()
    .addInterceptor(authInterceptor)
    .authenticator(tokenAuthenticator)  // ← authenticator, no interceptor
    .addInterceptor(loggingInterceptor)
    .build()

La diferencia entre Interceptor y AuthenticatorEl AuthInterceptor agrega el token a todos los requests proactivamente. El TokenAuthenticator solo actúa cuando el servidor responde 401 — hace el refresh y reintenta. Son complementarios: el interceptor evita los 401, el authenticator los resuelve cuando ocurren igual.

Manejo de errores HTTP — el problema sin resolver

Por defecto, Retrofit trata un 404 o un 500 como una respuesta exitosa — no lanza excepción, simplemente retorna la response con ese código. Si no verificás esto explícitamente, tu repositorio va a mapear una respuesta de error a un modelo de dominio vacío o crashear.

// ❌ El problema — Retrofit no lanza excepción en errores HTTP
interface ProductoApi {
    @GET("productos/{id}")
    suspend fun getProducto(@Path("id") id: Int): ProductoDto
    // Si el servidor retorna 404, lanza HttpException
    // Si retorna 500, también lanza HttpException
    // Si no hay red, lanza IOException
    // Si la respuesta no puede parsearse, lanza JsonSyntaxException
    // Cada repo tiene que manejar estos casos... o no los maneja
}

// ✓ Usar Response<T> para acceso explícito al código HTTP:
interface ProductoApi {
    @GET("productos/{id}")
    suspend fun getProducto(@Path("id") id: Int): Response<ProductoDto>
    // Ahora podés verificar response.isSuccessful antes de usar response.body()
}

Result wrapper tipado — una sola forma de manejar errores

En lugar de repetir el mismo try/catch en cada repositorio, centralizar el manejo de errores en una función de extensión:

// Tipos de error de red que tu app puede recibir
sealed class NetworkError {
    data class HttpError(val code: Int, val mensaje: String) : NetworkError()
    data class NoConexion(val causa: IOException) : NetworkError()
    data class ParseError(val causa: Exception) : NetworkError()
    data class Desconocido(val causa: Throwable) : NetworkError()
}

// Wrapper de resultado de red
sealed class NetworkResult<out T> {
    data class Success<T>(val data: T) : NetworkResult<T>()
    data class Error(val error: NetworkError) : NetworkResult<Nothing>()
}

// Extension function que centraliza el try/catch
suspend fun <T> safeApiCall(call: suspend () -> Response<T>): NetworkResult<T> {
    return try {
        val response = call()
        if (response.isSuccessful) {
            val body = response.body()
            if (body != null) {
                NetworkResult.Success(body)
            } else {
                NetworkResult.Error(NetworkError.HttpError(response.code(), "Respuesta vacía"))
            }
        } else {
            // Error del servidor — parsear el body de error si existe
            val errorMensaje = try {
                response.errorBody()?.string() ?: "Error ${response.code()}"
            } catch (e: Exception) {
                "Error ${response.code()}"
            }
            NetworkResult.Error(NetworkError.HttpError(response.code(), errorMensaje))
        }
    } catch (e: IOException) {
        NetworkResult.Error(NetworkError.NoConexion(e))
    } catch (e: JsonSyntaxException) {
        NetworkResult.Error(NetworkError.ParseError(e))
    } catch (e: Exception) {
        NetworkResult.Error(NetworkError.Desconocido(e))
    }
}

// Uso en el repositorio — limpio, sin try/catch repetidos:
class ProductoRepositoryImpl @Inject constructor(
    private val api: ProductoApi
) : ProductoRepository {

    override suspend fun getProducto(id: Int): NetworkResult<Producto> {
        return safeApiCall { api.getProducto(id) }
            .map { dto -> dto.toDomain() }  // transformar DTO a dominio si fue exitoso
    }

    override suspend fun getProductos(): NetworkResult<List<Producto>> {
        return safeApiCall { api.getProductos() }
            .map { dtos -> dtos.map { it.toDomain() } }
    }
}

// Extension para transformar el resultado manteniendo el tipo de error:
fun <T, R> NetworkResult<T>.map(transform: (T) -> R): NetworkResult<R> = when (this) {
    is NetworkResult.Success -> NetworkResult.Success(transform(data))
    is NetworkResult.Error -> this
}

// En el ViewModel — manejar el resultado de forma exhaustiva:
viewModelScope.launch {
    when (val resultado = repository.getProducto(id)) {
        is NetworkResult.Success -> {
            _uiState.update { it.copy(producto = resultado.data, isLoading = false) }
        }
        is NetworkResult.Error -> when (resultado.error) {
            is NetworkError.HttpError -> when (resultado.error.code) {
                404 -> _uiState.update { it.copy(error = "Producto no encontrado") }
                401 -> navegarALogin()
                else -> _uiState.update { it.copy(error = "Error del servidor") }
            }
            is NetworkError.NoConexion ->
                _uiState.update { it.copy(error = "Sin conexión a internet") }
            else ->
                _uiState.update { it.copy(error = "Error inesperado") }
        }
    }
}

Timeouts, reintentos y cache

// Timeouts diferenciados por tipo de request:
// Para la mayoría de la app → un OkHttpClient con timeouts estándar
// Para uploads → un OkHttpClient con write timeout más largo

@Provides @Singleton @Named("upload")
fun provideUploadOkHttpClient(
    authInterceptor: AuthInterceptor
): OkHttpClient = OkHttpClient.Builder()
    .addInterceptor(authInterceptor)
    .connectTimeout(30, TimeUnit.SECONDS)
    .readTimeout(60, TimeUnit.SECONDS)
    .writeTimeout(120, TimeUnit.SECONDS)  // más tiempo para uploads
    .build()

// Reintentos manuales para requests importantes:
suspend fun <T> retryApiCall(
    maxReintentos: Int = 3,
    delayInicial: Long = 1000L,
    call: suspend () -> NetworkResult<T>
): NetworkResult<T> {
    var intentos = 0
    var delay = delayInicial
    while (intentos < maxReintentos) {
        val resultado = call()
        if (resultado is NetworkResult.Success) return resultado
        if (resultado is NetworkResult.Error
            && resultado.error is NetworkError.HttpError
            && resultado.error.code !in 500..599) {
            return resultado  // error del cliente (4xx) — no reintentar
        }
        intentos++
        if (intentos < maxReintentos) delay(delay)
        delay *= 2  // backoff exponencial
    }
    return call()
}

// Cache HTTP con OkHttp:
@Provides @Singleton
fun provideCache(@ApplicationContext context: Context): Cache {
    val cacheDir = File(context.cacheDir, "http_cache")
    return Cache(cacheDir, 10 * 1024 * 1024)  // 10 MB
}

@Provides @Singleton
fun provideOkHttpClient(cache: Cache, ...): OkHttpClient = OkHttpClient.Builder()
    .cache(cache)
    // El cache funciona automáticamente con los headers Cache-Control del servidor
    .build()

Testing con MockWebServer

MockWebServer (de OkHttp) levanta un servidor HTTP real en la máquina local durante los tests. Es la forma más confiable de testear el cliente de red — sin mocks de Retrofit, sin mocks de OkHttp, con responses reales.

// src/test/ — tests unitarios con MockWebServer
class ProductoApiTest {

    private lateinit var mockWebServer: MockWebServer
    private lateinit var api: ProductoApi

    @Before
    fun setUp() {
        mockWebServer = MockWebServer()
        mockWebServer.start()

        val okHttpClient = OkHttpClient.Builder().build()

        api = Retrofit.Builder()
            .baseUrl(mockWebServer.url("/"))  // URL del servidor local
            .client(okHttpClient)
            .addConverterFactory(GsonConverterFactory.create())
            .build()
            .create(ProductoApi::class.java)
    }

    @After
    fun tearDown() {
        mockWebServer.shutdown()
    }

    @Test
    fun `getProducto retorna el producto correcto`() = runTest {
        // Configurar la respuesta que el servidor va a dar
        val responseBody = """
            {
                "id": 42,
                "name": "Mouse Logitech",
                "price": 5999.0,
                "image_url": "https://example.com/mouse.jpg"
            }
        """.trimIndent()

        mockWebServer.enqueue(
            MockResponse()
                .setResponseCode(200)
                .setHeader("Content-Type", "application/json")
                .setBody(responseBody)
        )

        val response = api.getProducto(42)

        assertThat(response.isSuccessful).isTrue()
        assertThat(response.body()?.id).isEqualTo(42)
        assertThat(response.body()?.nombre).isEqualTo("Mouse Logitech")
    }

    @Test
    fun `getProducto con 404 retorna respuesta de error`() = runTest {
        mockWebServer.enqueue(
            MockResponse()
                .setResponseCode(404)
                .setBody("""{"error": "Producto no encontrado"}""")
        )

        val response = api.getProducto(999)

        assertThat(response.isSuccessful).isFalse()
        assertThat(response.code()).isEqualTo(404)
    }

    @Test
    fun `safeApiCall con sin conexion retorna NoConexion`() = runTest {
        // Simular timeout o desconexión
        mockWebServer.enqueue(
            MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AT_START)
        )

        val resultado = safeApiCall { api.getProducto(1) }

        assertThat(resultado).isInstanceOf(NetworkResult.Error::class.java)
        val error = (resultado as NetworkResult.Error).error
        assertThat(error).isInstanceOf(NetworkError.NoConexion::class.java)
    }

    @Test
    fun `verifica que el request tiene el header de autorizacion`() = runTest {
        mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody("[]"))

        api.getProductos()

        val request = mockWebServer.takeRequest()
        assertThat(request.getHeader("Authorization")).startsWith("Bearer ")
    }
}

Multipart y uploads de archivos

// Interfaz Retrofit para upload de imagen:
interface PerfilApi {
    @Multipart
    @POST("perfil/foto")
    suspend fun subirFoto(
        @Part foto: MultipartBody.Part,
        @Part("descripcion") descripcion: RequestBody
    ): Response<FotoPerfilDto>
}

// En el repositorio — construir el MultipartBody.Part desde un Uri:
suspend fun subirFotoPerfil(uri: Uri): NetworkResult<FotoPerfil> {
    val inputStream = context.contentResolver.openInputStream(uri)
        ?: return NetworkResult.Error(NetworkError.Desconocido(Exception("No se pudo abrir el archivo")))

    val bytes = inputStream.readBytes()
    inputStream.close()

    val requestBody = bytes.toRequestBody("image/*".toMediaType())
    val part = MultipartBody.Part.createFormData(
        name = "foto",
        filename = "foto_${System.currentTimeMillis()}.jpg",
        body = requestBody
    )

    val descripcionBody = "Foto de perfil".toRequestBody("text/plain".toMediaType())

    return safeApiCall { api.subirFoto(part, descripcionBody) }
        .map { it.toDomain() }
}

// Para trackear el progreso del upload — usar un interceptor custom:
class ProgressInterceptor(
    private val onProgress: (Int) -> Unit
) : Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        val originalRequest = chain.request()
        val originalBody = originalRequest.body ?: return chain.proceed(originalRequest)

        val requestConProgreso = originalRequest.newBuilder()
            .method(
                originalRequest.method,
                ProgressRequestBody(originalBody, onProgress)
            )
            .build()

        return chain.proceed(requestConProgreso)
    }
}