Ktor Client: plugins, motores y multiplataforma
Ktor Client parte de una premisa arquitectónica opuesta a la de Retrofit: en lugar de declarar el contrato como una interfaz y generar la implementación, construye cada petición de forma explícita y hace pasar la llamada por una tubería de fases donde los plugins se instalan como interceptores. Esta lección explica ese modelo, muestra cómo se resuelven negociación de contenido, autenticación con renovación, tiempos y reintentos sin bibliotecas externas, analiza el papel de los motores intercambiables y ofrece un criterio honesto para decidir entre una y otra.
La discusión entre Retrofit y Ktor suele plantearse como una comparación de comodidad y termina en preferencias personales, cuando en realidad son dos respuestas distintas a una pregunta de diseño anterior: dónde debe vivir el conocimiento sobre la API remota. Retrofit responde que en un tipo del lenguaje, declarado una vez y realizado por generación en tiempo de ejecución; la ventaja es un contrato legible y sustituible, y el precio es una capa de indirección que solo existe en la máquina virtual de Java. Ktor responde que en el propio código que construye la llamada, y organiza su extensibilidad como una tubería de fases sobre la que se instalan plugins; la ventaja es que ese modelo no necesita generación dinámica ni reflexión y por tanto funciona igual en Android, en iOS, en un servidor y en un navegador, y el precio es que el contrato deja de ser un artefacto y pasa a ser una convención del equipo. Elegir sin entender esa asimetría es elegir por sintaxis, y la sintaxis es lo único que da igual.
- Describir el modelo de ejecución de
Ktor Clientcomo tubería de fases y explicar qué es exactamente un plugin dentro de ella. - Configurar negociación de contenido, autenticación con renovación, tiempos de espera y reintentos con los plugins previstos.
- Explicar qué aporta la separación entre interfaz común y motor, y qué implica que el motor en Android sea
OkHttp. - Aplicar un criterio de decisión defendible entre
RetrofityKtorsegún el alcance del proyecto y no según la moda.
Sin interfaz generada, con una tubería de fases
En Ktor no hay contrato declarado ni implementación fabricada al vuelo. Hay un objeto cliente que se configura una vez y funciones de extensión que construyen y ejecutan una petición cuando se las invoca. La respuesta llega como un objeto que se puede interrogar por su código, sus cabeceras o su cuerpo, y la conversión del cuerpo a un tipo concreto se pide explícitamente.
val cliente = HttpClient(OkHttp) {
install(ContentNegotiation) { json(analizador) }
install(HttpTimeout) {
connectTimeoutMillis = 10_000
requestTimeoutMillis = 30_000
socketTimeoutMillis = 15_000
}
defaultRequest {
url("https://api.ejemplo.com/v1/")
header("Accept", "application/json")
}
}
suspend fun producto(id: String): ProductoDto =
cliente.get("productos/$id") {
parameter("expandir", "precio")
}.body()
Lo que ocurre entre la invocación y la respuesta no es una llamada directa al transporte, sino un recorrido por varias tuberías encadenadas, cada una dividida en fases ordenadas. La de petición transforma el objeto que describe la llamada; la de envío la entrega al motor; la de recepción procesa lo que vuelve; y la de transformación de respuesta convierte el cuerpo al tipo que se pidió. Un plugin no es más que un conjunto de manejadores registrados en fases concretas de esas tuberías, más un bloque de configuración propio.
Esa uniformidad tiene una consecuencia valiosa: no hay una jerarquía privilegiada de funcionalidades del núcleo frente a extensiones de terceros. La negociación de contenido, el registro, la autenticación y los reintentos están construidos con el mismo mecanismo que puede usar cualquier plugin escrito por ti, y por tanto se pueden inspeccionar, reordenar o sustituir sin salirse del modelo.
Multiplataforma real
El mismo módulo de red compila para Android, iOS, escritorio, servidor y navegador. No hay generación dinámica ni reflexión en el camino, que es justo lo que impide a otras soluciones salir de la máquina virtual.
Plugins uniformes
Todo lo que hace el cliente son manejadores instalados en fases. Escribir uno propio usa exactamente el mismo mecanismo que usan los oficiales.
Batería incluida
Reintentos con espera creciente, renovación de credenciales, tiempos por petición y registro vienen resueltos de fábrica, sin depender de la capa de transporte.
Concurrencia estructurada
El cliente es un ámbito de corrutinas: cerrarlo cancela lo que quede en vuelo, y cada llamada hereda la cancelación de quien la lanzó.
Los plugins que sustituyen a media capa de infraestructura
La diferencia práctica más visible respecto de una configuración clásica es cuánto deja de escribirse a mano. La autenticación con renovación de credenciales, que en la lección anterior exigía un mecanismo reactivo con contador y sección crítica, aquí es un plugin que ya coordina las renovaciones concurrentes y que distingue la carga inicial del refresco.
install(Auth) {
bearer {
loadTokens { almacen.leer()?.let { BearerTokens(it.acceso, it.refresco) } }
refreshTokens {
val nuevos = clienteSinAuth.post("auth/refrescar") {
setBody(Refresco(oldTokens?.refreshToken.orEmpty()))
}.body<Credenciales>()
almacen.guardar(nuevos)
BearerTokens(nuevos.acceso, nuevos.refresco)
}
sendWithoutRequest { peticion -> peticion.url.host == "api.ejemplo.com" }
}
}
install(HttpRequestRetry) {
retryOnServerErrors(maxRetries = 3)
retryOnExceptionIf { _, causa -> causa is IOException }
exponentialDelay(base = 2.0, maxDelayMs = 8_000)
modifyRequest { it.header("X-Reintento", it.retryCount.toString()) }
}
Merece detenerse en el último detalle del bloque de reintentos, porque encierra una decisión de diseño que en otras bibliotecas hay que construir a mano: la petición reintentada puede marcarse, de modo que el servidor sepa que es una repetición. Esa marca es lo que permite instrumentar cuántos éxitos de tu aplicación son en realidad segundos intentos, un dato que casi nadie mide y que describe la calidad real de la conexión de tus usuarios mucho mejor que cualquier promedio de latencia.
Que el plugin haga trivial reintentar errores de servidor no significa que deba activarse globalmente. Un reintento sobre una operación no idempotente puede duplicar un pedido, y una política agresiva aplicada a toda la aplicación convierte una caída parcial del servidor en una avalancha que impide su recuperación. Configura el reintento por llamada o por grupo de llamadas, exige clave de idempotencia en las escrituras y respeta siempre la cabecera con la que el servidor pide un margen antes del siguiente intento.
Motores: la misma llamada sobre transportes distintos
El cliente separa la interfaz común del transporte concreto. En Android el motor habitual es OkHttp, con lo que todo lo estudiado en la lección anterior sigue vigente: el pool de conexiones, el repartidor, los interceptores y los tiempos siguen existiendo debajo y se pueden configurar desde el bloque del motor. En iOS el motor se apoya en la pila del sistema, en el servidor suele usarse el motor propio basado en corrutinas y en el navegador se traduce a la interfaz de red del entorno.
HttpClient(OkHttp) {
engine {
preconfigured = clienteOkHttpCompartido
// o bien: addInterceptor(...), config { ... }
}
}
Reutilizar un cliente de OkHttp ya existente como motor es, además, la vía de migración menos traumática: permite que la aplicación conserve su configuración de red actual, sus interceptores de diagnóstico y su caché mientras el código de llamada se traslada gradualmente.
flowchart TD A[Codigo que llama] --> B[Tuberia de peticion] B --> C[Plugins de cabeceras y autenticacion] C --> D[Tuberia de envio] D --> E[Motor concreto] E --> F[Transporte del sistema] F --> E E --> G[Tuberia de recepcion] G --> H[Transformacion del cuerpo al tipo pedido] H --> I[Valor tipado] style C fill:#89b4fa,color:#11111b style E fill:#f9e2af,color:#11111b style H fill:#a6e3a1,color:#11111b
Hay dos capacidades más que suelen decidir la elección cuando aparecen en los requisitos, porque en el mundo de las interfaces anotadas son incómodas y aquí son nativas. La primera es el trabajo con flujos: descargar o subir por partes con progreso, y consumir eventos enviados por el servidor sin cargar el cuerpo entero en memoria. La segunda son los sockets bidireccionales, que se expresan como una sesión de corrutina en la que se envían y reciben mensajes con canales, sin salir del mismo modelo de concurrencia que el resto de la aplicación.
Un criterio de decisión que se pueda defender
La pregunta no es cuál es mejor sino cuál resuelve el problema que tienes. Si el proyecto es una aplicación de Android sin ambición multiplataforma, con una capa de red ya escrita y un equipo acostumbrado a leer contratos como interfaces, migrar cuesta semanas y no compra nada que no se pueda conseguir donde ya estás. Si en cambio hay un módulo de datos que quiere compartirse con iOS, la conversación cambia de naturaleza: no se trata de comodidad, se trata de que una de las dos opciones simplemente no puede compilarse allí.
Hay dos criterios secundarios que inclinan la balanza con frecuencia. El primero es la cantidad de infraestructura que uno quiere escribir: reintentos, renovación de credenciales y tiempos por llamada vienen resueltos y uniformes, mientras que en el otro modelo se ensamblan entre el cliente de transporte y el código propio. El segundo es la naturaleza de la comunicación: si hay sockets bidireccionales, transmisión continua o eventos del servidor, el modelo de tubería encaja mejor que el de interfaz declarada.
Existen bibliotecas que generan, mediante procesamiento de anotaciones, implementaciones de interfaces al estilo declarativo cuyo motor de ejecución es Ktor. Permiten conservar el contrato como tipo del lenguaje y a la vez compilar el módulo para varias plataformas. No son la respuesta universal, y merecen estar en la lista de opciones antes de dar por hecho que la elección es binaria.
Debajo de la comparación entre estas dos bibliotecas hay una tensión de diseño mucho más general, que reaparece en cualquier sistema que tenga que hablar con otro y que no se resuelve con ninguna elección tecnológica. La pregunta es si el conocimiento sobre la frontera de tu sistema debe estar reificado en un artefacto único o distribuido por los puntos de uso. Reificarlo tiene virtudes que cuesta apreciar hasta que se pierden: una interfaz anotada es un inventario de todo lo que tu aplicación sabe pedir, se lee de un vistazo, se compara contra la documentación remota, se sustituye entera por un doble en las pruebas y hace que añadir una llamada sea un acto visible en la revisión de código. Distribuirlo tiene otras virtudes igual de reales: no hay ninguna capa que mantener sincronizada con la realidad, cada llamada expresa exactamente lo que necesita sin pasar por un molde común, y las peticiones que no encajan en la forma canónica —una descarga con progreso, una petición con cabeceras calculadas, un socket que se mantiene abierto— dejan de ser excepciones incómodas. El error consiste en creer que la segunda opción elimina el contrato. No lo elimina: lo desplaza a un lugar donde nadie lo mira. Sigue existiendo, sigue siendo tan rígido como antes y sigue rompiéndose cuando el servidor cambia, pero ahora está repartido entre veinte archivos y no hay ningún sitio donde consultarlo. Por eso los equipos que eligen el modelo de tubería y no toman ninguna medida adicional acaban, tres años después, sin saber cuántos puntos finales consume su propia aplicación. La disciplina que compensa esa dispersión es barata y casi nadie la aplica: concentrar todas las llamadas en una capa de servicios explícita, una función por punto final, ningún literal de ruta fuera de ella, y ninguna configuración de cabeceras en el sitio donde se pide el dato. Hecha esa disciplina, el contrato vuelve a estar en un solo lugar, solo que sostenido por convención en lugar de por el compilador. Y ahí está la única diferencia que de verdad importa entre ambas opciones: una te obliga a tener un contrato, la otra te permite tenerlo. Con equipos disciplinados el resultado converge; con equipos apresurados, la obligación vale más que la libertad.
- Reescribe tres llamadas existentes con
Ktorreutilizando tu cliente deOkHttpcomo motor, y comprueba que la caché y los interceptores actuales siguen funcionando. - Instala el plugin de autenticación con renovación, provoca cinco fallos de credencial simultáneos y verifica cuántas renovaciones se disparan.
- Configura reintentos con espera creciente, marca las peticiones repetidas con una cabecera y mide qué porcentaje de tus éxitos son segundos intentos.
- Escribe un plugin propio que registre en cada llamada la fase donde se consumió más tiempo, y explica en qué fase colocaste cada manejador.
- Extrae tu capa de red a un módulo multiplataforma, compílalo para un destino no Android y anota exactamente qué dependencias tuviste que sustituir.