From 610995cdb6f3ae2d22b3ed3aedf9984fa51ee80f Mon Sep 17 00:00:00 2001 From: Puneet Date: Sat, 25 Jul 2026 20:59:13 +0530 Subject: [PATCH] Allow overriding the network timeout with HATCH_NETWORK_TIMEOUT The default timeout applied to Hatch's own network requests was hardcoded to 10 seconds, which is too short on slow or heavily proxied connections. The only workaround was patching the constant. Add `get_timeout`, which returns `DEFAULT_TIMEOUT` unless the `HATCH_NETWORK_TIMEOUT` environment variable is set to a positive number, and use it for downloads and the package index client. An environment variable is used rather than a `config.toml` option because neither call site has access to the application config: `PythonManager` is constructed without it and the default template only receives a cache directory. Closes #2158 Co-Authored-By: Claude Opus 5 --- docs/config/hatch.md | 17 +++++++++ docs/history/hatch.md | 3 ++ src/hatch/config/constants.py | 1 + src/hatch/index/core.py | 4 +- src/hatch/utils/network.py | 28 +++++++++++++- tests/utils/test_network.py | 72 +++++++++++++++++++++++++++++++++++ 6 files changed, 122 insertions(+), 3 deletions(-) create mode 100644 tests/utils/test_network.py 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