From 80e0bbe899fd568485760e79dcc3ccb18a503d75 Mon Sep 17 00:00:00 2001 From: tocmo0nlord Date: Sat, 4 Jul 2026 04:57:04 +0000 Subject: [PATCH] Add XML-RPC timeout to odoo_client (hung Odoo leaked call slots) Reliability drill finding: xmlrpc.client has no default timeout, and the call slot is released only after run_agent returns (which awaits post-call persistence). A hung -- not refused -- Odoo would block forever; two hung persists would leave the phone line permanently at capacity ("busy") until a restart. All XML-RPC round-trips now use a 15s socket timeout (ODOO_TIMEOUT_SECS), http and https transports both covered. Drilled: black-hole address fails cleanly at the timeout; real auth unaffected. Co-Authored-By: Claude Opus 4.8 --- odoo_client.py | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/odoo_client.py b/odoo_client.py index 6842610..02bf605 100644 --- a/odoo_client.py +++ b/odoo_client.py @@ -26,18 +26,45 @@ ODOO_TEAM_ID = int(os.environ["ODOO_TEAM_ID"]) if os.environ.get("ODOO_TEAM_ID") ODOO_USER_ID = int(os.environ["ODOO_USER_ID"]) if os.environ.get("ODOO_USER_ID") else None +# Hard cap on any single XML-RPC round-trip. Without it, xmlrpc.client uses the OS +# default (no timeout): a HUNG Odoo (as opposed to a refused connection) would block +# post-call persistence forever — and since the call slot is released only after +# run_agent returns, two hung persists would leave the line permanently "busy". +ODOO_TIMEOUT_SECS = float(os.environ.get("ODOO_TIMEOUT_SECS", "15")) + + class OdooError(RuntimeError): pass +class _TimeoutTransport(xmlrpc.client.Transport): + def make_connection(self, host): + conn = super().make_connection(host) + conn.timeout = ODOO_TIMEOUT_SECS + return conn + + +class _TimeoutSafeTransport(xmlrpc.client.SafeTransport): + def make_connection(self, host): + conn = super().make_connection(host) + conn.timeout = ODOO_TIMEOUT_SECS + return conn + + +def _proxy(path): + transport = (_TimeoutSafeTransport() if ODOO_URL.lower().startswith("https") + else _TimeoutTransport()) + return xmlrpc.client.ServerProxy(f"{ODOO_URL}{path}", transport=transport) + + def _connect(): if not (ODOO_USER and ODOO_API_KEY): raise OdooError("ODOO_USER / ODOO_API_KEY not set") - common = xmlrpc.client.ServerProxy(f"{ODOO_URL}/xmlrpc/2/common") + common = _proxy("/xmlrpc/2/common") uid = common.authenticate(ODOO_DB, ODOO_USER, ODOO_API_KEY, {}) if not uid: raise OdooError("Odoo authentication failed (check db/user/key)") - models = xmlrpc.client.ServerProxy(f"{ODOO_URL}/xmlrpc/2/object") + models = _proxy("/xmlrpc/2/object") return uid, models