diff --git a/docs/config/hatch.md b/docs/config/hatch.md index 7cc24365a..52bd1bacc 100644 --- a/docs/config/hatch.md +++ b/docs/config/hatch.md @@ -189,6 +189,23 @@ The following values have special meanings: | --- | --- | | `isolated` (default) | `/pythons` | +## Network + +### Timeout + +Requests that Hatch itself makes over the network, such as downloading Python distributions or publishing to a package index, time out after 10 seconds by default. + +You can change this by setting the `HATCH_NETWORK_TIMEOUT` environment variable to the desired number of seconds, which is useful on slow or heavily proxied connections: + +```console +$ HATCH_NETWORK_TIMEOUT=60 hatch python install 3.12 +``` + +The value must be a positive number, and may be fractional e.g. `2.5`. + +!!! note + This does not affect the tools that Hatch invokes to install dependencies. Configure those directly, such as with pip's [`--timeout`](https://pip.pypa.io/en/stable/cli/pip/#cmdoption-timeout) option or uv's [`UV_HTTP_TIMEOUT`](https://docs.astral.sh/uv/reference/environment/#uv_http_timeout) environment variable. + ## Terminal You can configure how all output is displayed using the `terminal.styles` table. These settings are also applied to all plugins. diff --git a/docs/history/hatch.md b/docs/history/hatch.md index 98392bde2..70d11143b 100644 --- a/docs/history/hatch.md +++ b/docs/history/hatch.md @@ -8,6 +8,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## Unreleased +***Added:*** + +- The default timeout for Hatch's own network requests can now be overridden with the `HATCH_NETWORK_TIMEOUT` environment variable. ***Fixed:*** diff --git a/src/hatch/config/constants.py b/src/hatch/config/constants.py index 8b1d68c8b..4ff84d9d6 100644 --- a/src/hatch/config/constants.py +++ b/src/hatch/config/constants.py @@ -10,6 +10,7 @@ class AppEnvVars: NO_COLOR = "NO_COLOR" FORCE_COLOR = "FORCE_COLOR" KEEP_ENV = "HATCH_KEEP_ENV" + NETWORK_TIMEOUT = "HATCH_NETWORK_TIMEOUT" class ConfigEnvVars: diff --git a/src/hatch/index/core.py b/src/hatch/index/core.py index cf32ffb3c..7e9dc8d0e 100644 --- a/src/hatch/index/core.py +++ b/src/hatch/index/core.py @@ -52,13 +52,13 @@ def client(self) -> httpx2.Client: import httpx2 from hatch.utils.linehaul import get_linehaul_component - from hatch.utils.network import DEFAULT_TIMEOUT + from hatch.utils.network import get_timeout user_agent = f"Hatch/{__version__} {get_linehaul_component()} HTTPX2/{httpx2.__version__}" return httpx2.Client( headers={"User-Agent": user_agent}, transport=httpx2.HTTPTransport(retries=3, verify=self.__verify, cert=self.__cert), - timeout=DEFAULT_TIMEOUT, + timeout=get_timeout(), ) def upload_artifact(self, artifact: Path, data: dict): diff --git a/src/hatch/utils/network.py b/src/hatch/utils/network.py index cac1dc512..3f36c5d71 100644 --- a/src/hatch/utils/network.py +++ b/src/hatch/utils/network.py @@ -1,9 +1,13 @@ from __future__ import annotations +import math +import os import time from contextlib import contextmanager from typing import TYPE_CHECKING, Any +from hatch.config.constants import AppEnvVars + if TYPE_CHECKING: from collections.abc import Generator @@ -19,6 +23,28 @@ DEFAULT_TIMEOUT = 10 +def get_timeout() -> float: + """ + The number of seconds to wait for network requests, defaulting to `DEFAULT_TIMEOUT`. This may be + overridden by the `HATCH_NETWORK_TIMEOUT` environment variable, which must be a positive number. + """ + timeout = os.environ.get(AppEnvVars.NETWORK_TIMEOUT, "").strip() + if not timeout: + return float(DEFAULT_TIMEOUT) + + try: + value = float(timeout) + except ValueError: + message = f"Environment variable `{AppEnvVars.NETWORK_TIMEOUT}` must be a number, not: {timeout}" + raise ValueError(message) from None + + if not math.isfinite(value) or value <= 0: + message = f"Environment variable `{AppEnvVars.NETWORK_TIMEOUT}` must be positive, not: {timeout}" + raise ValueError(message) + + return value + + @contextmanager def streaming_response(*args: Any, **kwargs: Any) -> Generator[httpx2.Response, None, None]: from secrets import choice @@ -43,7 +69,7 @@ def streaming_response(*args: Any, **kwargs: Any) -> Generator[httpx2.Response, def download_file(path: Path, *args: Any, **kwargs: Any) -> None: - kwargs.setdefault("timeout", DEFAULT_TIMEOUT) + kwargs.setdefault("timeout", get_timeout()) with path.open(mode="wb", buffering=0) as f, streaming_response("GET", *args, **kwargs) as response: for chunk in response.iter_bytes(16384): diff --git a/tests/utils/test_network.py b/tests/utils/test_network.py new file mode 100644 index 000000000..62f87fb17 --- /dev/null +++ b/tests/utils/test_network.py @@ -0,0 +1,72 @@ +import re + +import pytest + +from hatch.config.constants import AppEnvVars +from hatch.utils.network import DEFAULT_TIMEOUT, download_file, get_timeout +from hatch.utils.structures import EnvVars + + +class TestGetTimeout: + def test_default(self): + with EnvVars(exclude=[AppEnvVars.NETWORK_TIMEOUT]): + assert get_timeout() == DEFAULT_TIMEOUT + + @pytest.mark.parametrize("value", ["", " "]) + def test_unset_by_empty_value(self, value): + with EnvVars({AppEnvVars.NETWORK_TIMEOUT: value}): + assert get_timeout() == DEFAULT_TIMEOUT + + @pytest.mark.parametrize(("value", "expected"), [("30", 30.0), ("2.5", 2.5), (" 45 ", 45.0)]) + def test_override(self, value, expected): + with EnvVars({AppEnvVars.NETWORK_TIMEOUT: value}): + assert get_timeout() == expected + + @pytest.mark.parametrize("value", ["foo", "10s", "1,5"]) + def test_not_a_number(self, value): + with ( + EnvVars({AppEnvVars.NETWORK_TIMEOUT: value}), + pytest.raises( + ValueError, match=re.escape(f"Environment variable `{AppEnvVars.NETWORK_TIMEOUT}` must be a number") + ), + ): + get_timeout() + + @pytest.mark.parametrize("value", ["0", "-1", "nan", "inf"]) + def test_not_positive(self, value): + with ( + EnvVars({AppEnvVars.NETWORK_TIMEOUT: value}), + pytest.raises( + ValueError, match=re.escape(f"Environment variable `{AppEnvVars.NETWORK_TIMEOUT}` must be positive") + ), + ): + get_timeout() + + +class TestDownloadFile: + def test_default_timeout(self, mocker, temp_dir): + streaming_response = mocker.patch("hatch.utils.network.streaming_response") + streaming_response.return_value.__enter__.return_value.iter_bytes.return_value = [b"data"] + + with EnvVars(exclude=[AppEnvVars.NETWORK_TIMEOUT]): + download_file(temp_dir / "file.txt", "https://example.com") + + assert streaming_response.call_args.kwargs["timeout"] == DEFAULT_TIMEOUT + + def test_timeout_from_env_var(self, mocker, temp_dir): + streaming_response = mocker.patch("hatch.utils.network.streaming_response") + streaming_response.return_value.__enter__.return_value.iter_bytes.return_value = [b"data"] + + with EnvVars({AppEnvVars.NETWORK_TIMEOUT: "45"}): + download_file(temp_dir / "file.txt", "https://example.com") + + assert streaming_response.call_args.kwargs["timeout"] == 45.0 + + def test_explicit_timeout_takes_precedence(self, mocker, temp_dir): + streaming_response = mocker.patch("hatch.utils.network.streaming_response") + streaming_response.return_value.__enter__.return_value.iter_bytes.return_value = [b"data"] + + with EnvVars({AppEnvVars.NETWORK_TIMEOUT: "45"}): + download_file(temp_dir / "file.txt", "https://example.com", timeout=5) + + assert streaming_response.call_args.kwargs["timeout"] == 5