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
0 commit comments