Guía de estudio 12 · Práctica 12

WebSocket, servidores propios y comunicación bidireccional en Android

FastAPI, Docker, WebSocket, protocolos, reconexión, QR y pruebas

35 min de lectura Tema de la Práctica 12 Independiente del ejemplo de la práctica

Introducción

En algunas aplicaciones, el teléfono solicita información y espera una respuesta. En otras, el servidor debe comunicar novedades sin esperar una consulta: un mensaje nuevo, una solicitud de acceso, la actualización de un pedido o el resultado de una operación compartida. Cuando también es necesario enviar datos desde el teléfono de manera continua, una conexión bidireccional puede resultar más adecuada que repetir solicitudes HTTP.

Esta guía estudia el funcionamiento de WebSocket, la organización de un servidor pequeño con FastAPI y Docker, la autenticación de participantes, los mensajes estructurados y el mantenimiento de conexiones desde Kotlin y Jetpack Compose. También analiza una dificultad frecuente: las respuestas tardías de conexiones antiguas pueden alterar el estado de una conexión nueva. Finalmente, se presentan estrategias de reconexión y pruebas reproducibles con servidores simulados.

Los ejemplos utilizan una sala de comunicación genérica y no requieren clonar el proyecto de la práctica. Las decisiones particulares del caso se identifican como tales y se distinguen de los principios aplicables a otros sistemas.

Resultados de aprendizaje

Al estudiar esta guía se espera que el aprendedor pueda:

  1. Seleccionar entre polling, SSE y WebSocket según la dirección y frecuencia de la comunicación.
  2. Explicar la arquitectura de un servidor FastAPI desplegado en Docker con almacenamiento persistente y acceso externo.
  3. Diseñar un protocolo de mensajes tipados y una conexión WebSocket autenticada.
  4. Gestionar el estado de la conexión, las operaciones de reconexión y los recursos asociados al ciclo de vida.
  5. Probar errores de red, solicitudes no autorizadas y condiciones de carrera de forma reproducible.

Lo imprescindible para el examen

Comparar SSE y WebSocket; interpretar handshake, protocolo y autenticación; distinguir estado de conexión de eventos; explicar callbacks obsoletos, reconexión y pruebas reproducibles.

Se evalúa la capacidad de explicar, interpretar fragmentos y justificar decisiones. Las versiones puntuales de librerías, URLs, nombres de proyecto y comandos accesorios quedan como contexto de consulta.

Criterios transversales para interpretar esta guía

Principio transferible. Conviene distinguir la responsabilidad de una tecnología de la forma concreta en que se utilizó en la práctica. Un ejemplo de ViewModel, Retrofit, Hilt o WebSocket ilustra una solución posible; su configuración exacta no es una regla universal.

Decisión del proyecto. Los nombres, rutas, versiones, intervalos, tamaños y elecciones de librerías del caso original sirven para interpretar el código. Salvo indicación expresa, no se exige memorizarlos para el examen.

Alcance del código. Los fragmentos son recursos didácticos; pueden omitir imports, dependencias, permisos, detalles de manejo de errores o infraestructura. Comprender un fragmento no equivale a comprobar que compila como proyecto independiente.

Referencias cruzadas. La síntesis transversal de la colección reúne las distinciones entre estado y evento, persistencia, reintentos, idempotencia y límites de las pruebas.

1. Elegir el mecanismo de comunicación

La necesidad de recibir cambios remotos no implica utilizar siempre la misma tecnología. Conviene identificar quién inicia las interacciones, en qué dirección viajan los datos y durante cuánto tiempo debe permanecer activa la comunicación.

Mecanismo Forma de intercambio Uso típico Limitación principal
HTTP bajo demanda El cliente solicita y recibe una respuesta Consultar perfil o catálogo No informa cambios por sí solo
Polling El cliente pregunta periódicamente Consultar una solicitud pendiente Genera solicitudes aunque nada cambie
SSE El cliente abre una respuesta HTTP de eventos del servidor Noticias, avisos o actualizaciones El canal de eventos es servidor a cliente
WebSocket Tras establecerse, ambos extremos intercambian mensajes Chat, colaboración, presencia Requiere gestionar conexión, fallos y reconexión

SSE y WebSocket pueden convivir con HTTP. Por ejemplo, una aplicación puede crear una solicitud mediante POST, preguntar su estado con GET mientras espera autorización y, una vez autorizada, intercambiar mensajes mediante WebSocket. Que una tecnología permita transportar una operación no significa que sea la opción más simple para ella.

En WebSocket, la aplicación solicita inicialmente una conexión HTTP compatible con el protocolo y, si el servidor acepta, se establece un canal persistente. En HTTP/1.1 esto se realiza mediante Upgrade; también existen mecanismos de establecimiento sobre versiones posteriores de HTTP. Una vez abierta, ambos extremos pueden enviar mensajes sin iniciar una nueva petición HTTP para cada uno.

Ejemplo de selección

En una sala virtual, una persona solicita entrar sin contar todavía con una credencial válida. El cliente crea la solicitud con HTTP. El anfitrión, que tiene una conexión autenticada, recibe la solicitud por WebSocket y responde por ese mismo canal. Cuando la solicitud es aprobada, el invitado obtiene una credencial y establece su propia conexión WebSocket.

Pregunta de análisis. ¿Por qué una pantalla que solo muestra el resultado de una operación cada diez minutos quizá no necesite WebSocket? Porque mantener una conexión tiene costes y responsabilidades adicionales; una consulta puntual o trabajo programado puede responder mejor al requisito.


2. Arquitectura de un servidor propio

Un servidor para una aplicación móvil puede organizarse en cuatro responsabilidades: exposición de endpoints, reglas de acceso, conservación de los datos y distribución de eventos a conexiones activas. La práctica utiliza FastAPI, SQLite, Docker Compose y un túnel para que otros dispositivos puedan alcanzar una máquina de desarrollo.

Teléfono A ───┐
              ├── HTTPS/WSS ── túnel ── FastAPI ── SQLite
Teléfono B ───┘                         │
                                      └─ conexiones WebSocket activas

En el ejemplo, FastAPI ofrece endpoints HTTP para solicitudes e invitaciones y un endpoint WebSocket para intercambiar mensajes. SQLite conserva solicitudes y mensajes en un archivo, mientras que el conjunto de sockets activos se mantiene en memoria del proceso. Esas dos formas de almacenamiento tienen responsabilidades distintas: la historia debe sobrevivir a reinicios; las conexiones abiertas no pueden serializarse para restaurarse como si fueran registros.

2.1 Dockerfile y Docker Compose

Un Dockerfile especifica cómo construir la imagen del servidor. docker-compose.yml describe los servicios, las redes, los puertos y los volúmenes que se ejecutarán conjuntamente.

services:
  api:
    build: .
    ports:
      - "127.0.0.1:8000:8000"
    volumes:
      - datos:/datos

volumes:
  datos:

Aquí, el puerto del servidor se publica en la interfaz local de la computadora. Un dispositivo en otra red no obtiene acceso directo por esa configuración. En el emulador estándar de Android, 10.0.2.2 permite acceder al host de desarrollo, mientras que un teléfono físico necesita una dirección alcanzable mediante red local o un punto de acceso externo.

Un volumen conserva el archivo de base de datos cuando se reemplaza el contenedor. docker compose down elimina los contenedores, pero normalmente conserva los volúmenes con nombre; docker compose down -v también solicita eliminar esos volúmenes y, por tanto, puede destruir los datos.

Para desarrollo, montar el directorio del código y habilitar --reload agiliza la edición. Esas opciones no representan por sí mismas una configuración apropiada para producción.

2.2 SQLite y la persistencia

En una sala simple pueden existir dos tablas:

CREATE TABLE solicitudes (
    id TEXT PRIMARY KEY,
    nickname TEXT NOT NULL,
    estado TEXT NOT NULL,
    token TEXT UNIQUE
);

CREATE TABLE mensajes (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    autor TEXT NOT NULL,
    texto TEXT NOT NULL,
    creado_en REAL NOT NULL
);

La tabla solicitudes conserva las peticiones de ingreso, su estado y la credencial que se entrega cuando una petición es aprobada. mensajes almacena el historial y un identificador que permite ordenar los registros. Para una aplicación con varias réplicas, numerosos participantes o alta concurrencia, este esquema debe revisarse: SQLite y un diccionario local de sockets no resuelven automáticamente el intercambio de eventos entre distintos procesos del servidor.

2.3 Un túnel para desarrollo

Un túnel conecta una dirección pública con un servicio que se ejecuta en la computadora. Es útil para una demostración con dispositivos que no comparten red y evita publicar directamente un puerto en el router.

En la práctica, un servicio cloudflared anuncia una dirección temporal y el servidor consulta esa dirección para construir una invitación. La URL de un túnel rápido puede cambiar al reiniciarlo; un QR que contiene una URL antigua dejará de servir. La exposición pública también exige considerar autenticación, límites de solicitudes, secretos, registro de incidentes y disponibilidad. El túnel no sustituye esos controles.


3. Autenticación y autorización de WebSocket

Un canal persistente requiere identificar a quien intenta abrirlo y decidir qué operaciones se le permitirán. En el ejemplo, el anfitrión conoce una clave privada del servidor; un invitado presenta un token que recibió después de la aprobación. El token se transmite en un encabezado Authorization: Bearer ... durante el establecimiento de la conexión.

from fastapi import WebSocket
import secrets

async def aceptar_si_autorizado(ws: WebSocket, clave: str):
    recibido = ws.headers.get("authorization", "")
    token = recibido.removeprefix("Bearer ").strip()
    if not secrets.compare_digest(token, clave):
        await ws.close(code=1008)
        return False
    await ws.accept()
    return True

Cerrar una conexión antes de accept() rechaza el establecimiento. Con FastAPI/Starlette, el cliente suele recibir una respuesta HTTP 403 cuando se rechaza la conexión de esta forma. El código 1008 representa una infracción de política WebSocket, pero debe distinguirse el rechazo previo al establecimiento de un cierre después de haber aceptado el canal.

Autenticación responde quién presenta la credencial. Autorización determina qué acciones puede realizar esa identidad. Un invitado autenticado puede enviar mensajes, pero aprobar el ingreso de otro usuario debe ser una acción reservada al anfitrión y comprobada por el servidor.

Para generar tokens impredecibles se utiliza una fuente criptográficamente segura, como secrets.token_urlsafe en Python. No conviene construir credenciales con el módulo general random. secrets.compare_digest ayuda a evitar comparaciones cuyo tiempo depende de la posición del primer carácter distinto; esto no reemplaza un diseño completo de autenticación ni mitiga por sí mismo todos los canales laterales.

Decisiones de seguridad que deben revisarse

Las credenciales persistidas en una base no deberían asumirse seguras solo porque su tabla sea privada: deben contemplarse acceso al archivo, respaldo, revocación, duración y protección del secreto. También hay que usar transporte cifrado para un servidor remoto (https:// y wss://), validar las entradas recibidas y no revelar claves de anfitrión en logs o invitaciones. El enlace del QR señala dónde está la sala; no debería conferir por sí solo autorización para entrar.


4. Distribución de mensajes y estado del servidor

Un servidor de chat tiene que mantener un conjunto de conexiones activas y distribuir cada mensaje a los participantes permitidos. La práctica conserva un diccionario en memoria que relaciona la conexión con la identidad.

conectados: dict[WebSocket, str] = {}

async def difundir(evento: dict) -> None:
    for ws in list(conectados):
        try:
            await ws.send_json(evento)
        except Exception:
            conectados.pop(ws, None)

La copia mediante list(conectados) evita iterar directamente sobre un diccionario que puede modificarse durante el envío. En un servidor pequeño, esta estrategia resulta fácil de comprender. Para sistemas de mayor escala conviene implementar separación entre clientes lentos, control de colas, supervisión y un mecanismo de distribución entre procesos.

El eco del servidor es una decisión relevante. En el ejemplo, cuando un usuario envía un mensaje, el servidor primero lo registra y después lo difunde a todos, incluido el remitente. Así la interfaz puede representar lo que fue aceptado por la sala, no solamente lo que el teléfono intentó enviar.

Cliente A ── envía texto ──▶ Servidor
                              │ valida y almacena
                              ├──▶ Cliente A
                              └──▶ Cliente B

Esto evita presentar como confirmado un mensaje que nunca llegó al servidor. Sin embargo, no equivale automáticamente a entrega garantizada a todos los destinatarios: cada conexión puede fallar. Para garantizar recuperación de mensajes perdidos hace falta historial, identificadores y una política explícita de sincronización.


5. Diseñar un protocolo de mensajes tipado

El WebSocket transporta mensajes, pero no define el significado de cada mensaje de una aplicación. Esa responsabilidad corresponde al protocolo de aplicación. Una estrategia es enviar JSON con un campo discriminador llamado tipo.

{"tipo":"mensaje","texto":"Hola"}
{"tipo":"solicitud","id":"s42","nickname":"Luisa"}

Las órdenes enviadas por el cliente y los eventos enviados por el servidor pueden modelarse con interfaces selladas distintas. Esto hace explícito qué operaciones están previstas en cada sentido.

@Serializable
sealed interface Orden {
    @Serializable
    @SerialName("mensaje")
    data class Enviar(val texto: String) : Orden

    @Serializable
    @SerialName("aprobar")
    data class Aprobar(val id: String) : Orden
}

@Serializable
sealed interface EventoServidor {
    @Serializable
    @SerialName("mensaje")
    data class Nuevo(val id: Long, val autor: String, val texto: String) : EventoServidor

    @Serializable
    @SerialName("solicitud")
    data class Solicitud(val id: String, val nickname: String) : EventoServidor
}

El decodificador de kotlinx.serialization puede configurarse para usar tipo como discriminador:

private val json = Json {
    classDiscriminator = "tipo"
    ignoreUnknownKeys = true
}

fun leer(texto: String): EventoServidor? =
    runCatching { json.decodeFromString<EventoServidor>(texto) }.getOrNull()

Con esa implementación, un tipo desconocido o un JSON incompatible se convierte en null. Esto permite que una versión antigua ignore ciertos mensajes nuevos, aunque también oculta errores de formato si todos los fallos se silencian. Para aplicaciones reales conviene registrar diagnósticos sin incluir datos sensibles, distinguir tipos desconocidos de mensajes malformados y definir reglas de compatibilidad y versionado.

ignoreUnknownKeys permite tolerar campos adicionales, pero no hace que un valor desconocido del discriminador se vuelva automáticamente válido. El tratamiento de ese caso depende de cómo se capture el error de deserialización.


6. WebSocket desde OkHttp

En Android, OkHttp proporciona un cliente WebSocket basado en callbacks. newWebSocket inicia el proceso de conexión y devuelve un objeto con el que se pueden enviar mensajes o solicitar el cierre; la apertura efectiva se informa posteriormente mediante onOpen.

val request = Request.Builder()
    .url("https://ejemplo.com/chat")
    .header("Authorization", "Bearer $token")
    .build()

val socket = cliente.newWebSocket(request, object : WebSocketListener() {
    override fun onOpen(webSocket: WebSocket, response: Response) {
        // Conexión establecida
    }

    override fun onMessage(webSocket: WebSocket, text: String) {
        // Interpretar un evento entrante
    }

    override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
        // Diagnosticar el fallo de conexión o del transporte
    }
})

OkHttp permite utilizar un URL http:// o https:// en la construcción de la solicitud y realiza el establecimiento de WebSocket; externamente las direcciones del protocolo suelen expresarse como ws:// y wss://. Es indispensable evitar HTTP sin cifrar hacia servidores de internet, excepto en entornos controlados de desarrollo.

6.1 Los callbacks principales

Callback Significado Responsabilidad típica
onOpen La conexión se abrió Publicar estado conectado
onMessage Llegó un mensaje de texto o binario Decodificar y distribuir el evento
onClosing El otro extremo inició el cierre Responder con cierre ordenado
onClosed Terminó el cierre normal Actualizar el estado y liberar recursos
onFailure Hubo una falla Clasificar el error y decidir si reconectar

send() devuelve un booleano relacionado con la aceptación del mensaje en la cola de envío local. No confirma que el servidor lo haya almacenado ni que otro dispositivo lo haya recibido. Para eso se requieren confirmaciones del protocolo de aplicación, como un evento de eco con identificador.

6.2 StateFlow para estado y SharedFlow para eventos

Un estado de conexión representa la situación actual, por ejemplo: desconectado, conectando, conectado, reconectando o rechazado. StateFlow resulta apropiado porque conserva un valor actual observable por la UI.

Los mensajes recibidos son ocurrencias puntuales que forman un flujo de eventos. Un SharedFlow permite distribuirlos a consumidores activos. Debe configurarse considerando capacidad de búfer, suspensiones y el comportamiento cuando no hay suscriptores.

private val _estado = MutableStateFlow<EstadoConexion>(EstadoConexion.Desconectado)
val estado: StateFlow<EstadoConexion> = _estado.asStateFlow()

private val _eventos = MutableSharedFlow<EventoServidor>(extraBufferCapacity = 64)
val eventos: SharedFlow<EventoServidor> = _eventos.asSharedFlow()

Un SharedFlow con replay = 0 no conserva la historia para nuevos observadores. La lista de mensajes que una pantalla necesita reconstruir debe obtenerse de una fuente de estado o de una consulta de historial, no depender únicamente de haber escuchado todos los eventos mientras estuvo activa.


7. Integración con ViewModel y ciclo de vida

La capa de conexión maneja los sockets; el ViewModel transforma sus eventos en un estado que la pantalla puede mostrar; la pantalla indica cuándo el recurso es necesario.

Servidor ⇄ WebSocket de OkHttp ⇄ ChatConexion
                                     │
                            StateFlow / SharedFlow
                                     │
                                  ViewModel
                                     │
                                Compose UI

En Compose puede emplearse LifecycleStartEffect para abrir una conexión cuando la pantalla alcanza el estado STARTED y cerrarla cuando deja de estar iniciada o el efecto sale de la composición.

LifecycleStartEffect(Unit) {
    viewModel.alAparecer()
    onStopOrDispose { viewModel.alDesaparecer() }
}

La elección depende de la necesidad del producto: un chat que solo sincroniza mientras la pantalla está visible puede cerrar el socket al salir; un requisito de recepción con la app en segundo plano necesita otra estrategia compatible con las restricciones de Android. Mantener un @Singleton no garantiza que el proceso permanezca vivo ni autoriza una conexión ilimitada en segundo plano.

La bienvenida debe reemplazar el historial

Si al conectar el servidor envía los últimos mensajes como evento de bienvenida, esa lista representa una instantánea. Si el ViewModel agrega esa lista a la anterior en cada reconexión, produce duplicados. Una política razonable consiste en reemplazar la instantánea y después agregar nuevos mensajes identificados, evitando repetir el mismo id.


8. Condiciones de carrera: el socket antiguo

La concurrencia introduce un error que puede pasar inadvertido en pruebas manuales rápidas. Imagina que una aplicación se conecta a un servidor remoto A y luego cambia inmediatamente al servidor local B. El cierre de A atraviesa una red lenta, mientras que B abre casi al instante.

Tiempo ───────────────────────────────────────────────▶
Socket A: conectar ────────── cerrar solicitado ───────── onClosed (tarde)
Socket B:                          conectar ── onOpen ──── conectado
Estado:                                      conectado ── desconectado (incorrecto)

El callback tardío de A podría sobrescribir el estado correspondiente a B. El problema no es que el cierre ocurra: es que una conexión ya reemplazada siga modificando los datos compartidos.

Comparar la identidad del socket

@Volatile
private var actual: WebSocket? = null

override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
    if (webSocket !== actual) return
    // Solo el socket vigente puede cambiar el estado o pedir reconexión.
}

En Kotlin, !== compara si dos referencias señalan el mismo objeto. En este caso importa la identidad exacta de la conexión que generó el callback. != comprueba desigualdad mediante equals, que expresa otro contrato.

@Volatile ayuda a publicar el valor más reciente de la referencia entre hilos, pero no convierte en atómica una secuencia de decisiones. Si una implementación modifica varios campos compartidos (actual, peticion, contador, trabajo de reconexión), puede necesitar sincronización, confinamiento a un único dispatcher o un mecanismo de generaciones. En arquitecturas más amplias, un identificador incremental de conexión permite descartar todos los callbacks de generaciones anteriores.

¿Por qué el error es difícil de reproducir?

En localhost, las conexiones suelen cerrarse muy rápido y los callbacks llegan en un orden favorable. Al atravesar un túnel, una red móvil o una conexión inestable, los tiempos cambian. Una prueba adecuada debe reproducir el orden problemático deliberadamente: retrasar la respuesta de cierre de A mientras B se conecta.


9. Reconexión y backoff exponencial

Una interrupción temporal puede deberse al cambio de red, la caída del servidor o un cierre inesperado. La reconexión automática mejora la experiencia, pero reintentar continuamente puede agravar una falla de infraestructura y consumir batería.

El backoff exponencial aumenta la espera entre intentos y establece un máximo.

fun esperaAntesDeReintentar(intento: Int): Long {
    val exponente = (intento - 1).coerceIn(0, 5)
    return (1_000L shl exponente).coerceAtMost(30_000L)
}
Intento Espera ilustrativa
1 1 segundo
2 2 segundos
3 4 segundos
4 8 segundos
5 16 segundos
6 y siguientes Hasta 30 segundos

La tabla representa la política de la práctica, aunque el código mostrado produce 30 segundos a partir del sexto intento debido al tope. Para una población grande de clientes resulta conveniente añadir jitter (una variación aleatoria) para evitar que todos reconecten simultáneamente.

No todos los fallos deben reintentarse de la misma manera. Una denegación de acceso 403 suele indicar un problema de credenciales o permisos que requiere intervención o renovación de autorización. Un fallo transitorio de transporte puede justificar otro intento. El cliente también debe cancelar un reintento programado cuando el usuario cierra la pantalla, cambia de servidor o cierra sesión.

La configuración de pingInterval permite que el cliente compruebe periódicamente la vitalidad del canal mediante tramas ping/pong. Un ping no respondido puede ayudar a detectar conexiones que aparentan seguir abiertas; sus intervalos deben equilibrar latencia, tráfico y consumo energético.


10. Invitaciones, enlaces y códigos QR

Un código QR puede transportar la dirección de una sala. El QR no añade seguridad criptográfica ni garantiza que el destino sea confiable: solo codifica información para facilitar su transferencia.

En la práctica, la invitación usa la dirección pública del túnel. El flujo de ingreso es:

  1. El servidor entrega la URL pública de la sala.
  2. La aplicación genera un QR y también permite copiar el enlace.
  3. El invitado escanea, elige una captura o pega la dirección.
  4. La aplicación valida el destino y envía una solicitud HTTP.
  5. Cuando el anfitrión aprueba, el servidor entrega un token de invitado.
  6. El invitado abre el WebSocket autenticado.

Generación y lectura

La práctica utiliza ZXing para crear el código y alternativas de escaneo basadas en servicios del dispositivo y ML Kit para leer códigos desde imágenes. El trabajo intensivo de generación de píxeles se mueve fuera del hilo principal mediante Dispatchers.Default. Un QR necesita dimensiones, contraste y un margen que permitan leerlo de manera confiable, también en modo oscuro.

Algunas opciones de lectura permiten delegar la cámara a otra aplicación o servicio, sin que la app de origen solicite acceso directo a la cámara. Una integración con CameraX, por el contrario, necesita gestionar su propio permiso de cámara y ciclo de vida.

Validación del enlace

Los enlaces recibidos por QR o portapapeles son entrada no confiable. Antes de conectarse, el cliente debe comprobar esquema, host, puerto, caracteres y política de destinos permitidos. Aceptar cualquier URL puede exponer credenciales o permitir que la app haga solicitudes a destinos no previstos. Extraer https://host:puerto de un texto es una transformación, no una validación de confianza completa.


11. Cambiar entre servidores y conservar el destino

Una app puede tener una sala propia y permitir visitar otras. El destino seleccionado puede representarse como dato persistente, por ejemplo en DataStore, y observarse como Flow. El ViewModel reacciona a los cambios de destino, detiene la conexión anterior y establece una nueva cuando la pantalla está activa.

Esta organización separa dos acciones que conviene no confundir: seleccionar el destino y abrir físicamente el WebSocket. La selección es una decisión de estado que puede persistir; la conexión es un recurso efímero que se recrea.

Interacción del usuario ──▶ destino en DataStore
                                  │
                                  ▼
                              ViewModel
                                  │
                     cerrar A / iniciar B
                                  ▼
                              ChatConexion

Hay que cuidar especialmente el almacenamiento de credenciales asociado a cada destino. Guardar una URL no implica autorización para esa sala, y un token de invitado no debería mezclarse con la clave del anfitrión ni reutilizarse en otro servidor.


12. Pruebas automatizadas del servidor

FastAPI permite probar endpoints y conexiones WebSocket con un cliente de pruebas. En la práctica, pytest se ejecuta dentro del contenedor usando el conjunto de dependencias fijado por el proyecto.

Una prueba puede comprobar que una persona sin credenciales no accede al chat:

def test_sin_token_se_rechaza(cliente):
    with pytest.raises(WebSocketDisconnect) as resultado:
        with cliente.websocket_connect("/chat") as ws:
            ws.receive_json()
    assert resultado.value.code == 1008

En una prueba del servidor es importante distinguir el código de cierre observado por el cliente de pruebas del código HTTP de un handshake rechazado en un cliente externo; no siempre se exponen de la misma forma.

También se prueban reglas de negocio como longitud de nickname, duplicados, autorización para aprobar invitados y reconstrucción del historial. Una base SQLite en memoria creada para cada prueba permite que los resultados sean independientes y no dañen datos reales.

@pytest.fixture
def cliente(monkeypatch):
    monkeypatch.setattr(datos, "db", datos.Datos(":memory:"))
    with TestClient(app) as c:
        yield c

El uso de monkeypatch ilustra una aplicación directa de la inyección de dependencias estudiada en la Guía 09: sustituir infraestructura real por una implementación controlada.


13. Pruebas de WebSocket en la JVM

El cliente Android puede probar su lógica de conexión con MockWebServer de OkHttp. A diferencia de una implementación falsa que simula el método send, MockWebServer puede establecer conexiones WebSocket de prueba y controlar qué mensajes y cierres recibe el cliente.

Una batería útil comprende cuatro categorías:

  • Protocolo: codificar y decodificar mensajes, y tolerar versiones desconocidas.
  • Autenticación: comprobar la reacción frente a un handshake rechazado.
  • Conectividad: informar conexión, recepción, cierre y reconexión.
  • Condiciones de carrera: forzar que el cierre de A llegue después de la apertura de B.
@Test
fun espera_aumenta_sin_superar_el_limite() {
    assertEquals(1_000L, esperaAntesDeReintentar(1))
    assertEquals(2_000L, esperaAntesDeReintentar(2))
    assertEquals(4_000L, esperaAntesDeReintentar(3))
    assertEquals(30_000L, esperaAntesDeReintentar(7))
}

La prueba del callback tardío

Primero se abre una conexión A. Después se solicita su cierre, pero el servidor de prueba retrasa su respuesta. Antes de que A termine, se abre B y se verifica que su onOpen produzca el estado conectado. Finalmente se permite que llegue el onClosed de A: el estado debe permanecer conectado y no debe iniciarse un reintento de A.

La prueba no debe depender de tener suerte con la latencia de internet. Se controla deliberadamente el orden de los eventos y, cuando sea posible, el tiempo mediante despachadores de prueba. Este enfoque conecta con las pruebas de concurrencia de la Guía 11.

En JUnit 4, una función de prueba debe devolver Unit (void en Java). La forma fun prueba() = runBlocking { ... } puede inferir un tipo de retorno distinto si la última expresión produce un valor. El patrón de bloque explícito evita esa ambigüedad:

@Test
fun prueba_del_canal() {
    runBlocking {
        // Preparar, actuar y comprobar.
    }
}

Conexión transversal: alcance y decisiones

Eventos, estado y entrega de mensajes

El estado de conexión (Conectado, Reconectando, Rechazado) se representa bien como estado actual con StateFlow. Cada mensaje o solicitud entrante puede emitirse como evento mediante SharedFlow, pero su entrega depende de los colectores y de la configuración del flujo. Si el sistema necesita recuperar mensajes no vistos, debe apoyarse en historial persistente, cursores o confirmaciones del protocolo.

Difundir a todos, incluido el emisor, sincroniza la vista de la sala, pero no demuestra entrega exactamente una vez. Un mensaje reenviado después de una desconexión puede requerir ID de cliente e idempotencia del servidor. El control webSocket !== actual evita que un callback antiguo altere una conexión nueva, sin hacer atómica toda operación concurrente. Véanse las guías 03, 08 y 11.

14. Diagnóstico y decisiones de implementación

Síntoma Posible causa Qué comprobar
El cliente no se conecta al servidor local Dirección errónea o restricción de red local URL, puerto, permiso aplicable y conectividad
El handshake recibe 403 Falta token, token inválido o política de acceso Credencial, servidor y respuesta de apertura
Aparecen mensajes duplicados tras reconectar El historial se agrega a la lista existente Política de reemplazo y deduplicación por id
El estado cambia a desconectado después de abrir una conexión nueva Callback tardío del socket anterior Guardia de identidad/generación
El cliente intenta conectar sin parar Reintento sin límite ni cancelación Backoff, clasificación de fallos y ciclo de vida
La invitación deja de funcionar La URL temporal del túnel cambió Generar otra invitación y validar el nuevo destino
La prueba solo falla cuando hay red real Dependencia de tiempos no controlados Simular el orden de callbacks y el servidor
Un mensaje aparece en pantalla pero no en el historial Actualización optimista sin confirmación Confirmaciones y persistencia del servidor

Criterios para elegir una solución

WebSocket resulta apropiado cuando hay intercambio frecuente, bidireccional y de baja latencia mientras la aplicación está activa. SSE puede simplificar actualizaciones unidireccionales. Polling resulta suficiente para estados que cambian poco o durante una espera corta. En todos los casos, la tecnología elegida debe integrarse con autenticación, recuperación ante fallos, observabilidad y las restricciones del ciclo de vida Android.


15. Vocabulario esencial

Término Definición
WebSocket Protocolo de intercambio bidireccional sobre una conexión persistente
Handshake Intercambio inicial para establecer o rechazar una conexión
Upgrade Mecanismo HTTP/1.1 para cambiar al protocolo WebSocket
Broadcast Distribución de un evento a múltiples conexiones
Polling Consultas periódicas iniciadas por el cliente
SSE Flujo de eventos unidireccional desde servidor hacia cliente
StateFlow Flujo que conserva y expone un estado actual
SharedFlow Flujo compartido de emisiones hacia colectores activos
Protocolo de aplicación Formato y significado acordados de mensajes y operaciones
Interfaz sellada Jerarquía con variantes conocidas y controladas por Kotlin
Token Credencial que permite representar autorización de acceso
@Volatile Garantía de visibilidad de una variable entre hilos, sin atomicidad compuesta
!== Comparación de identidad entre referencias en Kotlin
Backoff exponencial Aumento progresivo de espera entre reintentos
Jitter Variación aleatoria para distribuir intentos en el tiempo
Túnel Conexión que expone un servicio local mediante un punto de acceso externo
MockWebServer Servidor controlado usado para probar clientes HTTP y WebSocket

16. Preguntas de autoevaluación

Comprensión conceptual

  1. ¿Qué diferencias existen entre polling, SSE y WebSocket? ¿En qué situaciones usarías cada uno?
  2. ¿Qué establece el handshake de WebSocket y qué sucede cuando el servidor lo rechaza antes de aceptar?
  3. ¿Por qué los mensajes y las conexiones activas no se almacenan necesariamente de la misma forma?
  4. ¿Cuál es la diferencia entre autenticación del WebSocket y autorización de las órdenes que recibe?
  5. ¿Por qué el cliente podría mostrar un mensaje solo cuando recibe la confirmación del servidor?
  6. ¿Qué ventaja tiene separar las órdenes salientes de los eventos entrantes mediante interfaces selladas?
  7. ¿Qué diferencia funcional existe entre StateFlow y SharedFlow en una aplicación de chat?
  8. ¿Por qué un objeto @Singleton no mantiene por sí solo la conexión cuando Android termina el proceso?

Interpretación de código

  1. ¿Qué comprueba if (webSocket !== actual) return y por qué importa que sea una comparación de identidad?
  2. ¿Qué limitación tiene @Volatile cuando varios campos de la conexión deben cambiar juntos?
  3. ¿Qué valores devuelve la función de espera en los intentos 1, 3 y 7?
  4. ¿Qué significa que newWebSocket devuelva un objeto antes de que se ejecute onOpen?
  5. ¿Qué indica que WebSocket.send() devuelva true? ¿Qué información todavía falta para confirmar la entrega?
  6. ¿Qué efecto tiene ignoreUnknownKeys = true cuando aparece un nuevo campo en el JSON?

Aplicación y diagnóstico

  1. Una aplicación cambia del servidor A al B, se conecta a B y segundos después aparece «Desconectado». Explica una posible causa y su solución.
  2. La red se interrumpe y 2,000 dispositivos intentan reconectar cada segundo. ¿Qué cambiarías en la política de reintento?
  3. Al reconectar, la pantalla duplica todo el historial. ¿Qué dato debería reemplazarse y qué eventos deberían agregarse?
  4. ¿Qué riesgos aparecen cuando una app se conecta automáticamente a cualquier enlace escaneado desde un QR?
  5. ¿Cómo construirías una prueba que garantice que un cierre viejo no cambia el estado de un socket nuevo?
  6. ¿Qué pruebas mínimas desarrollarías para un chat con anfitrión e invitados sin depender del servidor real?

17. Respuestas posibles

1. Polling repite solicitudes y se adapta a cambios poco frecuentes o consultas breves. SSE mantiene un flujo de eventos servidor-cliente. WebSocket sostiene comunicación en ambos sentidos, útil para conversaciones interactivas y colaboración simultánea.

2. Durante el handshake el cliente solicita abrir el canal y el servidor evalúa si acepta. Si FastAPI rechaza antes de accept(), un cliente HTTP/WebSocket externo puede recibir 403. Esto es diferente de cerrar una conexión que ya se había establecido.

3. Los mensajes son datos duraderos que deben recuperarse después de reinicios. Un socket es un recurso activo del proceso y no se puede guardar en SQLite para volver a utilizarlo tras reiniciar. La lista de conectados se reconstruye mediante nuevas conexiones.

4. Autenticación significa comprobar la credencial presentada por un participante. Autorización significa decidir si ese participante puede enviar mensajes, aprobar solicitudes o realizar otras acciones; el servidor debe validar cada operación protegida.

5. Porque intentar enviar no confirma el almacenamiento. El eco del servidor sirve como confirmación del comportamiento acordado. Aun así, puede ser necesario un protocolo adicional para garantizar recepción por otros dispositivos.

6. Define claramente los mensajes válidos en cada sentido, facilita la serialización y permite usar un when exhaustivo sobre las variantes conocidas. También ayuda a detectar inconsistencias entre el contrato del servidor y la app.

7. StateFlow ofrece el estado vigente de la conexión y permite que un nuevo observador conozca ese valor. SharedFlow representa emisiones sucesivas; con replay = 0, un observador nuevo no recibe automáticamente mensajes anteriores.

8. @Singleton controla la cantidad de instancias dentro de un componente de dependencias; no conserva el proceso de Android. Si el proceso termina, el objeto y su socket desaparecen. Es necesario volver a crear los recursos cuando corresponda.

9. La condición descarta callbacks originados por una instancia de WebSocket reemplazada. Se usa identidad porque cada objeto representa una conexión concreta; una conexión vieja no puede decidir el estado de la vigente.

10. @Volatile da visibilidad entre hilos al valor de la referencia. No asegura que modificar actual, peticion, reintento e intentos sea una operación atómica. Para eso puede requerirse sincronización o gestionar todo desde un único contexto de ejecución.

11. Intento 1: 1,000 ms. Intento 3: 4,000 ms. Intento 7: 30,000 ms por el tope de la política. La progresión evita insistir demasiado rápido cuando el servidor no responde.

12. La operación inició la conexión, pero el handshake aún puede estar pendiente. El éxito se conoce al recibir onOpen; también puede llegar onFailure si la negociación o la red fallan.

13. Que el cliente aceptó encolar el mensaje para el socket, sujeto a su estado y capacidad. No confirma que el servidor lo haya procesado. Una confirmación debe venir del protocolo, por ejemplo un evento con id asignado después del almacenamiento.

14. Permite deserializar cuando llegan campos JSON no reconocidos. Si lo desconocido es un tipo completo del discriminador, la deserialización puede fallar; habría que capturarlo o definir una política específica de compatibilidad.

15. Puede llegar tarde onClosed de A y sobrescribir el estado de B. Se comprueba qué socket originó el callback y se ignora si ya no es el vigente. La prueba debe forzar el orden problemático de los eventos.

16. Se agrega backoff exponencial con límite y jitter, además de cancelar reconexiones innecesarias. Así se reducen las peticiones concurrentes al servidor durante una recuperación de infraestructura.

17. La bienvenida con el historial es una nueva instantánea y debe reemplazar la historia local según la política del producto. Los nuevos eventos se incorporan comprobando sus identificadores para evitar repetidos.

18. El QR puede contener una dirección manipulada, un servidor falso o una URL que no debe recibir credenciales. Deben validarse el esquema, el host y la política de destinos antes de enviar datos sensibles o establecer conexiones.

19. Se inicia A y se retrasa deliberadamente su cierre; después se inicia B y se espera onOpen. Finalmente se deja llegar el cierre de A y se comprueba que el estado permanezca conectado a B y que no aparezca un reintento adicional.

20. Conviene comprobar rechazo de participantes no autorizados, aprobación exclusiva del anfitrión, entrega de mensajes, bienvenida con historial, tratamiento de tipos desconocidos, reconexión tras un corte, ausencia de reconexión ante rechazo y el bug de cierre tardío. MockWebServer permite ejecutar pruebas del cliente de forma reproducible.


Preguntas finales de reflexión

  1. ¿Qué características del producto justificarían mantener un canal WebSocket abierto frente a utilizar SSE o consultas HTTP?
  2. ¿Cómo organizarías la autenticación si una aplicación permite ingresar a distintos servidores administrados por organizaciones independientes?
  3. ¿Qué debería ocurrir con los mensajes escritos durante una desconexión: descartarlos, encolarlos o solicitar confirmación antes de mostrarlos?
  4. ¿Qué mecanismos adicionales necesitaría el servidor para soportar varias réplicas y miles de conexiones simultáneas?
  5. ¿Qué condiciones de carrera podrías reproducir artificialmente antes de que se manifiesten en un entorno de producción?