From 664417c6dafe298bb0a8eb9e87208c2cd1f98ed6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mats=20T=C3=B6rnberg?= Date: Tue, 22 Sep 2026 11:21:52 +0200 Subject: [PATCH] fix(server): raise keepAliveTimeout above ingress-nginx's upstream idle timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Node's default keepAliveTimeout (5s) is shorter than ingress-nginx's default upstream keepalive idle timeout (60s). Under that mismatch, nginx can forward a request onto a pooled connection Node has *just* closed, which the browser sees as a 502 — and it never shows up in this process's own traces because Node never received the request. /api/rooms is polled every 5s from idle clients, landing almost exactly on that 5s boundary, which is why it was the one showing up for external users. Also add abort/slow-request logging (tagged with the active trace ID so it can be pivoted to from RUM) and a clientError handler, so a genuine backend-side failure is now visible instead of indistinguishable from the proxy-side race. Co-Authored-By: Claude Sonnet 5 --- server.js | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/server.js b/server.js index b3d05c5..11151fe 100644 --- a/server.js +++ b/server.js @@ -7,6 +7,11 @@ const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const { WebSocketServer } = require('ws'); +const { trace } = require('@opentelemetry/api'); + +function traceId() { + return trace.getActiveSpan()?.spanContext().traceId; +} const PORT = Number(process.env.PORT || 8080); const HOST = process.env.HOST || '0.0.0.0'; @@ -92,6 +97,20 @@ function serveStatic(req, res) { } const server = http.createServer((req, res) => { + const start = process.hrtime.bigint(); + const { method, url } = req; + res.on('close', () => { + const ms = Number(process.hrtime.bigint() - start) / 1e6; + // res.writableEnded false here means the client (or ingress) dropped the + // connection before we finished writing — the smoking gun for a 502 that + // originates on the proxy side rather than in this process. + if (!res.writableEnded) { + console.warn(`[http] ABORTED ${method} ${url} after ${ms.toFixed(1)}ms (status=${res.statusCode}) trace=${traceId()}`); + } else if (ms > 1000) { + console.warn(`[http] SLOW ${method} ${url} ${res.statusCode} ${ms.toFixed(1)}ms trace=${traceId()}`); + } + }); + if (req.url === '/healthz' || req.url === '/readyz' || req.url === '/health') { res.writeHead(200, { 'Content-Type': 'text/plain' }); res.end('ok'); @@ -110,6 +129,20 @@ const server = http.createServer((req, res) => { serveStatic(req, res); }); +// Node's default keepAliveTimeout (5s) is shorter than ingress-nginx's default +// upstream keepalive idle timeout (60s). Under that mismatch, nginx can pick a +// pooled connection Node has *just* closed and forward a request onto it, +// which comes back to the browser as a 502 — invisible from inside this +// process because Node never saw the request. headersTimeout must stay above +// keepAliveTimeout (Node enforces this). +server.keepAliveTimeout = 65_000; +server.headersTimeout = 66_000; + +server.on('clientError', (err, socket) => { + console.warn(`[http] clientError: ${err.message} (code=${err.code})`); + if (socket.writable) socket.end('HTTP/1.1 400 Bad Request\r\n\r\n'); +}); + // --------------------------------------------------------------------------- // Signaling // client -> server: {type: offer|answer|candidate, to} {type: pos|state}