Repository: dwavesystems/dwave-cloud-client

Issue:
Split request_timeout config option to read and connect timeout
Needed for #439, but also to allow for finer granularity of timeouts.

We could introduce two new options and/or support a tuple for `request_timeout` (similar to the requests lib).

See also #357.

Discussion:

Patch:
diff --git a/dwave/cloud/config/models.py b/dwave/cloud/config/models.py
index d11db89..a646772 100644
--- a/dwave/cloud/config/models.py
+++ b/dwave/cloud/config/models.py
@@ -12,14 +12,16 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+import ast
 import copy
 import json
 import logging
 from collections import abc
 from typing import Optional, Union, Literal, Tuple, Dict, Any
+from typing_extensions import Annotated     # backport for py37, py38
 
 import urllib3
-from pydantic import BaseModel
+from pydantic import BaseModel, BeforeValidator
 
 from dwave.cloud.config.loaders import update_config
 from dwave.cloud.api.constants import (
@@ -99,6 +101,12 @@ class PollingSchedule(BaseModel, GetterMixin):
     backoff_base: Optional[float] = 1.3
 
 
+def _literal_eval(obj):
+    if isinstance(obj, str):
+        return ast.literal_eval(obj)
+    return obj
+
+
 class ClientConfig(BaseModel, GetterMixin):
     # api region
     metadata_api_endpoint: Optional[str] = DEFAULT_METADATA_API_ENDPOINT
@@ -128,7 +136,8 @@ class ClientConfig(BaseModel, GetterMixin):
 
     # api request retry params
     request_retry: Optional[RequestRetryConfig] = RequestRetryConfig()
-    request_timeout: Optional[Union[float, Tuple[float, float]]] = (60.0, 120.0)
+    request_timeout: Annotated[Optional[Union[float, Tuple[float, float]]],
+                               BeforeValidator(_literal_eval)] = (60.0, 120.0)
 
 
 def validate_config_v1(raw_config: dict) -> ClientConfig:
@@ -244,11 +253,6 @@ def dump_config_v1(config: ClientConfig) -> dict:
 
     raw_config['solver'] = json.dumps(raw_config['solver'])
 
-    # TODO: undo this downcast once we support tuple input for `request_timeout` in the v1
-    # config format (issue https://github.com/dwavesystems/dwave-cloud-client/issues/440).
-    if isinstance(rt := raw_config['request_timeout'], tuple):
-        raw_config['request_timeout'] = rt[0]
-
     # expand/translate polling
     raw_config.update({f"poll_{k}": v for k, v in raw_config['polling_schedule'].items()})
     del raw_config['polling_schedule']
diff --git a/releasenotes/notes/refine-granularity-of-request-timeout-97f740eefabe6398.yaml b/releasenotes/notes/refine-granularity-of-request-timeout-97f740eefabe6398.yaml
new file mode 100644
index 0000000..ea01fb9
--- /dev/null
+++ b/releasenotes/notes/refine-granularity-of-request-timeout-97f740eefabe6398.yaml
@@ -0,0 +1,8 @@
+---
+features:
+  - |
+    Add support for specifying connection read timeout separately from connect
+    timeout. Previously, both read and connect timeouts were set to the same
+    value, specified with the ``request_timeout`` configuration parameter. We
+    now accept a ``(read, connect)`` tuple for the ``request_timeout`` parameter.
+    See `#440 <https://github.com/dwavesystems/dwave-cloud-client/issues/440>`_.


Test patch:
diff --git a/tests/test_client.py b/tests/test_client.py
index 0fbb19b..30d373f 100644
--- a/tests/test_client.py
+++ b/tests/test_client.py
@@ -647,7 +647,7 @@ class ClientConfigIntegration(unittest.TestCase):
         """Test custom options (request_timeout, polling_timeout, permissive_ssl)
         are propagated to Client."""
 
-        request_timeout = 15
+        request_timeout = (15, 30)
         polling_timeout = 180
 
         config_body = """
diff --git a/tests/test_config.py b/tests/test_config.py
index c80c5b9..b09dd41 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -652,6 +652,23 @@ class TestConfigModel(unittest.TestCase):
                              get_field=lambda config: getattr(config, flag),
                              model_value=model_value)
 
+    @parameterized.expand([
+        ("null", None, None),
+        ("omitted", OMITTED, (60.0, 120.0)),    # default
+        ("int", 10, 10.0),
+        ("float", 10.0, 10.0),
+        ("tuple[int]", (10, 20), (10.0, 20.0)),
+        ("tuple[float]", (10.0, 20.0), (10.0, 20.0)),
+        ("literal scalar", '10', 10.0),
+        ("literal tuple", '(20, 30)', (20.0, 30.0)),
+        ("literal list", '[20, 30]', (20.0, 30.0)),
+    ])
+    def test_request_timeout(self, name, raw_value, model_value):
+        raw_config = {} if raw_value is self.OMITTED else {"request_timeout": raw_value}
+        self._verify(raw_config=raw_config,
+                     get_field=lambda config: config.request_timeout,
+                     model_value=model_value)
+
     def test_model_getter_mixin(self):
         val = "https://some.url/path/"
         config = validate_config_v1({"endpoint": val})