wandres.dev
REDES · Retrofit, Ktor y OkHttp

Retrofit: la API como interfaz

Retrofit no es un cliente HTTP: es un generador de implementaciones que convierte una interfaz de Kotlin anotada en código que habla con la red por debajo mediante OkHttp. Esta lección desmonta esa maquinaria pieza a pieza: cómo se lee el contrato declarado con anotaciones, qué ocurre exactamente cuando una función se marca como suspend, por qué existen tres desenlaces posibles para una llamada y no dos, y en qué punto exacto de la arquitectura conviene traducir un código de estado HTTP a un concepto del dominio.

⏱ 22 min

La primera idea que hay que desalojar es que Retrofit sea un cliente HTTP. No lo es, y creerlo explica la mitad de las confusiones posteriores. Retrofit no abre sockets, no negocia TLS, no mantiene conexiones vivas ni decide tiempos de espera: todo eso lo hace OkHttp, que vive por debajo y que veremos en la lección siguiente. Lo que Retrofit aporta es una cosa mucho más específica y mucho más interesante desde el punto de vista del diseño: un mecanismo para expresar el contrato de una API remota como un tipo del lenguaje. Declaras una interfaz, la anotas, y en tiempo de ejecución recibes una implementación que traduce cada llamada a función en una petición y cada respuesta en un valor tipado. El desplazamiento conceptual es notable. Deja de haber código que construye peticiones y empieza a haber una declaración de qué existe al otro lado, con el mismo estatus que cualquier otra interfaz del proyecto. Y como toda declaración, se puede leer, se puede sustituir por un doble en las pruebas y se puede razonar sin ejecutarla.

🎯 Al terminar esta lección sabrás
  • Declarar el contrato de una API remota como interfaz anotada y explicar qué genera Retrofit a partir de ella.
  • Escribir funciones suspend sabiendo qué mecanismo las conecta con la ejecución asíncrona real y por qué no necesitan un cambio de hilo explícito.
  • Distinguir con precisión los tres desenlaces de una llamada: cuerpo correcto, respuesta de error y fallo de transporte.
  • Decidir dónde se traduce un código de estado HTTP a un tipo del dominio y por qué ese punto no es la capa de interfaz.

La interfaz no es documentación: es el programa

Cuando pides una implementación de tu interfaz, Retrofit no genera código fuente ni ejecuta ningún procesador de anotaciones. Construye un objeto delegado dinámico que intercepta cada invocación, y la primera vez que se llama a una función concreta analiza sus anotaciones y su firma para fabricar un plan de ejecución que después queda memorizado. Ese detalle tiene dos consecuencias prácticas. La primera es que los errores de declaración no aparecen al compilar sino al invocar por primera vez esa función concreta, con un mensaje que nombra la función culpable. La segunda es que el coste del análisis se paga una sola vez por método y no en cada petición.

El contrato se expresa con tres familias de anotaciones que conviene no mezclar mentalmente: las que fijan el verbo y la ruta, las que inyectan valores en la petición y las que describen el cuerpo.

interface ApiCatalogo {
    @GET("v1/productos/{id}")
    suspend fun producto(
        @Path("id") id: String,
        @Query("expandir") expandir: List<String> = emptyList(),
    ): Producto

    @POST("v1/pedidos")
    suspend fun crearPedido(
        @Header("Idempotency-Key") clave: String,
        @Body pedido: PedidoNuevo,
    ): Response<PedidoCreado>
}

La resolución de la ruta relativa contra la dirección base sigue las reglas de resolución de URL, no una concatenación de cadenas, y ahí se esconde una de las trampas más repetidas del ecosistema. Una dirección base que no termina en barra descarta su último segmento al resolver, y una ruta relativa que empieza por barra descarta la ruta entera de la base. El resultado es una petición dirigida a un sitio que nadie escribió y que suele responder con un 404 desconcertante. La regla operativa cabe en una línea: la base termina en barra, las rutas relativas nunca empiezan por ella.

La instancia que fabrica las implementaciones se compone de tres piezas y ninguna es accesoria: la dirección base, el cliente de transporte que ejecutará las llamadas y al menos una fábrica de convertidores que sepa traducir entre cuerpos y tipos. Que el cliente se inyecte desde fuera en lugar de dejar que se cree uno por omisión es lo que permite compartir conexiones, interceptores y caché con el resto de la aplicación, tal como veremos en la lección siguiente.

val retrofit = Retrofit.Builder()
    .baseUrl("https://api.ejemplo.com/v1/")   // siempre termina en barra
    .client(clienteOkHttpCompartido)
    .addConverterFactory(analizador.asConverterFactory(tipoJson))
    .build()

val api: ApiCatalogo = retrofit.create()

El orden en que se registran las fábricas importa, porque se consultan en secuencia y gana la primera que declare saber manejar el tipo pedido. Registrar una fábrica especializada en un tipo concreto después de una genérica equivale a no registrarla, y ese es el mecanismo por el que un tipo envoltorio propio deja de recibir el tratamiento que se le había preparado.

🌐

Verbo y ruta

@GET, @POST, @PUT, @PATCH y @DELETE fijan el método y la plantilla de ruta. Los segmentos entre llaves se rellenan con @Path y se codifican salvo que se indique lo contrario.

🔧

Inyectar valores

@Query y @QueryMap alimentan la cadena de consulta, @Header y @HeaderMap las cabeceras de esa llamada concreta, y @Url sustituye la ruta entera por una absoluta recibida en tiempo de ejecución.

📦

Describir el cuerpo

@Body delega la conversión en la fábrica registrada, mientras que @Field y @Part producen formularios codificados y peticiones multiparte respectivamente.

🧪

Sustituible por diseño

Como el contrato es una interfaz del lenguaje, una implementación falsa en un test no necesita servidor ni biblioteca de simulación: es una clase que devuelve datos.

Qué significa exactamente marcar la función como suspend

Una función suspend en el contrato no se convierte en nada mágico. Retrofit detecta que el último parámetro real de la firma compilada es una continuación, envuelve la llamada asíncrona de OkHttp y reanuda esa continuación cuando llega la respuesta o cuando falla el transporte. La cancelación viaja en sentido contrario: si la corrutina que espera se cancela, se invoca la cancelación de la llamada subyacente, que cierra el socket si la petición estaba en vuelo.

De ahí se sigue una propiedad que se enuncia mucho y se entiende poco: estas funciones son seguras desde el hilo principal. No porque Retrofit cambie de hilo por ti, sino porque nunca hubo bloqueo que trasladar. La petición se encola en el repartidor de OkHttp, que tiene sus propios hilos, y la corrutina que la espera queda suspendida sin ocupar ninguno. Envolver la llamada en un cambio explícito al despachador de entrada y salida no la hace más correcta ni más rápida: añade un salto de contexto que no resuelve ningún problema.

💡
El cambio de hilo que casi nunca hace falta

Si tu repositorio hace withContext alrededor de una llamada suspend de Retrofit, comprueba qué más hay dentro de ese bloque. Si solo está la llamada, sobra. Si además hay un análisis pesado del resultado, una escritura en base de datos o un mapeo de miles de elementos, entonces el cambio de contexto es correcto pero lo justifica ese trabajo, no la petición. La distinción importa porque la costumbre de envolverlo todo por reflejo enmascara dónde está de verdad el coste de cómputo.

Conviene además recordar que el mecanismo anterior a las corrutinas sigue existiendo y ocasionalmente sigue siendo el adecuado. Una función que devuelve Call entrega un objeto ejecutable que se puede encolar, cancelar o repetir manualmente, y una fábrica de adaptadores permite que el tipo de retorno sea un flujo o cualquier otra abstracción. Las funciones suspend no son un adaptador más: están integradas en el núcleo. Pero saber que el punto de extensión existe evita reescribir a mano lo que ya está previsto.

Tres desenlaces, no dos

Aquí está el error conceptual más caro de esta lección, y merece enunciarse sin rodeos. Una llamada HTTP no tiene dos finales posibles sino tres, y confundir dos de ellos produce código que trata como catástrofe lo que era una respuesta perfectamente válida del servidor.

El primero es que la petición llegue, el servidor conteste con un código de éxito y el convertidor sepa transformar el cuerpo. El segundo es que la petición llegue, el servidor conteste con un código de error y devuelva un cuerpo que explica qué pasó. El tercero es que la petición no llegue a completarse en absoluto: no hay red, el nombre no resuelve, expira el tiempo de espera, el certificado no valida o el socket se corta. Los dos primeros son conversaciones que terminaron; el tercero es una conversación que nunca ocurrió.

flowchart TD
A[Llamada suspend en el repositorio] --> B{Se completo el intercambio}
B -->|No| C[Fallo de transporte tipo IOException]
B -->|Si| D{Codigo de estado en el rango de exito}
D -->|Si| E[Convertidor produce el cuerpo tipado]
D -->|No| F[Respuesta de error con cuerpo propio]
E --> G[Valor de dominio]
F --> H[Traduccion a error de dominio]
C --> H
style E fill:#a6e3a1,color:#11111b
style F fill:#f9e2af,color:#11111b
style C fill:#f38ba8,color:#11111b

El tipo de retorno que declares decide cómo se te presentan esos tres desenlaces. Si declaras el tipo del cuerpo directamente, Retrofit te entrega el valor cuando todo fue bien y lanza una excepción en los otros dos casos: una excepción de HTTP cuando el servidor respondió con error y una excepción de entrada y salida cuando falló el transporte. Si declaras Response envolviendo el tipo, la respuesta de error deja de ser excepcional y llega como un objeto que puedes interrogar, mientras que el fallo de transporte sigue siendo una excepción porque no hay nada que interrogar.

suspend fun cargarProducto(id: String): ResultadoDominio<Producto> = try {
    val respuesta = api.producto(id)
    ResultadoDominio.Exito(respuesta)
} catch (e: HttpException) {
    ResultadoDominio.Fallo(traducirCodigo(e.code(), e.response()?.errorBody()))
} catch (e: IOException) {
    ResultadoDominio.Fallo(ErrorDominio.SinConexion)
}

El cuerpo de error merece una advertencia técnica concreta porque su mal uso produce fugas silenciosas. Es un flujo que se puede leer una sola vez y que debe cerrarse; leerlo dos veces devuelve vacío y no leerlo nunca deja una conexión retenida hasta que el recolector la libere. Además, para deserializarlo con el mismo convertidor que usa el resto de la API hay que pedírselo explícitamente a la instancia de Retrofit, porque la conversión automática solo se aplica a los cuerpos de éxito.

val convertidor = retrofit.responseBodyConverter<ErrorApi>(
    ErrorApi::class.java, emptyArray()
)

fun leerError(cuerpo: ResponseBody?): ErrorApi? =
    cuerpo?.use { runCatching { convertidor.convert(it) }.getOrNull() }

Dónde termina el contrato y empieza el dominio

Una interfaz de Retrofit describe fielmente lo que existe al otro lado del cable, con sus nombres de campo ajenos, sus códigos numéricos y sus convenciones. Esa fidelidad es una virtud dentro de la capa de red y un defecto en cuanto se propaga hacia arriba. Una excepción de HTTP viajando hasta un modelo de vista significa que la interfaz de usuario conoce el vocabulario del servidor, y a partir de ahí cualquier cambio remoto atraviesa la aplicación entera.

La frontera correcta es el repositorio, y su trabajo consiste en traducir tres alfabetos en uno: códigos de estado, cuerpos de error y fallos de transporte se convierten en un tipo cerrado propio que enumera lo que tu aplicación sabe hacer. Un 401 no es un número, es sesión caducada. Un 422 con un cuerpo que enumera campos no es un error genérico, es validación fallida con detalle. Un 503 no es un fallo del cliente, es indisponibilidad temporal que quizá merezca reintento.

private fun traducirCodigo(codigo: Int, cuerpo: ResponseBody?): ErrorDominio =
    when (codigo) {
        401 -> ErrorDominio.SesionCaducida
        403 -> ErrorDominio.SinPermiso
        404 -> ErrorDominio.NoEncontrado
        422 -> ErrorDominio.Validacion(leerError(cuerpo)?.campos.orEmpty())
        in 500..599 -> ErrorDominio.Servidor(codigo)
        else -> ErrorDominio.Inesperado(codigo)
    }

Esa función es más importante de lo que su tamaño sugiere, porque es el único punto del sistema donde el vocabulario ajeno se convierte en propio. Todo lo que quede por encima de ella puede escribirse, leerse y probarse sin saber que existe HTTP, y esa independencia es exactamente lo que permite sustituir la fuente remota por una local, o cambiar de biblioteca, sin tocar una sola pantalla.

El tipo es el contrato, y el contrato siempre está desactualizado

Hay una asimetría estructural en toda comunicación con una API remota que no se resuelve con ninguna biblioteca y que conviene mirar de frente porque gobierna todas las decisiones de esta lección. Una interfaz anotada parece un contrato, se lee como un contrato y el compilador la trata como si fuera un contrato: los tipos cuadran, las firmas encajan, el proyecto compila. Pero el compilador no ha verificado absolutamente nada sobre el servidor. Ha verificado que tu creencia sobre el servidor es internamente coherente, que es una propiedad muchísimo más débil y que se confunde con la primera cada vez que alguien ve la compilación en verde. El contrato real vive en otra máquina, lo mantiene otro equipo, se despliega en otro calendario y puede cambiar cualquier martes por la tarde sin que tu repositorio se entere. De ahí se derivan tres consecuencias que no son opinables. La primera es que todo lo que tu interfaz afirma sobre la forma de los datos es una hipótesis, y las hipótesis se validan en el límite del sistema y no en el interior: por eso el mapeo a tipos de dominio ocurre en el repositorio y no en la pantalla, porque es ahí donde se decide qué hacer cuando la realidad no coincide con la declaración. La segunda es que la versión de tu aplicación instalada en el teléfono de un usuario sobrevive años, mientras que la del servidor cambia semanalmente, de modo que en todo momento existe una población de clientes que sostienen contratos antiguos y que el servidor tiene la obligación de seguir honrando. La tercera, y la más incómoda, es que un fallo de transporte y una respuesta de error no son variantes del mismo suceso aunque el usuario perciba ambas como que la aplicación no funciona. En un caso el servidor te dijo algo y hay que escucharlo; en el otro nadie dijo nada y solo cabe decidir si insistir. Tratarlos con el mismo bloque de captura es la manera más eficiente de convertir un problema diagnosticable en un mensaje genérico que no ayuda ni al usuario ni a quien tenga que investigarlo seis meses después.

⚔️ Somete el contrato a la realidad
  1. Declara una interfaz con tres funciones que usen @Path, @Query y @Body, y provoca a propósito un error de declaración para comprobar en qué momento exacto salta y qué dice el mensaje.
  2. Configura una dirección base sin barra final y una ruta relativa que empiece por barra, registra la URL resultante con un interceptor y explica la regla de resolución que la produjo.
  3. Escribe la misma llamada dos veces, una devolviendo el tipo del cuerpo y otra devolviendo Response, y documenta qué ocurre en cada versión ante un 404 y ante el modo avión.
  4. Deserializa el cuerpo de error con el convertidor de la instancia y comprueba qué pasa si intentas leerlo por segunda vez.
  5. Sustituye toda propagación de HttpException hacia arriba por un tipo cerrado propio y comprueba que ningún archivo fuera de la capa de datos importa nada de Retrofit.