From 4a9a4d17ee807b0bb4faa2e5e9e798fa7314f67b Mon Sep 17 00:00:00 2001 From: Amadeus Date: Thu, 15 Oct 2026 21:40:06 -0300 Subject: [PATCH] feature: read the Docker client timeout from DOCKER_CLIENT_TIMEOUT The Docker SDK's default timeout of 60 seconds is too short for some Docker hosts. DOCKER_CLIENT_TIMEOUT, the variable Docker Compose reads for the same purpose, now sets the timeout of the client the CLI creates. When it is not set, the SDK default is used as before, and a value that is not a positive whole number of seconds is reported instead of being silently ignored. Closes #364 Co-Authored-By: Claude Opus 5.5 (1M context) --- lean/components/docker/docker_manager.py | 31 ++++++++++++- .../components/docker/test_docker_manager.py | 43 +++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) 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)