fix(service_logger): don't run an event loop for sync service events nobody receives

With no service callback configured, the sync service hooks still ran
asyncio.run() after every call. Under gevent that loop is visible to every
greenlet on the thread, and Django then rejects their database queries with
SynchronousOnlyOperation. Skip it when there is nothing to receive the event.

Fixes #43690

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yashvardhan Didwania 2026-09-29 14:52:23 +05:30
parent 85dc7cb62e
commit 487787ecae
2 changed files with 64 additions and 0 deletions

View file

@ -133,6 +133,16 @@ class ServiceLogging(CustomLogger):
except Exception as e:
verbose_logger.exception("Error emitting service event - %s", e)
def _has_service_event_observers(self) -> bool:
"""Whether anything would receive a service event sent from a blocking caller.
Sending one runs an event loop on the calling thread. Under gevent, every
greenlet on that thread then sees a running loop, and Django rejects their
database queries with ``SynchronousOnlyOperation``. So skip it when there is
no service callback (or test counter) to receive the event.
"""
return bool(litellm.service_callback) or self.mock_testing
@staticmethod
def _dispatch_from_sync(hook: Callable[[], Coroutine[object, object, None]]) -> None:
"""Run an async service hook from a blocking caller, whatever event loop it holds.
@ -167,6 +177,8 @@ class ServiceLogging(CustomLogger):
if self.mock_testing:
self.mock_testing_sync_success_hook += 1
if not self._has_service_event_observers():
return
self._dispatch_from_sync(
lambda: self.async_service_success_hook(
@ -196,6 +208,8 @@ class ServiceLogging(CustomLogger):
"""
if self.mock_testing:
self.mock_testing_sync_failure_hook += 1
if not self._has_service_event_observers():
return
self._dispatch_from_sync(
lambda: self.async_service_failure_hook(

View file

@ -5,6 +5,9 @@ Regression test for KeyError: 'call_type' when async_log_success_event
is called without call_type in kwargs (e.g. from batch polling callbacks).
"""
import asyncio
import asyncio.events
import pytest
from datetime import datetime
from unittest.mock import AsyncMock, patch
@ -277,3 +280,50 @@ async def test_service_failure_span_not_duplicated_for_string_and_instance(
s for s in exporter.get_finished_spans() if s.name == "postgres get_user_object"
]
assert len(db_spans) == 1
_SYNC_HOOK_CALLS = [
pytest.param("service_success_hook", {}, id="success"),
pytest.param("service_failure_hook", {"error": RuntimeError("boom")}, id="failure"),
]
@pytest.fixture
def started_loops(monkeypatch) -> list[asyncio.AbstractEventLoop]:
"""Event loops started during the test."""
started: list[asyncio.AbstractEventLoop] = []
original = asyncio.events._set_running_loop
def recording_set_running_loop(loop):
if loop is not None:
started.append(loop)
original(loop)
monkeypatch.setattr(asyncio.events, "_set_running_loop", recording_set_running_loop)
return started
@pytest.mark.parametrize("hook_name, extra_kwargs", _SYNC_HOOK_CALLS)
def test_sync_service_hook_starts_no_event_loop_without_callbacks(monkeypatch, started_loops, hook_name, extra_kwargs):
"""With no service callback, nothing receives the event, so no loop should start."""
monkeypatch.setattr(litellm, "service_callback", [])
getattr(ServiceLogging(), hook_name)(service=ServiceTypes.REDIS, duration=0.1, call_type="test", **extra_kwargs)
assert started_loops == []
@pytest.mark.parametrize("hook_name, extra_kwargs", _SYNC_HOOK_CALLS)
def test_sync_service_hook_still_delivers_to_configured_callbacks(
monkeypatch, started_loops, hook_name, extra_kwargs
):
"""With a service callback, the event is still delivered."""
monkeypatch.setattr(litellm, "service_callback", ["datadog"])
service_logger = ServiceLogging()
async_hook_name = f"async_{hook_name}"
with patch.object(service_logger, async_hook_name, new_callable=AsyncMock) as mock_hook:
getattr(service_logger, hook_name)(service=ServiceTypes.REDIS, duration=0.1, call_type="test", **extra_kwargs)
mock_hook.assert_awaited_once()
assert started_loops