Skip to content

Commit ee528fc

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

8 files changed

Lines changed: 385 additions & 43 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: 92 additions & 16 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,47 @@ 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,
264+
async with asyncio.timeout(self._PER_CALL_DEADLINE_SECONDS):
265+
await self._provider.track_event_async(
266+
event_name=event_name,
267+
data=data,
268+
operation_id=operation_id,
269+
)
270+
except TimeoutError:
271+
# The platform call outran the per-call deadline (retries +
272+
# Retry-After can otherwise run for minutes). Drop it rather
273+
# than let a degraded backend pin an in-flight slot.
274+
self._log_dispatch_failure(
275+
"track_event exceeded the %.0fs deadline; dropped (event_name=%s)",
276+
self._PER_CALL_DEADLINE_SECONDS,
277+
event_name,
227278
)
228279
except Exception as exc: # noqa: BLE001 - fire-and-forget contract
229-
logger.debug("Failed to dispatch track_event: %s", exc, exc_info=True)
280+
self._log_dispatch_failure(
281+
"Failed to dispatch track_event (event_name=%s): %s",
282+
event_name,
283+
exc,
284+
exc_info=True,
285+
)
286+
287+
def _log_dispatch_failure(
288+
self, msg: str, *args: object, exc_info: bool = False
289+
) -> None:
290+
"""Log a swallowed dispatch failure — first at warning, then debug.
291+
292+
Dispatch failures are silent to the caller by the fire-and-forget
293+
contract, so the first one is surfaced at warning to make telemetry
294+
loss visible; subsequent failures drop to debug so a sustained-down
295+
backend cannot flood the logs. Only ever called on the background
296+
loop thread, so the flag read/write needs no lock.
297+
"""
298+
if not self._dispatch_failure_logged:
299+
self._dispatch_failure_logged = True
300+
logger.warning(msg, *args, exc_info=exc_info)
301+
else:
302+
logger.debug(msg, *args, exc_info=exc_info)
230303

231304
def _on_future_done(self, future: concurrent.futures.Future[None]) -> None:
232305
"""Observe the future, drop it from the pending set, release the slot.
@@ -253,7 +326,7 @@ def _on_future_done(self, future: concurrent.futures.Future[None]) -> None:
253326
self._futures.discard(future)
254327
self._inflight.release()
255328

256-
def shutdown(self, *, wait: bool = True, timeout: float = 30.0) -> None:
329+
def shutdown(self, *, wait: bool = True, timeout: float = 5.0) -> None:
257330
"""Stop accepting new submissions; optionally drain pending, then stop the loop.
258331
259332
Call at process exit to avoid losing in-flight telemetry.
@@ -267,7 +340,10 @@ def shutdown(self, *, wait: bool = True, timeout: float = 30.0) -> None:
267340
teardown path.
268341
timeout: Maximum seconds to wait for pending coroutines
269342
when ``wait=True``. Coroutines still in flight after
270-
the timeout are cancelled by loop teardown.
343+
the timeout are cancelled by loop teardown. The default
344+
is deliberately short: the only caller is a CLI exit path,
345+
where a long drain against a degraded backend would just
346+
stall process teardown (telemetry is best-effort).
271347
"""
272348
if self._shutdown_event.is_set():
273349
return

packages/uipath-platform/tests/services/test_live_track_event_dispatcher.py

Lines changed: 143 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from __future__ import annotations
1717

1818
import asyncio
19+
import inspect
1920
import logging
2021
import threading
2122
import time
@@ -464,37 +465,42 @@ def test_shutdown_swallows_when_loop_already_stopped(provider: MagicMock) -> Non
464465
dispatcher.shutdown()
465466

466467

467-
def test_worker_exception_is_logged_at_debug(
468+
def test_worker_exception_warns_once_then_debug(
468469
provider: MagicMock,
469470
dispatcher: LiveTrackEventDispatcher,
470471
caplog: pytest.LogCaptureFixture,
471472
) -> None:
472-
"""Covers the ``except Exception`` branch inside ``_run``.
473-
474-
Complements :func:`test_worker_exception_does_not_propagate` by
475-
asserting the log record (with ``exc_info``) actually fires, so
476-
coverage records the debug-log line inside the coroutine.
473+
"""Covers the ``except Exception`` branch inside ``_run`` and the
474+
warn-once policy.
475+
476+
Swallowed coroutine exceptions are silent to the caller by the
477+
fire-and-forget contract, so the FIRST one is surfaced at warning (to
478+
make otherwise-invisible telemetry loss visible) and the rest drop to
479+
debug (so a sustained-down backend can't flood the logs). Both carry
480+
``exc_info`` so operators can trace the failure.
477481
"""
478482
provider.track_event_async.side_effect = ValueError("bad payload")
479483

480484
with caplog.at_level(logging.DEBUG, logger=_DISPATCHER_LOGGER):
481-
dispatcher.dispatch(event_name="agent.bad")
482-
# Wait for the coroutine to run AND the callback to fire so
483-
# coverage collects the except-branch lines from the loop thread.
485+
dispatcher.dispatch(event_name="agent.bad.1")
484486
assert _wait_for(lambda: provider.track_event_async.await_count >= 1)
485-
# Small sleep to let the callback finalize (release semaphore,
486-
# drop from set) — otherwise coverage may race the callback.
487+
time.sleep(0.05)
488+
dispatcher.dispatch(event_name="agent.bad.2")
489+
assert _wait_for(lambda: provider.track_event_async.await_count >= 2)
487490
time.sleep(0.05)
488491

489-
matching = [
490-
r
491-
for r in caplog.records
492-
if "Failed to dispatch track_event" in r.message and r.levelno == logging.DEBUG
492+
failures = [
493+
r for r in caplog.records if "Failed to dispatch track_event" in r.message
493494
]
494-
assert matching, "expected a debug log for the swallowed exception"
495-
# exc_info is attached so operators can trace the failure.
496-
assert matching[0].exc_info is not None
497-
assert isinstance(matching[0].exc_info[1], ValueError)
495+
warnings = [r for r in failures if r.levelno == logging.WARNING]
496+
debugs = [r for r in failures if r.levelno == logging.DEBUG]
497+
498+
# Exactly one warning (warn-once), and at least one subsequent debug.
499+
assert len(warnings) == 1, f"expected one warning, got {len(warnings)}"
500+
assert debugs, "expected subsequent failures logged at debug"
501+
# exc_info attached on the surfaced warning.
502+
assert warnings[0].exc_info is not None
503+
assert isinstance(warnings[0].exc_info[1], ValueError)
498504

499505

500506
# ---------------------------------------------------------------------------
@@ -579,3 +585,121 @@ async def _never_finish(**_: object) -> None:
579585
# few seconds — a hang here would freeze the whole test suite.
580586
assert elapsed < 3.0, f"shutdown took {elapsed:.3f}s with timeout=0.1s"
581587
assert not dispatcher._loop_thread.is_alive()
588+
589+
590+
def test_shutdown_default_timeout_is_short_for_cli_exit() -> None:
591+
"""The drain default must be short.
592+
593+
The only caller is a CLI exit path; a long drain against a degraded
594+
backend would just stall process teardown (telemetry is best-effort).
595+
Guards against silent drift back to a large default.
596+
"""
597+
default = (
598+
inspect.signature(LiveTrackEventDispatcher.shutdown)
599+
.parameters["timeout"]
600+
.default
601+
)
602+
assert default == 5.0
603+
604+
605+
# ---------------------------------------------------------------------------
606+
# per-call deadline
607+
# ---------------------------------------------------------------------------
608+
609+
610+
def test_per_call_deadline_drops_slow_call(
611+
provider: MagicMock,
612+
monkeypatch: pytest.MonkeyPatch,
613+
caplog: pytest.LogCaptureFixture,
614+
) -> None:
615+
"""A single dispatched call that outruns the per-call deadline is
616+
cancelled and dropped — it must not pin its in-flight slot, and the
617+
caller must never see an error.
618+
619+
Without the deadline, one degraded call (5 retries × 30s + Retry-After
620+
backoff) could hold a slot for minutes and stall shutdown drain.
621+
"""
622+
monkeypatch.setattr(LiveTrackEventDispatcher, "_PER_CALL_DEADLINE_SECONDS", 0.1)
623+
624+
cancelled = threading.Event()
625+
626+
async def _too_slow(**_: object) -> None:
627+
try:
628+
await asyncio.sleep(5.0) # far beyond the 0.1s deadline
629+
except asyncio.CancelledError:
630+
cancelled.set()
631+
raise
632+
633+
provider.track_event_async.side_effect = _too_slow
634+
635+
dispatcher = LiveTrackEventDispatcher(provider, max_inflight=1)
636+
try:
637+
with caplog.at_level(logging.WARNING, logger=_DISPATCHER_LOGGER):
638+
dispatcher.dispatch(event_name="slow.event")
639+
# The 0.1s deadline fires long before the 5s sleep would.
640+
assert cancelled.wait(timeout=2.0), (
641+
"coroutine was not cancelled at the deadline"
642+
)
643+
# The in-flight slot is released once the dropped call finalizes,
644+
# so it can be re-acquired.
645+
reacquired = _wait_for(lambda: dispatcher._inflight.acquire(blocking=False))
646+
assert reacquired, "in-flight slot not released after deadline drop"
647+
dispatcher._inflight.release()
648+
649+
deadline_logs = [
650+
r
651+
for r in caplog.records
652+
if "exceeded the" in r.message and "deadline" in r.message
653+
]
654+
assert deadline_logs, "expected a deadline-exceeded log"
655+
finally:
656+
dispatcher.shutdown()
657+
658+
659+
# ---------------------------------------------------------------------------
660+
# construction: bounded wait for the loop thread
661+
# ---------------------------------------------------------------------------
662+
663+
664+
def test_late_loop_start_raises_and_thread_closes_its_own_loop(
665+
provider: MagicMock,
666+
monkeypatch: pytest.MonkeyPatch,
667+
) -> None:
668+
"""A loop thread that starts AFTER the readiness deadline must:
669+
670+
1. cause construction to fail fast (fail open — the bootstrap treats a
671+
dispatcher construction failure as "governance unavailable", so it
672+
can never hang the CLI), and
673+
2. abort cleanly by closing its OWN loop when it finally runs — the
674+
loop is never closed across threads (which would race
675+
``run_forever``: closing a running loop, or running a closed one).
676+
677+
Regression guard for the loop-affinity-review finding: ``__init__`` must
678+
only *signal* the abort, and ``_run_loop`` must do the close.
679+
"""
680+
monkeypatch.setattr(LiveTrackEventDispatcher, "_LOOP_START_TIMEOUT_SECONDS", 0.1)
681+
682+
gate = threading.Event()
683+
real_run_loop = LiveTrackEventDispatcher._run_loop
684+
captured: dict[str, LiveTrackEventDispatcher] = {}
685+
686+
def _delayed_run_loop(self: LiveTrackEventDispatcher) -> None:
687+
# Hold the thread past the readiness deadline so __init__ times out
688+
# first, THEN run the real loop body (which must hit the abort path).
689+
captured["self"] = self
690+
gate.wait(timeout=2.0)
691+
real_run_loop(self)
692+
693+
monkeypatch.setattr(LiveTrackEventDispatcher, "_run_loop", _delayed_run_loop)
694+
695+
with pytest.raises(RuntimeError, match="failed to start"):
696+
LiveTrackEventDispatcher(provider)
697+
698+
# __init__ has raised and signalled abort. Release the late thread into
699+
# the REAL _run_loop: it must observe the abort flag, close its own loop,
700+
# and exit — no run_forever, no leaked running loop, no cross-thread close.
701+
dispatcher = captured["self"]
702+
gate.set()
703+
dispatcher._loop_thread.join(timeout=2.0)
704+
assert not dispatcher._loop_thread.is_alive(), "late loop thread did not exit"
705+
assert dispatcher._loop.is_closed(), "aborting thread did not close its own loop"

packages/uipath-platform/uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/uipath/pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
[project]
22
name = "uipath"
3-
version = "2.13.13"
3+
version = "2.13.14"
44
description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools."
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"
77
dependencies = [
88
"uipath-core>=0.5.30, <0.6.0",
99
"uipath-runtime>=0.12.2, <0.13.0",
10-
"uipath-platform>=0.2.4, <0.3.0",
10+
"uipath-platform>=0.2.13, <0.3.0",
1111
"click>=8.3.1",
1212
"httpx>=0.28.1",
1313
"pyjwt>=2.10.1",

0 commit comments

Comments
 (0)