Skip to content

Commit e4f7cd2

Browse files
committed
fix: dedicated http client for governance telemetry dispatcher
1 parent 684009f commit e4f7cd2

8 files changed

Lines changed: 432 additions & 44 deletions

File tree

packages/uipath-platform/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "uipath-platform"
3-
version = "0.2.12"
3+
version = "0.2.13"
44
description = "HTTP client library for programmatic access to UiPath Platform"
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"

packages/uipath-platform/src/uipath/platform/governance/_live_track_event_dispatcher.py

Lines changed: 106 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,11 @@
2222
assumes it owns the provider's async HTTP path — nothing else in
2323
the process should await ``track_event_async`` (or any other
2424
``*_async`` method on the same underlying service) on a *different*
25-
loop. See "one dispatcher per provider" below.
25+
loop. In particular, the provider passed in must be backed by a
26+
service whose async client has not already served a request on
27+
another loop (e.g. a governance policy fetch on the CLI's main
28+
loop) — hand the dispatcher a *dedicated* provider. See "one
29+
dispatcher per provider" below.
2630
2731
- **Backpressure.** A ``BoundedSemaphore`` caps in-flight coroutines;
2832
submissions that exceed the cap are dropped with a warning so
@@ -74,6 +78,19 @@ class LiveTrackEventDispatcher:
7478

7579
_DEFAULT_MAX_INFLIGHT = 40
7680

81+
# Total wall-clock ceiling for a single dispatched call. The platform
82+
# call retries (up to 5 attempts, honoring ``Retry-After`` up to 120s)
83+
# and httpx's 30s timeout is per-phase, not total — so one degraded
84+
# call could otherwise run for minutes, pinning an in-flight slot and
85+
# stalling ``shutdown`` drain. This caps every dispatched call.
86+
_PER_CALL_DEADLINE_SECONDS = 60.0
87+
88+
# Max wait for the background loop thread to signal readiness. The
89+
# thread only fails to signal if it never starts (e.g. OS
90+
# thread-creation failure); the caller treats a construction failure as
91+
# "governance unavailable" (fail open) rather than blocking forever.
92+
_LOOP_START_TIMEOUT_SECONDS = 5.0
93+
7794
def __init__(
7895
self,
7996
provider: UiPathPlatformGovernanceProvider,
@@ -103,6 +120,10 @@ def __init__(
103120
self._shutdown_event = threading.Event()
104121
self._futures_lock = threading.Lock()
105122
self._futures: set[concurrent.futures.Future[None]] = set()
123+
# Guards warn-once for swallowed dispatch failures. Only ever read
124+
# or written on the background loop thread (inside ``_run``), so no
125+
# lock is needed.
126+
self._dispatch_failure_logged = False
106127

107128
self._loop = asyncio.new_event_loop()
108129
self._loop_ready = threading.Event()
@@ -113,13 +134,31 @@ def __init__(
113134
)
114135
self._loop_thread.start()
115136
# Block until the loop is running so the first ``dispatch`` cannot
116-
# race with startup and hit "loop not running" errors.
117-
self._loop_ready.wait()
137+
# race with startup and hit "loop not running" errors. Bounded so a
138+
# thread that never starts surfaces as a construction failure the
139+
# caller can fall back on, instead of hanging the process forever.
140+
if not self._loop_ready.wait(timeout=self._LOOP_START_TIMEOUT_SECONDS):
141+
# Signal the (possibly late-starting) loop thread to abort and
142+
# close its own loop. NEVER close it here, across threads — that
143+
# would race the thread's ``run_forever`` (closing a running
144+
# loop, or running an already-closed one). See ``_run_loop``.
145+
self._shutdown_event.set()
146+
raise RuntimeError(
147+
"governance track-event loop failed to start within "
148+
f"{self._LOOP_START_TIMEOUT_SECONDS}s"
149+
)
118150

119151
def _run_loop(self) -> None:
120152
"""Body of the background loop thread — runs until ``shutdown``."""
121153
asyncio.set_event_loop(self._loop)
122154
self._loop_ready.set()
155+
# If construction already gave up waiting for readiness (the
156+
# loop-start-timeout path set ``_shutdown_event`` before this thread
157+
# got scheduled), abort now and close the loop HERE — on the thread
158+
# that owns it — so ``__init__`` never closes it across threads.
159+
if self._shutdown_event.is_set():
160+
self._loop.close()
161+
return
123162
try:
124163
self._loop.run_forever()
125164
finally:
@@ -170,11 +209,13 @@ def dispatch(
170209
raises ``RuntimeError`` if the loop is stopped/closed
171210
(late-firing atexit path); the dispatcher rolls back the
172211
semaphore slot, closes the coroutine, and logs at debug.
173-
- **Coroutine exception**: the provider's HTTP call may raise
174-
for any reason (serialization, 5xx, transport). ``_run``
175-
catches, logs at debug with ``exc_info=True``, and the
176-
done-callback observes the future to suppress asyncio's
177-
"exception was never retrieved" warning.
212+
- **Coroutine exception / deadline**: the provider's HTTP call may
213+
raise for any reason (serialization, 5xx, transport) or exceed
214+
``_PER_CALL_DEADLINE_SECONDS``. ``_run`` catches both, logs the
215+
first such failure at warning (so silent telemetry loss is
216+
visible) and the rest at debug, and the done-callback observes
217+
the future to suppress asyncio's "exception was never retrieved"
218+
warning.
178219
"""
179220
if self._shutdown_event.is_set():
180221
logger.debug(
@@ -218,15 +259,60 @@ async def _run(
218259
data: dict[str, Any] | None,
219260
operation_id: str | None,
220261
) -> None:
221-
"""Coroutine body — the async HTTP call itself."""
262+
"""Coroutine body — the async HTTP call itself, under a total deadline."""
222263
try:
223-
await self._provider.track_event_async(
224-
event_name=event_name,
225-
data=data,
226-
operation_id=operation_id,
227-
)
264+
async with asyncio.timeout(self._PER_CALL_DEADLINE_SECONDS) as cm:
265+
await self._provider.track_event_async(
266+
event_name=event_name,
267+
data=data,
268+
operation_id=operation_id,
269+
)
270+
except TimeoutError as exc:
271+
# ``cm.expired()`` disambiguates OUR deadline from a TimeoutError
272+
# raised inside the provider call itself (e.g. a socket timeout —
273+
# ``socket.timeout`` is aliased to ``TimeoutError``). Only the
274+
# former is a deadline drop; the latter is a normal failure and
275+
# deserves the exc_info-carrying generic log.
276+
if cm.expired():
277+
# The platform call outran the per-call deadline (retries +
278+
# Retry-After can otherwise run for minutes). Drop it rather
279+
# than let a degraded backend pin an in-flight slot.
280+
self._log_dispatch_failure(
281+
"track_event exceeded the %.0fs deadline; dropped (event_name=%s)",
282+
self._PER_CALL_DEADLINE_SECONDS,
283+
event_name,
284+
)
285+
else:
286+
self._log_dispatch_failure(
287+
"Failed to dispatch track_event (event_name=%s): %s",
288+
event_name,
289+
exc,
290+
exc_info=True,
291+
)
228292
except Exception as exc: # noqa: BLE001 - fire-and-forget contract
229-
logger.debug("Failed to dispatch track_event: %s", exc, exc_info=True)
293+
self._log_dispatch_failure(
294+
"Failed to dispatch track_event (event_name=%s): %s",
295+
event_name,
296+
exc,
297+
exc_info=True,
298+
)
299+
300+
def _log_dispatch_failure(
301+
self, msg: str, *args: object, exc_info: bool = False
302+
) -> None:
303+
"""Log a swallowed dispatch failure — first at warning, then debug.
304+
305+
Dispatch failures are silent to the caller by the fire-and-forget
306+
contract, so the first one is surfaced at warning to make telemetry
307+
loss visible; subsequent failures drop to debug so a sustained-down
308+
backend cannot flood the logs. Only ever called on the background
309+
loop thread, so the flag read/write needs no lock.
310+
"""
311+
if not self._dispatch_failure_logged:
312+
self._dispatch_failure_logged = True
313+
logger.warning(msg, *args, exc_info=exc_info)
314+
else:
315+
logger.debug(msg, *args, exc_info=exc_info)
230316

231317
def _on_future_done(self, future: concurrent.futures.Future[None]) -> None:
232318
"""Observe the future, drop it from the pending set, release the slot.
@@ -253,7 +339,7 @@ def _on_future_done(self, future: concurrent.futures.Future[None]) -> None:
253339
self._futures.discard(future)
254340
self._inflight.release()
255341

256-
def shutdown(self, *, wait: bool = True, timeout: float = 30.0) -> None:
342+
def shutdown(self, *, wait: bool = True, timeout: float = 5.0) -> None:
257343
"""Stop accepting new submissions; optionally drain pending, then stop the loop.
258344
259345
Call at process exit to avoid losing in-flight telemetry.
@@ -267,7 +353,10 @@ def shutdown(self, *, wait: bool = True, timeout: float = 30.0) -> None:
267353
teardown path.
268354
timeout: Maximum seconds to wait for pending coroutines
269355
when ``wait=True``. Coroutines still in flight after
270-
the timeout are cancelled by loop teardown.
356+
the timeout are cancelled by loop teardown. The default
357+
is deliberately short: the only caller is a CLI exit path,
358+
where a long drain against a degraded backend would just
359+
stall process teardown (telemetry is best-effort).
271360
"""
272361
if self._shutdown_event.is_set():
273362
return

0 commit comments

Comments
 (0)