diff --git a/lean/components/docker/docker_manager.py b/lean/components/docker/docker_manager.py index c3bc80a0..2f6366d9 100644 --- a/lean/components/docker/docker_manager.py +++ b/lean/components/docker/docker_manager.py @@ -572,9 +572,14 @@ def _get_docker_client(self): error = MoreInfoError("Please make sure Docker is installed and running", "https://www.lean.io/docs/v2/lean-cli/key-concepts/troubleshooting#02-Common-Errors") + client_kwargs = {} + timeout = self._get_docker_client_timeout() + if timeout is not None: + client_kwargs["timeout"] = timeout + try: from docker import from_env - docker_client = from_env() + docker_client = from_env(**client_kwargs) except Exception: raise error @@ -586,6 +591,30 @@ def _get_docker_client(self): return docker_client + def _get_docker_client_timeout(self) -> Optional[int]: + """Returns the timeout for Docker API calls set through the DOCKER_CLIENT_TIMEOUT environment variable. + + This is the variable Docker Compose reads for the same purpose. The Docker SDK's default of 60 seconds + is too short for some Docker hosts, like remote ones or ones under heavy load. + + :return: the timeout in seconds, or None if the variable is not set, so the SDK default is used + """ + from os import environ + + value = environ.get("DOCKER_CLIENT_TIMEOUT") + if value is None or value.strip() == "": + return None + + try: + timeout = int(value) + except ValueError: + timeout = 0 + + if timeout <= 0: + raise RuntimeError(f"DOCKER_CLIENT_TIMEOUT must be a positive number of seconds, got '{value}'") + + return timeout + def _format_source_path(self, path: str) -> str: """Formats a source path so Docker knows what it refers to. diff --git a/tests/components/docker/test_docker_manager.py b/tests/components/docker/test_docker_manager.py index c3a146d4..6624134e 100644 --- a/tests/components/docker/test_docker_manager.py +++ b/tests/components/docker/test_docker_manager.py @@ -14,6 +14,9 @@ from pathlib import Path from unittest import mock +import docker +import pytest + from lean.components.docker.docker_manager import DockerManager @@ -46,3 +49,43 @@ def test_write_to_file_does_not_let_the_host_shell_expand_the_payload() -> None: # $type must reach the container untouched (raw JSON: real double quotes, no backslash escaping) assert '"$type"' in echo_command assert "\\" not in echo_command + + +def _get_docker_client_with_env(monkeypatch, value): + if value is None: + monkeypatch.delenv("DOCKER_CLIENT_TIMEOUT", raising=False) + else: + monkeypatch.setenv("DOCKER_CLIENT_TIMEOUT", value) + + docker_manager = _create_docker_manager() + # docker is imported at the top of this file: the tests run on a fake filesystem, where importing it + # inside the test would fail + with mock.patch.object(docker, "from_env") as from_env_mock: + from_env_mock.return_value.ping.return_value = True + docker_manager._get_docker_client() + + return from_env_mock + + +def test_get_docker_client_uses_the_sdk_default_timeout_when_docker_client_timeout_is_not_set(monkeypatch) -> None: + from_env_mock = _get_docker_client_with_env(monkeypatch, None) + + from_env_mock.assert_called_once_with() + + +def test_get_docker_client_uses_the_sdk_default_timeout_when_docker_client_timeout_is_empty(monkeypatch) -> None: + from_env_mock = _get_docker_client_with_env(monkeypatch, " ") + + from_env_mock.assert_called_once_with() + + +def test_get_docker_client_passes_docker_client_timeout_to_the_docker_client(monkeypatch) -> None: + from_env_mock = _get_docker_client_with_env(monkeypatch, "300") + + from_env_mock.assert_called_once_with(timeout=300) + + +@pytest.mark.parametrize("value", ["abc", "0", "-5", "1.5"]) +def test_get_docker_client_raises_when_docker_client_timeout_is_invalid(monkeypatch, value: str) -> None: + with pytest.raises(RuntimeError, match="DOCKER_CLIENT_TIMEOUT"): + _get_docker_client_with_env(monkeypatch, value)