Nota

HTTP/2 en el tráfico interno: una clase conocida de trampas

Una conexión para todas las peticiones, una ventana de flow control compartida y un pool que nunca expulsa lo muerto: por qué los servicios HTTP/2 se cuelgan «sin errores» y qué hicieron Go y gRPC.

HTTP/2 se convirtió hace tiempo en el estándar de la web de navegadores y desde allí fue colándose en la comunicación entre servicios — casi siempre sin que nadie lo note, a lomos de una actualización de librería o de plataforma. Ayer tu cliente hablaba con el servicio vecino por HTTP/1.1; hoy, tras subir de versión, ya habla HTTP/2. El código no cambió, todo funciona… hasta la primera pausa, el primer pico de tráfico o el primer paquete perdido.

Este artículo trata del conjunto de trampas que HTTP/2 trae al tráfico interno. Un conjunto bien conocido: la comunidad de gRPC lo sufrió hace unos diez años y se blindó con protecciones, la de Go algo más tarde y con issues sonados. Pero si tu stack recibió HTTP/2 «gratis», lo más probable es que esas protecciones no estén.

Qué cambia HTTP/2 — y por qué cada mejora se vuelve en tu contra

Frente a HTTP/1.1 hay tres cambios de fondo, cada uno con su reverso.

Una conexión en lugar de un pool. El cliente abre un único socket TCP y multiplexa en él todas las peticiones en paralelo. Ahorra handshakes y puertos — pero ahora todas las peticiones comparten destino: lo que le pase a ese socket tumba de golpe todas las peticiones del servicio. En HTTP/1.1 una conexión rancia del pool costaba una petición fallida; en HTTP/2 puede costar el servicio entero.

Multiplexación de streams. Las peticiones viajan entrelazadas, cada una con su stream id. Cómodo — pero el estado de la sesión (contadores de streams, límites, colas) pasa a ser un recurso compartido que puede fugarse o agotarse. Un error de contabilidad en una petición afecta a todas las siguientes.

Flow control a nivel de sesión. Junto a la ventana de cada stream existe una ventana común para toda la conexión: el receptor confirma los bytes consumidos (WINDOW_UPDATE) y el emisor no puede enviar más allá de la ventana. Si el receptor deja de leer, la ventana cae a cero y todos los streams de la conexión se congelan. Es intencional: el RFC advierte abiertamente de que un manejo descuidado del flow control acaba en deadlocks.

De estas tres propiedades salen cuatro escenarios típicos que en producción se ven idénticos: «el servicio dejó de responder, solo ayudó un reinicio».

Trampa 1: la conexión medio abierta

Entre dos servicios en producción casi siempre hay alguien en medio: un balanceador, un proxy, conntrack, NAT. Muchos tienen timeouts de inactividad y matan conexiones de formas distintas — unos con RST honestos en ambas direcciones, otros borrando en silencio la entrada de su tabla. Súmale la pérdida mundana de un paquete FIN al cerrar. El resultado es el mismo: el cliente sostiene un socket ESTABLISHED cuando al otro lado ya no queda nadie. El TCP puro lo notará solo al escribir, tras decenas de minutos de retransmisiones. Con HTTP/1.1 esa conexión mataba una petición; con HTTP/2 todas se multiplexan hacia el socket muerto. Una forma de sacar esa capa de la aplicación es un mesh sin sidecar.

Trampa 2: el deadlock del flow control

Un servidor bajo carga (pausa de GC, operación pesada, pool de hilos agotado) deja de leer los cuerpos de las peticiones. La ventana común de la sesión llega a cero y todos los emisores se paran. Si el servidor nunca vuelve a leer, la conexión queda atascada para siempre — con un aspecto perfectamente sano a nivel TCP. La señal diagnóstica en el lado del servidor: sockets en CLOSE_WAIT con bytes sin leer en Recv-Q — los clientes se cansaron y se fueron, el servidor nunca leyó ni cerró.

Trampa 3: el pool que nunca expulsa lo muerto

La trampa más traicionera vive en el cliente. Uno esperaría: las peticiones caducan, pues tira la conexión y abre otra. Pero muchas implementaciones necesitan un error de socket para expulsar — y no lo hay: el socket vive (trampa 1) o vive formalmente (trampa 2). Las peticiones individuales fallan, la sesión sigue «válida», el pool la sigue repartiendo. El ejemplo de manual es golang/go#39750: una conexión HTTP/2 muerta queda cacheada en el pool para siempre, porque las peticiones nuevas, al subirse a ella, la marcan como «ocupada» — y el limpiador de conexiones inactivas tampoco la toca.

Trampa 4: la pelea por una única tubería

Incluso sin fallos: N workers escribiendo cuerpos grandes en paralelo por una sesión comparten una ventana de flow control y un búfer TCP. Un pico de carga crea por sí solo las condiciones del deadlock de la trampa 2 — y luego se mete de cabeza en él. El perfil «escritor batch con diez hilos» sobre HTTP/2 sin ajustar ventanas es un incidente esperando fecha.

Cómo desactivaron estas trampas los mayores

Todos los ecosistemas HTTP/2 maduros convergieron en el mismo conjunto de protecciones:

ProtecciónQué aportaQuién la hizo default
Health checks con PINGUna conexión medio abierta se detecta en segundos, no en media horagRPC (keepalive), Go (ReadIdleTimeout)
Límite de edad de la conexiónLa conexión se rota antes de que llegue a pudrirsegRPC (MAX_CONNECTION_AGE, con jitter contra tormentas de reconexión)
Timeout de stream atascado (servidor)Un stream sin progreso se resetea y devuelve su ventana a la sesiónJetty (streamIdleTimeout), servidores gRPC
Ventanas de tamaño sensatoLos cuerpos grandes en paralelo no agotan la ventana de sesiónAjuste según carga; los defaults suelen ser pequeños

Es revelador por qué nacieron estas protecciones: la documentación de gRPC nombra las causas sin rodeos — proxies que matan conexiones inactivas y FIN perdidos tras los cuales TCP calla hasta media hora. Exactamente las trampas 1 y 3 de la lista, aprendidas en producciones ajenas hace años.

Nuestro caso: todas las trampas en un solo incidente

Una ilustración fresca de nuestra propia práctica. Tras actualizar Solr de la versión 9 a la 10, la librería cliente SolrJ pasó a HTTP/2 (h2c en claro). El servicio de indexación empezó a colgarse del todo cada pocos días — todas las peticiones a Solr en timeout, curable solo con un reinicio. Su vecino, que escribe el mismo contenido con el mismo cliente, funcionaba sin un solo error.

La explicación encajó punto por punto con la lista: el vecino envía las peticiones de una en una (un POST solitario no puede agotar la ventana de sesión), mientras que el scheduler del indexador, tras cada pausa, vuelca su backlog en diez workers paralelos a través de una sesión (trampa 4) justo cuando Solr está ocupado con commits y deja de leer (trampa 2). En el servidor — una colección de sockets CLOSE_WAIT con kilobytes sin leer; en el cliente apareció también el socket medio abierto (trampa 1). Y el cliente SolrJ, joven y sin instrumentación de PING, no expulsó ni una sola vez la sesión atascada de su pool (trampa 3). La teoría inicial — «es la red, conntrack mata las conexiones inactivas» — no sobrevivió a los hechos. La red resultó inocente, algo típico en esta clase de problemas: la primera sospechosa siempre es la red.

El tratamiento salió también del kit estándar: streamIdleTimeout en el servidor, una ventana de sesión mayor, un idle timeout más alto — y, en lo estratégico, mover el escritor batch a HTTP/1.1: para su perfil de carga, las conexiones aisladas de un pool ganan a la tubería compartida.

Checklist antes de adoptar HTTP/2 entre servicios

  • ¿Tu cliente envía PING por una conexión inactiva? Si no — ¿cómo se enterará de un socket medio abierto?
  • ¿Puede el pool expulsar una sesión tras una serie de timeouts, y no solo tras un error de socket?
  • ¿Está limitada la edad de la conexión? ¿Qué pasa en un deploy o un scale-out del servidor — verán los clientes los endpoints nuevos?
  • ¿Resetea el servidor los streams sin progreso, o un stream atascado vive para siempre reteniendo la ventana?
  • ¿Cuántos cuerpos grandes en paralelo mete tu perfil de carga en una sesión? ¿Alcanza la ventana?
  • Y una pregunta honesta para cerrar: ¿de verdad necesitas HTTP/2 aquí? Para «una docena de RPS entre dos pods», un pool de conexiones HTTP/1.1 es más aburrido — y bastante más resistente.

Conclusión

HTTP/2 es un buen protocolo con un trade-off honesto: economía de conexiones a cambio del destino compartido de las peticiones. Cuenta con que habrá instrumentación madura alrededor — pings, rotación, timeouts de stream. En la web de navegadores y en gRPC viene de serie. Cuando HTTP/2 llega «gratis» a tu tráfico interno, nadie envía la instrumentación con él — y te queda una clase de fallos con un síntoma inconfundible: «todo colgado, sin errores, ayudó un reinicio». Ahora ya sabes qué aspecto tiene y dónde mirar: ss -tan en ambos lados más Recv-Q/Send-Q cuentan más que cualquier log. Y sobre cuándo puede darse por cerrado un incidente así hay una nota aparte sobre severidad y definición de «recuperado».

© 2026 axyi.ru · CC BY 4.0