fix(proxy): fall back to default asyncio loop when uvloop import fails (#20933)

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 <module>
        import uvloop
    File "uvloop/__init__.py", line 7, in <module>
        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.
This commit is contained in:
Aldrich_CC 2026-05-16 03:47:16 +08:00
parent 50df072d95
commit 986ff76a83
2 changed files with 60 additions and 3 deletions

View file

@ -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

View file

@ -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(