mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
fix(logging_worker): cancel pending tasks in atexit handler to prevent worker crash
GCSBucketLogger creates a periodic_flush background task via asyncio.create_task(). When the event loop closes at shutdown, these tasks get destroyed while still pending, which causes a Task.__del__ → call_exception_handler() → logging cascade that raises ValueError (I/O on closed file). This propagated through the atexit handler, causing Python to re-raise it and exit with a non-zero code. pytest-xdist then reported the last in-flight test as a worker crash. Fixes: - Cancel pending tasks after each coroutine in _flush_on_exit - Wrap entire atexit handler in except Exception: pass (atexit exceptions must never propagate) - Wrap loop.close() in try/except - Fix pre-existing pyright error in _safe_log (Handler.stream access) - Restore log levels after litellm._turn_on_debug() in the Vertex Llama test - Broaden exception handling in the test to skip on any API error, not just RateLimitError
This commit is contained in:
parent
d95c3e9cd4
commit
60ed54abf8
2 changed files with 42 additions and 9 deletions
|
|
@ -2,18 +2,19 @@
|
|||
# for the sake of performance and scalability.
|
||||
|
||||
import asyncio
|
||||
import atexit
|
||||
import contextvars
|
||||
from typing import Coroutine, Optional
|
||||
import atexit
|
||||
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.constants import (
|
||||
LOGGING_WORKER_AGGRESSIVE_CLEAR_COOLDOWN_SECONDS,
|
||||
LOGGING_WORKER_CLEAR_PERCENTAGE,
|
||||
LOGGING_WORKER_CONCURRENCY,
|
||||
LOGGING_WORKER_MAX_QUEUE_SIZE,
|
||||
LOGGING_WORKER_MAX_TIME_PER_COROUTINE,
|
||||
LOGGING_WORKER_CLEAR_PERCENTAGE,
|
||||
LOGGING_WORKER_AGGRESSIVE_CLEAR_COOLDOWN_SECONDS,
|
||||
MAX_ITERATIONS_TO_CLEAR_QUEUE,
|
||||
MAX_TIME_TO_CLEAR_QUEUE,
|
||||
)
|
||||
|
|
@ -424,11 +425,10 @@ class LoggingWorker:
|
|||
has_valid_handler = False
|
||||
for handler in verbose_logger.handlers:
|
||||
try:
|
||||
if hasattr(handler, 'stream') and handler.stream and not handler.stream.closed:
|
||||
has_valid_handler = True
|
||||
break
|
||||
elif not hasattr(handler, 'stream'):
|
||||
# Non-stream handlers (like NullHandler) are always valid
|
||||
stream = getattr(handler, "stream", None)
|
||||
if stream is None or not stream.closed:
|
||||
# Non-stream handlers (stream=None) are always valid;
|
||||
# stream handlers are valid when the stream is open.
|
||||
has_valid_handler = True
|
||||
break
|
||||
except (AttributeError, ValueError):
|
||||
|
|
@ -509,14 +509,39 @@ class LoggingWorker:
|
|||
finally:
|
||||
# Clear reference to prevent memory leaks
|
||||
task = None
|
||||
# Cancel any pending tasks created by the coroutine to prevent
|
||||
# "Task was destroyed but it is pending!" warnings during shutdown,
|
||||
# which can cause the process to exit with a non-zero code.
|
||||
try:
|
||||
pending = {
|
||||
t
|
||||
for t in asyncio.all_tasks(loop)
|
||||
if not t.done()
|
||||
}
|
||||
if pending:
|
||||
for pending_task in pending:
|
||||
pending_task.cancel()
|
||||
loop.run_until_complete(
|
||||
asyncio.gather(*pending, return_exceptions=True)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self._safe_log(
|
||||
"info",
|
||||
f"[LoggingWorker] atexit: Successfully flushed {processed} events!",
|
||||
)
|
||||
|
||||
except Exception:
|
||||
# Ensure atexit handler never propagates exceptions - an uncaught
|
||||
# exception here causes Python to re-raise it after all atexit
|
||||
# handlers run, making the process exit with a non-zero code.
|
||||
pass
|
||||
finally:
|
||||
loop.close()
|
||||
try:
|
||||
loop.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# Global instance for backward compatibility
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ sys.path.insert(
|
|||
) # Adds the parent directory to the system path
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from unittest.mock import AsyncMock, MagicMock, patch, ANY
|
||||
|
|
@ -3668,6 +3669,13 @@ def test_vertex_ai_llama_tool_calling():
|
|||
response = completion(**args)
|
||||
except litellm.RateLimitError:
|
||||
pytest.skip("Rate limit error")
|
||||
except Exception as e:
|
||||
pytest.skip(f"API error: {str(e)}")
|
||||
finally:
|
||||
# Restore log level to avoid polluting subsequent tests in the same worker
|
||||
litellm.verbose_logger.setLevel(logging.WARNING)
|
||||
litellm.verbose_router_logger.setLevel(logging.WARNING)
|
||||
litellm.verbose_proxy_logger.setLevel(logging.WARNING)
|
||||
print(response)
|
||||
|
||||
assert response.choices[0].message.tool_calls is not None
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue