Por qué una reescritura de middleware de Next.js devuelve 500 detrás de un proxy inverso
El síntoma: verde en staging, 500 en producción
Hace poco lanzamos una función de internacionalización en nuestro sitio de marketing: un selector de idioma por prefijo de ruta que sirve inglés en / y español bajo /es. El mecanismo es una pequeña pieza de middleware de Next.js que reescribe las solicitudes sin prefijo hacia un árbol de rutas interno /en para que la URL se mantenga limpia.
Pasó todas las verificaciones: comprobación de tipos, pruebas unitarias, una compilación de producción local y un despliegue completo en staging con una comprobación de salud en verde. Promovimos exactamente el mismo commit a producción. En cuestión de segundos la comprobación de salud posterior al despliegue se puso en rojo —HTTP 500 en todas las rutas— y nuestra reversión automática restauró la versión anterior. Producción nunca llegó a caerse, pero la nueva versión no servía ni una sola página.
El dato más útil y más engañoso: una compilación de producción local del commit idéntico servía todas las rutas con un 200. Staging también. Solo producción fallaba. Esa diferencia es toda la historia.
La pista: un handshake TLS contra un puerto en texto plano
Los registros de la aplicación contaban la historia real. Cada solicitud producía:
Failed to proxy https://localhost:3000/en Error: write EPROTO ... ssl3_get_record:wrong version number
Dos cosas destacan. Primero, la aplicación estaba intentando hacer proxy de una solicitud hacia sí misma: https://localhost:3000/en. Segundo, EPROTO ... wrong version number es la firma clásica de hablar TLS con un socket en texto plano: algo envió un handshake HTTPS a un puerto que solo entiende HTTP.
La causa raíz: X-Forwarded-Proto más una reescritura relativa al origen
Nuestra topología de producción es convencional. Un proxy inverso termina TLS y reenvía al servidor standalone de Next.js por HTTP plano en localhost:3000, estableciendo X-Forwarded-Proto: https para que la aplicación sepa que la solicitud original era segura.
El middleware construía su destino de reescritura a partir de la propia URL de la solicitud entrante:
const url = request.nextUrl.clone()
url.pathname = "/en" + pathname
return NextResponse.rewrite(url)
Detrás del proxy, request.nextUrl toma su protocolo de X-Forwarded-Proto (https) pero su host del enlace en texto plano (localhost:3000). El destino de la reescritura resuelve por tanto a https://localhost:3000/en, un origen que la aplicación trata como externo, de modo que Next emite una solicitud proxy real hacia él. Esa solicitud habla TLS con un puerto que solo sirve HTTP, Node lanza EPROTO y la solicitud devuelve 500.
Directamente por HTTP —un next start local, o una comprobación de salud de staging que llega a la aplicación en localhost sin un proxy que termine TLS—, X-Forwarded-Proto está ausente, la reescritura se mantiene en el mismo origen y Next la gestiona internamente. Verde. El error es invisible a menos que la solicitud llegue a través del proxy inverso HTTPS.
La solución: mantener el loopback en HTTP
Un servidor standalone de Next.js nunca termina TLS por sí mismo; TLS siempre se termina aguas arriba. Así que la reescritura interna debe apuntar al protocolo que el servidor realmente habla —HTTP—, independientemente del esquema externo:
const url = request.nextUrl.clone()
url.protocol = "http:" // the loopback is always plain HTTP; TLS is terminated upstream
url.pathname = pathname === "/" ? "/en" : "/en" + pathname
return NextResponse.rewrite(url)
Una línea. Con el protocolo fijado en http:, la reescritura se mantiene como un salto interno dentro del mismo proceso, y todas las rutas devuelven 200: en producción, detrás del proxy, exactamente como lo hacían en local.
Reproducirlo antes de lanzar
La lección que generaliza más allá de este error: reproduzca con la forma real de la solicitud. Nuestra reproducción local era engañosa porque omitía la única cabecera que añade el proxy inverso. Reproducir la solicitud contra la compilación standalone con esa cabecera reproduce la falla al instante:
curl -s -o /dev/null -w "%{http_code}" \
-H "X-Forwarded-Proto: https" \
http://127.0.0.1:3000/
# 500 before the fix, 200 after
Tres conclusiones
- Una reescritura de middleware detrás de un proxy que termina TLS debe fijar el protocolo interno en HTTP. Cualquier cosa derivada de
X-Forwarded-Protointentará que el loopback hable TLS con un puerto en texto plano. - Las comprobaciones de salud deben ejercitar la ruta real de la solicitud. La nuestra llegaba a la aplicación directamente por HTTP, de modo que staging no podía detectar un error que solo aparece a través del proxy inverso. Una comprobación que pase por el proxy —o que simplemente envíe
X-Forwarded-Proto: https— habría fallado en staging en lugar de en producción. - La reversión automática y una comprobación de salud estricta en producción se ganan su lugar. Una verificación que falla solo con 500 detectó lo que otras más laxas habrían dejado pasar, y la reversión mantuvo la versión anterior sirviendo mientras diagnosticábamos. El incidente costó minutos de un despliegue en rojo, no una interrupción del servicio.
Los proxies inversos y el middleware de los frameworks hacen, cada uno, suposiciones razonables sobre protocolo y origen. Las fallas viven en la costura entre ambos, y solo salen a la luz en el entorno que tiene los dos.