fix(unifi): close the cached client before discarding it (fd leak)
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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user