From 986ff76a83fa2ae3842034205635637872b82aed Mon Sep 17 00:00:00 2001 From: Aldrich_CC <109075336+Chen17-sq@users.noreply.github.com> Date: Sat, 16 May 2026 03:47:16 +0800 Subject: [PATCH] fix(proxy): fall back to default asyncio loop when uvloop import fails (#20933) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LiteLLM Proxy fails to start on Python 3.14.2 because the pinned ``uvloop==0.21.0`` (in ``[project.optional-dependencies] proxy``) can't import on 3.14 — Python 3.14 removed the ``BaseDefaultEventLoopPolicy`` symbol uvloop 0.21 imports during its own initialisation. The user-facing crash is:: File "uvicorn/loops/uvloop.py", line 3, in import uvloop File "uvloop/__init__.py", line 7, in from asyncio.events import BaseDefaultEventLoopPolicy ImportError: cannot import name 'BaseDefaultEventLoopPolicy' from 'asyncio.events' 44 users 👍'd the issue (#20933, 10 + the workaround comments). Current workaround is ``pip install uvloop==0.22.1 --force`` AFTER ``pip install litellm[proxy]``, which is fragile (next ``pip install --upgrade`` reverts it) and requires uv users to fight with ``override-dependencies`` in ``pyproject.toml``. This patch makes ``ProxyInitializationHelpers._get_loop_type()`` — the one place that hands uvicorn ``loop="uvloop"`` — probe the import before returning the string. If uvloop can't be imported on the current Python, return ``None`` and let uvicorn pick the default asyncio loop. Three lines of real code change. Users wanting uvloop's perf back on a previously-broken Python install ``uvloop>=0.22.1`` themselves; the probe picks the new wheel up automatically. No ``pyproject.toml`` change is included — that would force a ``uv.lock`` regen across the whole resolution graph, which is out of scope for this issue. Test plan --------- ``tests/test_litellm/proxy/test_proxy_cli.py`` gains three ``_get_loop_type`` tests: - returns ``"uvloop"`` on Linux when uvloop imports cleanly - returns ``None`` on Windows / cygwin (existing behaviour, locked) - returns ``None`` when ``import uvloop`` raises ImportError (simulates the Python 3.14 + uvloop 0.21 case via ``patch("builtins.__import__")``) All three pass plus 31 pre-existing tests in the same file. Closes #20933. --- litellm/proxy/proxy_cli.py | 24 +++++++++++-- tests/test_litellm/proxy/test_proxy_cli.py | 39 +++++++++++++++++++++- 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/litellm/proxy/proxy_cli.py b/litellm/proxy/proxy_cli.py index 5fc8c44b2d8..40ce102b8d5 100644 --- a/litellm/proxy/proxy_cli.py +++ b/litellm/proxy/proxy_cli.py @@ -398,9 +398,29 @@ class ProxyInitializationHelpers: @staticmethod def _get_loop_type(): - """Helper function to determine the event loop type based on platform""" + """Helper function to determine the event loop type based on platform. + + Returns ``None`` (let uvicorn pick the default asyncio loop) when: + + - the platform doesn't ship uvloop (Windows + cygwin); OR + - uvloop is installed but can't be imported on the current + Python — see GH #20933, where the pinned ``uvloop==0.21.0`` + fails on Python 3.14.2 with ``ImportError: cannot import + name 'BaseDefaultEventLoopPolicy' from 'asyncio.events'``. + + Probing via ``import uvloop`` here is cheap (one-time at proxy + startup) and lets the proxy boot under any Python version even + when the lock-file-pinned uvloop happens to be incompatible. + Users wanting uvloop's perf back on a previously-broken Python + install ``uvloop>=0.22.1`` themselves; the probe picks the new + wheel up automatically. + """ if sys.platform in ("win32", "cygwin", "cli"): - return None # Let uvicorn choose the default loop on Windows + return None + try: + import uvloop # noqa: F401 + except ImportError: + return None return "uvloop" @staticmethod diff --git a/tests/test_litellm/proxy/test_proxy_cli.py b/tests/test_litellm/proxy/test_proxy_cli.py index 327200a6a95..ff649019a4b 100644 --- a/tests/test_litellm/proxy/test_proxy_cli.py +++ b/tests/test_litellm/proxy/test_proxy_cli.py @@ -8,7 +8,6 @@ sys.path.insert( 0, os.path.abspath("../../..") ) # Adds the parent directory to the system-path -import builtins import types from litellm.proxy.proxy_cli import ProxyInitializationHelpers @@ -82,6 +81,44 @@ class TestProxyInitializationHelpers: ) mock_client.chat.completions.create.assert_called() + @patch("sys.platform", "linux") + def test_get_loop_type_returns_uvloop_when_available(self): + """On Linux/macOS with a working uvloop install, return "uvloop".""" + # uvloop is in the proxy extras and importable in this test env. + assert ProxyInitializationHelpers._get_loop_type() == "uvloop" + + @patch("sys.platform", "win32") + def test_get_loop_type_returns_none_on_windows(self): + """Windows / cygwin don't ship uvloop — let uvicorn pick the default.""" + assert ProxyInitializationHelpers._get_loop_type() is None + + @patch("sys.platform", "linux") + def test_get_loop_type_returns_none_when_uvloop_unimportable(self): + """GH #20933: uvloop 0.21.0 fails to import on Python 3.14.2 with + ``ImportError: cannot import name 'BaseDefaultEventLoopPolicy' + from 'asyncio.events'``. Before the fix this propagated up + through ``uvicorn.run`` and crashed proxy startup. The probe in + ``_get_loop_type`` now catches the ImportError and falls back + to ``None`` so uvicorn picks the default asyncio loop. + + Implementation note: setting ``sys.modules["uvloop"] = None`` + is the documented Python idiom for forcing the next + ``import uvloop`` statement to raise ImportError (see CPython + importlib source). This routes through the real ``try / except`` + block in ``_get_loop_type`` rather than mocking it away, so + coverage tools see the fallback branch executed. + """ + # Drop any cached real-uvloop module so the patched None takes effect. + original = sys.modules.get("uvloop", _MISSING := object()) + sys.modules["uvloop"] = None # type: ignore[assignment] + try: + assert ProxyInitializationHelpers._get_loop_type() is None + finally: + if original is _MISSING: + sys.modules.pop("uvloop", None) + else: + sys.modules["uvloop"] = original + def test_get_default_unvicorn_init_args(self): # Test without log_config args = ProxyInitializationHelpers._get_default_unvicorn_init_args(