From 8f19a18290789abcab7345b8a43b8b37cd371874 Mon Sep 17 00:00:00 2001 From: agustin18 Date: Sun, 27 Sep 2026 22:59:52 +0000 Subject: [PATCH 1/6] fix(timeout): respect per-call request_timeout in async timeout decorator (#43497) Signed-off-by: agustin18 --- litellm/timeout.py | 14 ++++--- tests/unit/test_timeout.py | 76 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 6 deletions(-) create mode 100644 tests/unit/test_timeout.py diff --git a/litellm/timeout.py b/litellm/timeout.py index 33d1ce92e53..8a27f7c3fe5 100644 --- a/litellm/timeout.py +++ b/litellm/timeout.py @@ -65,13 +65,15 @@ def timeout(timeout_duration: float = 0.0, exception_to_raise=Timeout): @wraps(func) async def async_wrapper(*args, **kwargs): - local_timeout_duration = timeout_duration - if "force_timeout" in kwargs: - local_timeout_duration = kwargs["force_timeout"] - elif "request_timeout" in kwargs and kwargs["request_timeout"] is not None: - local_timeout_duration = kwargs["request_timeout"] + local_timeout_duration: Final = ( + kwargs["force_timeout"] + if kwargs.get("force_timeout") is not None + else kwargs["request_timeout"] + if kwargs.get("request_timeout") is not None + else timeout_duration + ) try: - value: Final = await asyncio.wait_for(func(*args, **kwargs), timeout=timeout_duration) + value: Final = await asyncio.wait_for(func(*args, **kwargs), timeout=local_timeout_duration) return value except asyncio.TimeoutError: model: Final = args[0] if len(args) > 0 else kwargs["model"] diff --git a/tests/unit/test_timeout.py b/tests/unit/test_timeout.py new file mode 100644 index 00000000000..6656c20e9b7 --- /dev/null +++ b/tests/unit/test_timeout.py @@ -0,0 +1,76 @@ +"""Unit tests for litellm.timeout decorator.""" + +import asyncio +import time +from typing import Final + +import pytest + +from litellm.exceptions import Timeout +from litellm.timeout import timeout + + +@pytest.mark.asyncio +@pytest.mark.parametrize("timeout_arg", ["request_timeout", "force_timeout"]) +async def test_async_timeout_decorator_respects_per_call_timeout(timeout_arg: str): + """An async function decorated with a large timeout must respect a shorter per-call timeout.""" + + @timeout(timeout_duration=5.0) + async def slow_async_func(**kwargs): + await asyncio.sleep(0.5) + return "ok" + + start_time: Final = time.monotonic() + kwargs = {timeout_arg: 0.05, "model": "test-model"} + + with pytest.raises(Timeout) as exc_info: + await slow_async_func(**kwargs) + + elapsed: Final = time.monotonic() - start_time + assert elapsed < 0.3, f"Call waited {elapsed}s instead of respecting {timeout_arg}=0.05" + assert "0.05 second(s)" in str(exc_info.value) + + +@pytest.mark.asyncio +async def test_async_timeout_decorator_extends_duration_with_per_call_timeout(): + """An async function with a small default timeout must not time out if request_timeout gives more time.""" + + @timeout(timeout_duration=0.05) + async def moderately_slow_async_func(**kwargs): + await asyncio.sleep(0.1) + return "completed" + + result = await moderately_slow_async_func(request_timeout=1.0, model="test-model") + assert result == "completed" + + +@pytest.mark.asyncio +async def test_async_timeout_decorator_handles_none_force_timeout(): + """Passing force_timeout=None should not break timeout_duration fallback.""" + + @timeout(timeout_duration=0.05) + async def slow_async_func(**kwargs): + await asyncio.sleep(0.2) + return "ok" + + with pytest.raises(Timeout) as exc_info: + await slow_async_func(force_timeout=None, model="test-model") + + assert "0.05 second(s)" in str(exc_info.value) + + +def test_sync_timeout_decorator_respects_per_call_timeout(): + """A sync function decorated with a large timeout must respect a shorter per-call timeout.""" + + @timeout(timeout_duration=5.0) + def slow_sync_func(**kwargs): + time.sleep(0.5) + return "ok" + + start_time: Final = time.monotonic() + with pytest.raises(Timeout) as exc_info: + slow_sync_func(request_timeout=0.05, model="test-model") + + elapsed: Final = time.monotonic() - start_time + assert elapsed < 0.3, f"Call waited {elapsed}s instead of respecting request_timeout=0.05" + assert "0.05 second(s)" in str(exc_info.value) From 8cd366911f1fbc7dba1e8779f4955facabcd3fe2 Mon Sep 17 00:00:00 2001 From: agustin18 Date: Sun, 27 Sep 2026 23:06:30 +0000 Subject: [PATCH 2/6] test(timeout): use clock-free unit tests per AGENTS.md Signed-off-by: agustin18 --- tests/unit/test_timeout.py | 101 ++++++++++++++++++++++--------------- 1 file changed, 61 insertions(+), 40 deletions(-) diff --git a/tests/unit/test_timeout.py b/tests/unit/test_timeout.py index 6656c20e9b7..e6f1d915ce2 100644 --- a/tests/unit/test_timeout.py +++ b/tests/unit/test_timeout.py @@ -1,7 +1,6 @@ """Unit tests for litellm.timeout decorator.""" import asyncio -import time from typing import Final import pytest @@ -12,65 +11,87 @@ from litellm.timeout import timeout @pytest.mark.asyncio @pytest.mark.parametrize("timeout_arg", ["request_timeout", "force_timeout"]) -async def test_async_timeout_decorator_respects_per_call_timeout(timeout_arg: str): - """An async function decorated with a large timeout must respect a shorter per-call timeout.""" +async def test_async_timeout_decorator_passes_per_call_timeout( + monkeypatch: pytest.MonkeyPatch, timeout_arg: str +) -> None: + captured_timeout: Final[list[float | None]] = [] + + async def fake_wait_for(fut, timeout): + captured_timeout.append(timeout) + return await fut + + monkeypatch.setattr("litellm.timeout.asyncio.wait_for", fake_wait_for) @timeout(timeout_duration=5.0) - async def slow_async_func(**kwargs): - await asyncio.sleep(0.5) + async def sample_func(**kwargs): return "ok" - start_time: Final = time.monotonic() - kwargs = {timeout_arg: 0.05, "model": "test-model"} + result: Final = await sample_func(**{timeout_arg: 0.05, "model": "test-model"}) + assert result == "ok" + assert captured_timeout == [0.05] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("timeout_arg", ["request_timeout", "force_timeout"]) +async def test_async_timeout_decorator_raises_with_per_call_timeout_message( + monkeypatch: pytest.MonkeyPatch, timeout_arg: str +) -> None: + async def fake_wait_for_timeout(fut, timeout): + fut.close() + raise asyncio.TimeoutError() + + monkeypatch.setattr("litellm.timeout.asyncio.wait_for", fake_wait_for_timeout) + + @timeout(timeout_duration=5.0) + async def sample_func(**kwargs): + return "ok" with pytest.raises(Timeout) as exc_info: - await slow_async_func(**kwargs) + await sample_func(**{timeout_arg: 0.05, "model": "test-model"}) - elapsed: Final = time.monotonic() - start_time - assert elapsed < 0.3, f"Call waited {elapsed}s instead of respecting {timeout_arg}=0.05" assert "0.05 second(s)" in str(exc_info.value) @pytest.mark.asyncio -async def test_async_timeout_decorator_extends_duration_with_per_call_timeout(): - """An async function with a small default timeout must not time out if request_timeout gives more time.""" +async def test_async_timeout_decorator_handles_none_force_timeout(monkeypatch: pytest.MonkeyPatch) -> None: + captured_timeout: Final[list[float | None]] = [] - @timeout(timeout_duration=0.05) - async def moderately_slow_async_func(**kwargs): - await asyncio.sleep(0.1) - return "completed" + async def fake_wait_for(fut, timeout): + captured_timeout.append(timeout) + return await fut - result = await moderately_slow_async_func(request_timeout=1.0, model="test-model") - assert result == "completed" + monkeypatch.setattr("litellm.timeout.asyncio.wait_for", fake_wait_for) + + @timeout(timeout_duration=5.0) + async def sample_func(**kwargs): + return "ok" + + await sample_func(force_timeout=None, model="test-model") + assert captured_timeout == [5.0] @pytest.mark.asyncio -async def test_async_timeout_decorator_handles_none_force_timeout(): - """Passing force_timeout=None should not break timeout_duration fallback.""" +async def test_async_timeout_decorator_uses_default_timeout(monkeypatch: pytest.MonkeyPatch) -> None: + captured_timeout: Final[list[float | None]] = [] - @timeout(timeout_duration=0.05) - async def slow_async_func(**kwargs): - await asyncio.sleep(0.2) + async def fake_wait_for(fut, timeout): + captured_timeout.append(timeout) + return await fut + + monkeypatch.setattr("litellm.timeout.asyncio.wait_for", fake_wait_for) + + @timeout(timeout_duration=42.0) + async def sample_func(**kwargs): return "ok" - with pytest.raises(Timeout) as exc_info: - await slow_async_func(force_timeout=None, model="test-model") - - assert "0.05 second(s)" in str(exc_info.value) + await sample_func(model="test-model") + assert captured_timeout == [42.0] -def test_sync_timeout_decorator_respects_per_call_timeout(): - """A sync function decorated with a large timeout must respect a shorter per-call timeout.""" - +def test_sync_timeout_decorator_runs_without_clock() -> None: @timeout(timeout_duration=5.0) - def slow_sync_func(**kwargs): - time.sleep(0.5) - return "ok" + def sample_sync_func(**kwargs): + return "sync_ok" - start_time: Final = time.monotonic() - with pytest.raises(Timeout) as exc_info: - slow_sync_func(request_timeout=0.05, model="test-model") - - elapsed: Final = time.monotonic() - start_time - assert elapsed < 0.3, f"Call waited {elapsed}s instead of respecting request_timeout=0.05" - assert "0.05 second(s)" in str(exc_info.value) + result: Final = sample_sync_func(request_timeout=0.05, model="test-model") + assert result == "sync_ok" From 0c4e2874b0f77a55a9a62f9969022b3425b7105a Mon Sep 17 00:00:00 2001 From: agustin18 Date: Sun, 27 Sep 2026 23:26:33 +0000 Subject: [PATCH 3/6] test(timeout): test real timeout enforcement without wall-clock assertions Signed-off-by: agustin18 --- tests/unit/test_timeout.py | 100 ++++++++++++------------------------- 1 file changed, 31 insertions(+), 69 deletions(-) diff --git a/tests/unit/test_timeout.py b/tests/unit/test_timeout.py index e6f1d915ce2..160dd49f3eb 100644 --- a/tests/unit/test_timeout.py +++ b/tests/unit/test_timeout.py @@ -1,6 +1,7 @@ """Unit tests for litellm.timeout decorator.""" import asyncio +import time from typing import Final import pytest @@ -11,87 +12,48 @@ from litellm.timeout import timeout @pytest.mark.asyncio @pytest.mark.parametrize("timeout_arg", ["request_timeout", "force_timeout"]) -async def test_async_timeout_decorator_passes_per_call_timeout( - monkeypatch: pytest.MonkeyPatch, timeout_arg: str -) -> None: - captured_timeout: Final[list[float | None]] = [] - - async def fake_wait_for(fut, timeout): - captured_timeout.append(timeout) - return await fut - - monkeypatch.setattr("litellm.timeout.asyncio.wait_for", fake_wait_for) - - @timeout(timeout_duration=5.0) - async def sample_func(**kwargs): - return "ok" - - result: Final = await sample_func(**{timeout_arg: 0.05, "model": "test-model"}) - assert result == "ok" - assert captured_timeout == [0.05] - - -@pytest.mark.asyncio -@pytest.mark.parametrize("timeout_arg", ["request_timeout", "force_timeout"]) -async def test_async_timeout_decorator_raises_with_per_call_timeout_message( - monkeypatch: pytest.MonkeyPatch, timeout_arg: str -) -> None: - async def fake_wait_for_timeout(fut, timeout): - fut.close() - raise asyncio.TimeoutError() - - monkeypatch.setattr("litellm.timeout.asyncio.wait_for", fake_wait_for_timeout) - - @timeout(timeout_duration=5.0) - async def sample_func(**kwargs): - return "ok" +async def test_async_timeout_decorator_enforces_per_call_timeout(timeout_arg: str) -> None: + @timeout(timeout_duration=60.0) + async def hung_func(**kwargs): + await asyncio.sleep(10.0) + return "never" with pytest.raises(Timeout) as exc_info: - await sample_func(**{timeout_arg: 0.05, "model": "test-model"}) + await hung_func(**{timeout_arg: 0.001, "model": "test-model"}) - assert "0.05 second(s)" in str(exc_info.value) + assert "0.001 second(s)" in str(exc_info.value) @pytest.mark.asyncio -async def test_async_timeout_decorator_handles_none_force_timeout(monkeypatch: pytest.MonkeyPatch) -> None: - captured_timeout: Final[list[float | None]] = [] +async def test_async_timeout_decorator_extends_duration_with_per_call_timeout() -> None: + @timeout(timeout_duration=0.001) + async def fast_func(**kwargs): + return "completed" - async def fake_wait_for(fut, timeout): - captured_timeout.append(timeout) - return await fut - - monkeypatch.setattr("litellm.timeout.asyncio.wait_for", fake_wait_for) - - @timeout(timeout_duration=5.0) - async def sample_func(**kwargs): - return "ok" - - await sample_func(force_timeout=None, model="test-model") - assert captured_timeout == [5.0] + result: Final = await fast_func(request_timeout=60.0, model="test-model") + assert result == "completed" @pytest.mark.asyncio -async def test_async_timeout_decorator_uses_default_timeout(monkeypatch: pytest.MonkeyPatch) -> None: - captured_timeout: Final[list[float | None]] = [] +async def test_async_timeout_decorator_handles_none_force_timeout() -> None: + @timeout(timeout_duration=0.001) + async def hung_func(**kwargs): + await asyncio.sleep(10.0) + return "never" - async def fake_wait_for(fut, timeout): - captured_timeout.append(timeout) - return await fut + with pytest.raises(Timeout) as exc_info: + await hung_func(force_timeout=None, model="test-model") - monkeypatch.setattr("litellm.timeout.asyncio.wait_for", fake_wait_for) - - @timeout(timeout_duration=42.0) - async def sample_func(**kwargs): - return "ok" - - await sample_func(model="test-model") - assert captured_timeout == [42.0] + assert "0.001 second(s)" in str(exc_info.value) -def test_sync_timeout_decorator_runs_without_clock() -> None: - @timeout(timeout_duration=5.0) - def sample_sync_func(**kwargs): - return "sync_ok" +def test_sync_timeout_decorator_enforces_per_call_timeout() -> None: + @timeout(timeout_duration=60.0) + def hung_sync_func(**kwargs): + time.sleep(10.0) + return "never" - result: Final = sample_sync_func(request_timeout=0.05, model="test-model") - assert result == "sync_ok" + with pytest.raises(Timeout) as exc_info: + hung_sync_func(request_timeout=0.001, model="test-model") + + assert "0.001 second(s)" in str(exc_info.value) From 818812fdc80b514eb0a725cf4ed3e7568c85e729 Mon Sep 17 00:00:00 2001 From: agustin18 Date: Mon, 28 Sep 2026 20:04:59 +0000 Subject: [PATCH 4/6] test(timeout): test timeout extension against regressions with delayed call Signed-off-by: agustin18 --- tests/unit/test_timeout.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/unit/test_timeout.py b/tests/unit/test_timeout.py index 160dd49f3eb..4f8a247f90c 100644 --- a/tests/unit/test_timeout.py +++ b/tests/unit/test_timeout.py @@ -25,12 +25,16 @@ async def test_async_timeout_decorator_enforces_per_call_timeout(timeout_arg: st @pytest.mark.asyncio -async def test_async_timeout_decorator_extends_duration_with_per_call_timeout() -> None: +@pytest.mark.parametrize("timeout_arg", ["request_timeout", "force_timeout"]) +async def test_async_timeout_decorator_extends_duration_with_per_call_timeout( + timeout_arg: str, +) -> None: @timeout(timeout_duration=0.001) - async def fast_func(**kwargs): + async def delayed_func(**kwargs): + await asyncio.sleep(0.01) return "completed" - result: Final = await fast_func(request_timeout=60.0, model="test-model") + result: Final = await delayed_func(**{timeout_arg: 60.0, "model": "test-model"}) assert result == "completed" From 25b3f99e361714e1c992423663f9351a11cc73dc Mon Sep 17 00:00:00 2001 From: agustin18 Date: Sun, 18 Oct 2026 20:45:30 +0000 Subject: [PATCH 5/6] fix(timeout): add explicit typing for local_timeout_duration to pass type_check_gate Signed-off-by: agustin18 --- litellm/timeout.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/litellm/timeout.py b/litellm/timeout.py index 8a27f7c3fe5..2b9e04a07c7 100644 --- a/litellm/timeout.py +++ b/litellm/timeout.py @@ -16,7 +16,7 @@ from concurrent import futures from functools import wraps from inspect import iscoroutinefunction from threading import Thread -from typing import Final +from typing import Final, cast from litellm.exceptions import Timeout @@ -65,13 +65,11 @@ def timeout(timeout_duration: float = 0.0, exception_to_raise=Timeout): @wraps(func) async def async_wrapper(*args, **kwargs): - local_timeout_duration: Final = ( - kwargs["force_timeout"] - if kwargs.get("force_timeout") is not None - else kwargs["request_timeout"] - if kwargs.get("request_timeout") is not None - else timeout_duration - ) + local_timeout_duration: float | None = timeout_duration + if "force_timeout" in kwargs and kwargs["force_timeout"] is not None: + local_timeout_duration = cast(float | None, kwargs["force_timeout"]) + elif "request_timeout" in kwargs and kwargs["request_timeout"] is not None: + local_timeout_duration = cast(float | None, kwargs["request_timeout"]) try: value: Final = await asyncio.wait_for(func(*args, **kwargs), timeout=local_timeout_duration) return value From 5ca091b210833a122ed79e998db786f91d7fc5e8 Mon Sep 17 00:00:00 2001 From: agustin18 Date: Sun, 18 Oct 2026 21:16:49 +0000 Subject: [PATCH 6/6] fix(timeout): eliminate banned cast import and use float conversion Signed-off-by: agustin18 --- litellm/timeout.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/litellm/timeout.py b/litellm/timeout.py index 2b9e04a07c7..b83fccbdaf4 100644 --- a/litellm/timeout.py +++ b/litellm/timeout.py @@ -16,7 +16,7 @@ from concurrent import futures from functools import wraps from inspect import iscoroutinefunction from threading import Thread -from typing import Final, cast +from typing import Final from litellm.exceptions import Timeout @@ -66,10 +66,10 @@ def timeout(timeout_duration: float = 0.0, exception_to_raise=Timeout): @wraps(func) async def async_wrapper(*args, **kwargs): local_timeout_duration: float | None = timeout_duration - if "force_timeout" in kwargs and kwargs["force_timeout"] is not None: - local_timeout_duration = cast(float | None, kwargs["force_timeout"]) - elif "request_timeout" in kwargs and kwargs["request_timeout"] is not None: - local_timeout_duration = cast(float | None, kwargs["request_timeout"]) + if "force_timeout" in kwargs and isinstance(kwargs["force_timeout"], (int, float)): + local_timeout_duration = float(kwargs["force_timeout"]) + elif "request_timeout" in kwargs and isinstance(kwargs["request_timeout"], (int, float)): + local_timeout_duration = float(kwargs["request_timeout"]) try: value: Final = await asyncio.wait_for(func(*args, **kwargs), timeout=local_timeout_duration) return value