litellm/tests/unit/responses/test_responses_streaming_iterator.py
yuneng-jiang 1587faeeb9
test(unit): wait on the work itself instead of fixed sleeps (#46261)
* test(unit): wait on the work itself instead of fixed sleeps in 21 flaky files

Replace sleeps that raced background logging, cache writes, refreshes and
thread work with either awaiting that work directly (logging worker flush,
the batch logger's flush_queue, the tasks the call spawned, thread joins) or
an Event the test's own fake sets when it runs. Each wait keeps a 5s hang
guard that never decides pass or fail. Tests that need a fake clock, real
sockets, or measure latency are left unchanged, and no production code moves

* test(unit): await the tasks each completion spawns instead of yielding once

The logging tests yielded once with asyncio.sleep(0) so the task that
enqueues the success log could run before the worker flush. That relied
on the enqueue happening before the task's first await. Snapshot the
running tasks around each acompletion call and await the ones it spawned,
so the guarantee holds however many steps sit before the enqueue
2026-10-13 12:41:06 -07:00

165 lines
5.4 KiB
Python

"""
Regression tests for LIT-4210: the streaming iterators must never run the sync
success_handler on the thread-pool executor concurrently with
async_success_handler. Concurrent mutation of the shared response object /
model_call_details from two threads segfaults pydantic-core (customer pods
crashed with exit 139 whenever any CustomLogger was registered).
"""
import asyncio
import time
import httpx
import pytest
import litellm
from litellm.integrations.custom_logger import CustomLogger
from litellm.litellm_core_utils import thread_pool_executor as thread_pool_executor_module
from litellm.responses import streaming_iterator as responses_streaming_iterator_module
from litellm.litellm_core_utils.litellm_logging import Logging as LitellmLogging
from litellm.responses.streaming_iterator import ResponsesAPIStreamingIterator
from litellm.types.llms.openai import ResponsesAPIResponse
class RecordingCustomLogger(CustomLogger):
def __init__(self):
super().__init__()
self.async_hook_started: float | None = None
self.async_hook_finished: float | None = None
async def _record(self):
self.async_hook_started = time.monotonic()
await asyncio.sleep(0.2)
self.async_hook_finished = time.monotonic()
async def async_log_success_event(self, kwargs, response_obj, start_time, end_time):
await self._record()
async def async_log_stream_event(self, kwargs, response_obj, start_time, end_time):
await self._record()
class RecordingExecutor:
def __init__(self, inner):
self._inner = inner
self.submits: list = []
def submit(self, fn, *args, **kwargs):
self.submits.append((time.monotonic(), fn))
return self._inner.submit(fn, *args, **kwargs)
def submit_times_for(self, logging_obj) -> list:
return [t for t, fn in self.submits if getattr(fn, "__self__", None) is logging_obj]
@pytest.fixture(autouse=True)
def _isolate_callbacks():
saved = (
litellm.callbacks,
litellm.success_callback,
litellm._async_success_callback,
litellm.failure_callback,
litellm._async_failure_callback,
)
yield
(
litellm.callbacks,
litellm.success_callback,
litellm._async_success_callback,
litellm.failure_callback,
litellm._async_failure_callback,
) = saved
@pytest.fixture
def recording_executor(monkeypatch):
recording = RecordingExecutor(thread_pool_executor_module.executor)
monkeypatch.setattr(thread_pool_executor_module, "executor", recording)
monkeypatch.setattr(responses_streaming_iterator_module, "executor", recording)
return recording
def _make_logging_obj() -> LitellmLogging:
logging_obj = LitellmLogging(
model="gpt-5.4-nano",
messages=[{"role": "user", "content": "hi"}],
stream=True,
call_type="aresponses",
start_time=time.time(),
litellm_call_id="lit-4210-test",
function_id="lit-4210-test",
)
logging_obj.model_call_details["litellm_params"] = {"aresponses": True}
return logging_obj
def _make_iterator(logging_obj: LitellmLogging) -> ResponsesAPIStreamingIterator:
iterator = ResponsesAPIStreamingIterator(
response=httpx.Response(200),
model="gpt-5.4-nano",
responses_api_provider_config=None,
logging_obj=logging_obj,
)
iterator.completed_response = ResponsesAPIResponse(
id="resp_lit4210",
created_at=1700000000.0,
model="gpt-5.4-nano",
object="response",
output=[],
parallel_tool_calls=True,
tool_choice="auto",
tools=[],
error=None,
incomplete_details=None,
instructions=None,
metadata={},
temperature=1.0,
top_p=1.0,
)
return iterator
async def _await_tasks_spawned_since(existing: set["asyncio.Task[object]"]) -> None:
spawned = asyncio.all_tasks() - existing
await asyncio.wait_for(asyncio.gather(*spawned, return_exceptions=True), timeout=5)
@pytest.mark.asyncio
async def test_custom_logger_only_never_submits_sync_success_handler(recording_executor):
recorder = RecordingCustomLogger()
litellm.success_callback = [recorder]
litellm._async_success_callback = [recorder]
logging_obj = _make_logging_obj()
iterator = _make_iterator(logging_obj)
existing_tasks = asyncio.all_tasks()
iterator._log_completed_response(is_async=True)
await _await_tasks_spawned_since(existing_tasks)
assert recorder.async_hook_started is not None
assert recording_executor.submit_times_for(logging_obj) == []
@pytest.mark.asyncio
async def test_sync_callbacks_run_only_after_async_handler_completes(recording_executor):
recorder = RecordingCustomLogger()
sync_events: list = []
def sync_callback(kwargs, response_obj, start_time, end_time):
sync_events.append(time.monotonic())
litellm.success_callback = [recorder, sync_callback]
litellm._async_success_callback = [recorder]
logging_obj = _make_logging_obj()
iterator = _make_iterator(logging_obj)
existing_tasks = asyncio.all_tasks()
iterator._log_completed_response(is_async=True)
await _await_tasks_spawned_since(existing_tasks)
assert recorder.async_hook_finished is not None
submit_times = recording_executor.submit_times_for(logging_obj)
assert len(submit_times) == 1
assert submit_times[0] >= recorder.async_hook_finished