fix(unifi): close the cached client before discarding it (fd leak)
CI / lint (push) Successful in 3s
CI / unit (push) Successful in 47s
CI / integration (push) Successful in 2m19s
CI / publish (push) Successful in 8s

The UniFi poller caches a UnifiClient holding a long-lived httpx.AsyncClient
(a real socket pool). On a login failure or a mid-poll error it set
`_client = None` to force re-auth, but never called close() — orphaning the
connection pool until GC. Under a flapping/unreachable controller that leaks
a file descriptor every failure tick: the same class of bug as the SNMP
engine leak (OSError: [Errno 24] Too many open files).

Add _drop_client() that aclose()s the client (best-effort) before nulling
the cache, and route both discard paths through it. Regression tests cover
both failure paths plus the noop/close-error contracts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-24 09:38:41 -04:00
parent 2df5fc94a3
commit 0c5a1573da
3 changed files with 113 additions and 2 deletions
+21 -2
View File
@@ -15,6 +15,25 @@ logger = logging.getLogger(__name__)
_client = None # UnifiClient instance, initialised on first scrape
async def _drop_client() -> None:
"""Close and discard the cached client.
The client holds a long-lived ``httpx.AsyncClient`` (a connection pool over
real sockets). Nulling the reference without ``aclose()`` orphans that pool
until GC, so every failure tick that re-auths would leak file descriptors —
the same class of bug that took the app down via SNMP (Errno 24, "Too many
open files"). Close first, then drop. Best-effort: a failed close must not
break the poll loop.
"""
global _client
if _client is not None:
try:
await _client.close()
except Exception:
pass
_client = None
def make_poll_task(app: "Quart") -> ScheduledTask:
cfg = app.config["PLUGINS"]["unifi"]
interval = int(cfg.get("poll_interval_seconds", 60))
@@ -51,7 +70,7 @@ async def _do_poll(app: "Quart") -> None:
await _client.login()
except Exception as exc:
logger.warning("UniFi initial login failed: %s", exc)
_client = None
await _drop_client()
return
try:
@@ -60,7 +79,7 @@ async def _do_poll(app: "Quart") -> None:
devices = await _client.get_devices()
except Exception as exc:
logger.warning("UniFi poll failed — will retry next tick: %s", exc)
_client = None # force re-auth on next tick
await _drop_client() # close + force re-auth on next tick
return
scraped_at = datetime.now(timezone.utc)