Repository: conan-io/conan

Issue:
Add a way to configure timeout for downloading files from a remote
We had a few jobs get stuck in downloading the package from the remote because of a wrongly configured network. These would then get stuck for hours and we wouldn't notice in the CI until someone manually checked. I would like to be able to set a sane default timeout for the download to requests. Via environment or conan.conf.


Discussion:
Actually, the "requests" library recommend to specify a timeout in a parameter for production, and we are not doing it. What would be a sane timeout for you?
I googled around a bit and it seems like both firefox and chrome are using 30seconds for this. So that sounds sane to me.

Note this:

```
timeout is not a time limit on the entire response download; rather, an exception is raised if the server has not issued a response for timeout seconds (more precisely, if no bytes have been received on the underlying socket for timeout seconds). If no timeout is specified explicitly, requests do not time out.
```

in that context I think 30 seconds seems nice.

Resolving patch:
diff --git a/conans/client/conan_api.py b/conans/client/conan_api.py
--- a/conans/client/conan_api.py
+++ b/conans/client/conan_api.py
@@ -45,10 +45,19 @@
 default_manifest_folder = '.conan_manifests'
 
 
+def get_request_timeout():
+    timeout = os.getenv("CONAN_REQUEST_TIMEOUT")
+    try:
+        return float(timeout) if timeout is not None else None
+    except ValueError:
+        raise ConanException("Specify a numeric parameter for 'request_timeout'")
+
+
 def get_basic_requester(client_cache):
     requester = requests.Session()
     # Manage the verify and the client certificates and setup proxies
-    return ConanRequester(requester, client_cache)
+
+    return ConanRequester(requester, client_cache, get_request_timeout())
 
 
 def api_method(f):
diff --git a/conans/client/conf/__init__.py b/conans/client/conf/__init__.py
--- a/conans/client/conf/__init__.py
+++ b/conans/client/conf/__init__.py
@@ -84,6 +84,7 @@
 default_profile = %s
 compression_level = 9                 # environment CONAN_COMPRESSION_LEVEL
 sysrequires_sudo = True               # environment CONAN_SYSREQUIRES_SUDO
+request_timeout = 60                  # environment CONAN_REQUEST_TIMEOUT (seconds)
 # sysrequires_mode = enabled            # environment CONAN_SYSREQUIRES_MODE (allowed modes enabled/verify/disabled)
 # vs_installation_preference = Enterprise, Professional, Community, BuildTools # environment CONAN_VS_INSTALLATION_PREFERENCE
 # verbose_traceback = False           # environment CONAN_VERBOSE_TRACEBACK
@@ -161,6 +162,7 @@ def env_vars(self):
                "CONAN_PYLINT_WERR": self._env_c("general.pylint_werr", "CONAN_PYLINT_WERR", None),
                "CONAN_SYSREQUIRES_SUDO": self._env_c("general.sysrequires_sudo", "CONAN_SYSREQUIRES_SUDO", "False"),
                "CONAN_SYSREQUIRES_MODE": self._env_c("general.sysrequires_mode", "CONAN_SYSREQUIRES_MODE", "enabled"),
+               "CONAN_REQUEST_TIMEOUT": self._env_c("general.request_timeout", "CONAN_REQUEST_TIMEOUT", None),
                "CONAN_VS_INSTALLATION_PREFERENCE": self._env_c("general.vs_installation_preference", "CONAN_VS_INSTALLATION_PREFERENCE", None),
                "CONAN_RECIPE_LINTER": self._env_c("general.recipe_linter", "CONAN_RECIPE_LINTER", "True"),
                "CONAN_CPU_COUNT": self._env_c("general.cpu_count", "CONAN_CPU_COUNT", None),
@@ -288,6 +290,13 @@ def cache_no_locks(self):
     def storage(self):
         return dict(self.get_conf("storage"))
 
+    @property
+    def request_timeout(self):
+        try:
+            return self.get_item("general.request_timeout")
+        except ConanException:
+            return None
+
     @property
     def storage_path(self):
         # Try with CONAN_STORAGE_PATH
diff --git a/conans/client/rest/conan_requester.py b/conans/client/rest/conan_requester.py
--- a/conans/client/rest/conan_requester.py
+++ b/conans/client/rest/conan_requester.py
@@ -6,10 +6,11 @@
 
 class ConanRequester(object):
 
-    def __init__(self, requester, client_cache):
+    def __init__(self, requester, client_cache, timeout):
         self.proxies = client_cache.conan_config.proxies or {}
         self._no_proxy_match = [el.strip() for el in
                                 self.proxies.pop("no_proxy_match", "").split(",")]
+        self._timeout_seconds = timeout
 
         # Retrocompatibility with deprecated no_proxy
         # Account for the requests NO_PROXY env variable, not defined as a proxy like http=
@@ -52,6 +53,8 @@ def _add_kwargs(self, url, kwargs):
         if self.proxies:
             if not self._should_skip_proxy(url):
                 kwargs["proxies"] = self.proxies
+        if self._timeout_seconds:
+            kwargs["timeout"] = self._timeout_seconds
         return kwargs
 
     def get(self, url, **kwargs):


Test patch:
diff --git a/conans/test/functional/requester_test.py b/conans/test/functional/requester_test.py
new file mode 100644
--- /dev/null
+++ b/conans/test/functional/requester_test.py
@@ -0,0 +1,57 @@
+import unittest
+
+from conans import tools
+from conans.test.utils.tools import TestClient
+from conans.util.files import save
+
+
+class MyRequester(object):
+
+    def __init__(*args, **kwargs):
+        pass
+
+    def get(self, _, **kwargs):
+        return kwargs.get("timeout", "NOT SPECIFIED")
+
+
+class RequesterTest(unittest.TestCase):
+
+    def requester_timeout_test(self):
+
+        client = TestClient(requester_class=MyRequester)
+        conf = """
+[general]
+request_timeout=2
+"""
+        save(client.client_cache.conan_conf_path, conf)
+        client.init_dynamic_vars()
+
+        self.assertEquals(client.requester.get("MyUrl"), 2.0)
+
+        with tools.environment_append({"CONAN_REQUEST_TIMEOUT": "4.3"}):
+            client = TestClient(requester_class=MyRequester)
+            client.init_dynamic_vars()
+            self.assertEquals(client.requester.get("MyUrl"), 4.3)
+
+    def requester_timeout_errors_test(self):
+        client = TestClient(requester_class=MyRequester)
+        conf = """
+[general]
+request_timeout=any_string
+"""
+        save(client.client_cache.conan_conf_path, conf)
+        with self.assertRaisesRegexp(Exception, "Specify a numeric parameter for 'request_timeout'"):
+            client.run("install Lib/1.0@conan/stable")
+
+    def no_request_timeout_test(self):
+        # Test that not having timeout works
+        client = TestClient(requester_class=MyRequester)
+        conf = """
+[general]
+"""
+        save(client.client_cache.conan_conf_path, conf)
+        client.init_dynamic_vars()
+        self.assertEquals(client.requester.get("MyUrl"), "NOT SPECIFIED")
+
+
+
diff --git a/conans/test/utils/tools.py b/conans/test/utils/tools.py
--- a/conans/test/utils/tools.py
+++ b/conans/test/utils/tools.py
@@ -17,7 +17,7 @@
 from conans.client import settings_preprocessor
 from conans.client.client_cache import ClientCache
 from conans.client.command import Command
-from conans.client.conan_api import migrate_and_get_client_cache, Conan
+from conans.client.conan_api import migrate_and_get_client_cache, Conan, get_request_timeout
 from conans.client.conan_command_output import CommandOutputer
 from conans.client.conf import MIN_SERVER_COMPATIBLE_VERSION
 from conans.client.output import ConanOutput
@@ -147,6 +147,7 @@ def _prepare_call(self, url, kwargs):
             kwargs.pop("verify", None)
             kwargs.pop("auth", None)
             kwargs.pop("cert", None)
+            kwargs.pop("timeout", None)
             if "data" in kwargs:
                 if isinstance(kwargs["data"], IterableToFileAdapter):
                     data_accum = b""
@@ -388,22 +389,23 @@ def _init_collaborators(self, user_io=None):
             if isinstance(server, str):  # Just URI
                 real_servers = True
 
-        if real_servers:
-            requester = requests.Session()
-        else:
-            if self.requester_class:
-                requester = self.requester_class(self.servers)
+        with tools.environment_append(self.client_cache.conan_config.env_vars):
+            if real_servers:
+                requester = requests.Session()
             else:
-                requester = TestRequester(self.servers)
-
-        self.requester = ConanRequester(requester, self.client_cache)
-
-        self.localdb, self.rest_api_client, self.remote_manager = Conan.instance_remote_manager(
-                                                        self.requester, self.client_cache,
-                                                        self.user_io, self.client_version,
-                                                        self.min_server_compatible_version)
-
-        set_global_instances(output, self.requester)
+                if self.requester_class:
+                    requester = self.requester_class(self.servers)
+                else:
+                    requester = TestRequester(self.servers)
+
+            self.requester = ConanRequester(requester, self.client_cache,
+                                            get_request_timeout())
+
+            self.localdb, self.rest_api_client, self.remote_manager = Conan.instance_remote_manager(
+                                                            self.requester, self.client_cache,
+                                                            self.user_io, self.client_version,
+                                                            self.min_server_compatible_version)
+            set_global_instances(output, self.requester)
 
     def init_dynamic_vars(self, user_io=None):
         # Migration system