From 487787ecae4e3830f696d73c8abd7d7f1b7548a1 Mon Sep 17 00:00:00 2001 From: Yashvardhan Didwania Date: Tue, 29 Sep 2026 14:52:23 +0530 Subject: [PATCH 1/2] 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 --- litellm/_service_logger.py | 14 +++++++++ tests/unit/test_service_logger.py | 50 +++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/litellm/_service_logger.py b/litellm/_service_logger.py index 1a5f46e9261..89c177b91a5 100644 --- a/litellm/_service_logger.py +++ b/litellm/_service_logger.py @@ -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( diff --git a/tests/unit/test_service_logger.py b/tests/unit/test_service_logger.py index de46403b64d..35dea3728f5 100644 --- a/tests/unit/test_service_logger.py +++ b/tests/unit/test_service_logger.py @@ -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 From fbd7a9285ec42083b7bc23e15bd8c68c4bcd7fa2 Mon Sep 17 00:00:00 2001 From: Yashvardhan Didwania Date: Tue, 29 Sep 2026 14:52:23 +0530 Subject: [PATCH 2/2] test(service_logger): check sync delivery through a real OTel logger The delivery test mocked litellm's own async hook, so it would pass even if the event never reached a callback. Register a real OpenTelemetry V2 logger and assert the exported span instead. Co-Authored-By: Claude Opus 5.5 --- tests/unit/test_service_logger.py | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/tests/unit/test_service_logger.py b/tests/unit/test_service_logger.py index 35dea3728f5..a0a175f9f05 100644 --- a/tests/unit/test_service_logger.py +++ b/tests/unit/test_service_logger.py @@ -315,15 +315,22 @@ def test_sync_service_hook_starts_no_event_loop_without_callbacks(monkeypatch, s @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 + monkeypatch, 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 a service callback, the event still reaches it: a real OTel logger exports the span.""" + from litellm.integrations.otel.model.spans import SpanRole - 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) + v2_logger, exporter = _make_otel_v2_logger() + parent = v2_logger._emitter.start_span(SpanRole.PROXY_REQUEST, "POST /chat/completions") + monkeypatch.setattr(litellm, "service_callback", [v2_logger]) - mock_hook.assert_awaited_once() - assert started_loops + getattr(ServiceLogging(), hook_name)( + service=ServiceTypes.REDIS, + duration=0.1, + call_type="test", + parent_otel_span=parent, + **extra_kwargs, + ) + parent.end() + + assert "redis test" in [span.name for span in exporter.get_finished_spans()]