Skip to content

Build1 publisher2 min readPublished

Forty-eight hours of load balancer forensics ended at a module-level requests.Session

A rolling deploy kept serving deleted JSON fields to a slice of traffic while stickiness was switched off in the balancer config, and the connection doing it was an idle socket held by a module-level requests.Session.

The Engineer · Build desk

Illustration accompanying Forty-eight hours of load balancer forensics ended at a module-level requests.Session

What happened

  • An engineer writing on dev.to spent forty-eight hours convinced the load balancer had gone sticky in the worst possible way after a quiet rolling deploy.
  • The symptom looked like stickiness because a canary instance returned the new response shape while long-running jobs kept parsing a payload the handlers no longer emitted.
  • A grep of every Terraform file for stickiness, cookie affinity and source-IP hashing showed all three switched off.
  • Target health for the old instances moved from draining to unused, which the author took as proof that no live sockets remained on the old process.

Compiled by The EngineerSomething wrong?How this is made

Why it matters

  • constraint Deploy verification that stops at the balancer's target view cannot see connections a client already holds, so the check has to move inside the worker process.
  • decision Anyone chasing this has to build a resident harness, because a script that exits after one request passes while production keeps failing.
  • exposure Any service that removes response fields during a rolling deploy can keep serving the old shape to its own long-lived clients after every external probe reports the cutover done.

The two things being compared are maintained by different processes. A balancer reports on the targets it registered and the connections it is prepared to open. The socket that kept returning the deleted fields was a file descriptor inside a Python process that had been running since before the deploy [10], and the target view does not enumerate those [2].

Connection reuse is what hides the old code path. Once a TCP connection is idle but still alive, the next request goes back down the same connection and the resolver is never asked again [9]. For as long as the pool holds that entry, it makes no difference that the hostname now points at new instances [5].

Every check that came before the client interrogated either the balancer or a brand new connection [1]. The laptop looked innocent for the same reason: curling the public hostname returned the new header every time [6], because a short curl opens one connection, reads the body and exits [7]. Each of those invocations exercised the one path that could not fail, a connection opened after the cutover [3]. "I blamed eventual consistency out loud, which is usually how I mark a session that has already gone sideways," the author wrote on dev.to [11].

A working repro needs a server that prints a generation string, and a way to replace that generation without changing the hostname [15]. In the post's whoami_server.py that is an http.server subclass with protocol_version = "HTTP/1.1", commented "keep-alive on", plus an explicit Connection: keep-alive header on every response [14]. The client side is one line at module scope, session = requests.Session(), then a thread that loops a GET with timeout=(0.5, 2.0) and sleeps a second between calls [12]. The one-second gap is short enough that the pool entry stays in use for the whole life of the process [6].

For this to be your bug, three things have to hold. The client is a long-lived process holding a session created at import time and passed around like a global database handle [8]. The connection was opened before the cutover [10]. And the host has to keep idle sockets alive across it: the post says to run the local server where the workers actually run, and not to trust a laptop TCP stack to keep idle sockets alive as long as Linux does [16]. The evidence is that repro and the author's account of the debugging loop; the post calls itself field notes, and it comes without production dashboards or graphs [17].

What to watch

  • Whether the post is updated with the client-side fix the author settled on: recycling the session, capping pool entry age, or closing idle connections from the backend.
  • Whether the repro behaves the same on the Linux hosts where the workers run, given the author's warning about laptop TCP stacks.
  • Whether backend idle timeouts short enough to break pooled sockets at cutover remove the need to change any client code.
Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories