Skip to content

Commit c5e0d87

Browse files
authored
Merge pull request #349 from rstudio/346-add-timeout-configuration
Adds CONNECT_REQUEST_TIMEOUT env variable
2 parents 8574160 + ce7d898 commit c5e0d87

File tree

7 files changed

+107
-28
lines changed

7 files changed

+107
-28
lines changed

CHANGELOG.md

+7-4
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file.
44
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
55
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

7+
## Unreleased
8+
9+
### Added
10+
- The `CONNECT_REQUEST_TIMEOUT` environment variable, which configures the request timeout for all blocking HTTP and HTTPS operations. This value translates into seconds (e.g., `CONNECT_REQUEST_TIMEOUT=60` is equivalent to 60 seconds.) By default, this value is 300.
11+
712
## [1.15.0] - 2023-03-15
813

914
### Added
@@ -13,10 +18,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1318
### Changed
1419
- `deploy html` was refactored. Its behavior is described below.
1520

16-
### deploy html
17-
- specifying a directory in the path will result in that entire directory*, subdirectories, and sub contents included in the deploy bundle
18-
- the entire directory is included whether or not an entrypoint was supplied
19-
21+
#### Deploying HTML
22+
Specifying a directory in the path will result in that entire directory*, subdirectories, and sub contents included in the deploy bundle. The entire directory is included whether or not an entrypoint was supplied
2023

2124

2225
e.g.

rsconnect/actions_content.py

+4-4
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ def build_add_content(connect_server, content_guids_with_bundle):
3535
+ "please wait for it to finish before adding new content."
3636
)
3737

38-
with RSConnectClient(connect_server, timeout=120) as client:
38+
with RSConnectClient(connect_server) as client:
3939
if len(content_guids_with_bundle) == 1:
4040
all_content = [client.content_get(content_guids_with_bundle[0].guid)]
4141
else:
@@ -290,7 +290,7 @@ def download_bundle(connect_server, guid_with_bundle):
290290
"""
291291
:param guid_with_bundle: models.ContentGuidWithBundle
292292
"""
293-
with RSConnectClient(connect_server, timeout=120) as client:
293+
with RSConnectClient(connect_server) as client:
294294
# bundle_id not provided so grab the latest
295295
if not guid_with_bundle.bundle_id:
296296
content = client.get_content(guid_with_bundle.guid)
@@ -309,7 +309,7 @@ def get_content(connect_server, guid):
309309
:param guid: a single guid as a string or list of guids.
310310
:return: a list of content items.
311311
"""
312-
with RSConnectClient(connect_server, timeout=120) as client:
312+
with RSConnectClient(connect_server) as client:
313313
if isinstance(guid, str):
314314
result = [client.get_content(guid)]
315315
else:
@@ -320,7 +320,7 @@ def get_content(connect_server, guid):
320320
def search_content(
321321
connect_server, published, unpublished, content_type, r_version, py_version, title_contains, order_by
322322
):
323-
with RSConnectClient(connect_server, timeout=120) as client:
323+
with RSConnectClient(connect_server) as client:
324324
result = client.search_content()
325325
result = _apply_content_filters(
326326
result, published, unpublished, content_type, r_version, py_version, title_contains

rsconnect/api.py

+12-12
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
from .metadata import ServerStore, AppStore
2929
from .exception import RSConnectException
3030
from .bundle import _default_title, fake_module_file_from_directory
31+
from .timeouts import get_timeout
3132

3233

3334
class AbstractRemoteServer:
@@ -127,15 +128,14 @@ def __init__(self, url: str):
127128

128129

129130
class RSConnectClient(HTTPServer):
130-
def __init__(self, server: RSConnectServer, cookies=None, timeout=30):
131+
def __init__(self, server: RSConnectServer, cookies=None):
131132
if cookies is None:
132133
cookies = server.cookie_jar
133134
super().__init__(
134135
append_to_path(server.url, "__api__"),
135136
server.insecure,
136137
server.ca_data,
137138
cookies,
138-
timeout,
139139
)
140140
self._server = server
141141

@@ -282,7 +282,7 @@ def get_content(self, content_guid):
282282
return results
283283

284284
def wait_for_task(
285-
self, task_id, log_callback, abort_func=lambda: False, timeout=None, poll_wait=0.5, raise_on_error=True
285+
self, task_id, log_callback, abort_func=lambda: False, timeout=get_timeout(), poll_wait=0.5, raise_on_error=True
286286
):
287287

288288
last_status = None
@@ -387,7 +387,7 @@ def __init__(
387387
token=token,
388388
secret=secret,
389389
)
390-
self.setup_client(cookies, timeout)
390+
self.setup_client(cookies)
391391

392392
@classmethod
393393
def fromConnectServer(cls, connect_server, **kwargs):
@@ -485,11 +485,11 @@ def setup_remote_server(
485485
else:
486486
raise RSConnectException("Unable to infer Connect server type and setup server.")
487487

488-
def setup_client(self, cookies=None, timeout=30, **kwargs):
488+
def setup_client(self, cookies=None, **kwargs):
489489
if isinstance(self.remote_server, RSConnectServer):
490-
self.client = RSConnectClient(self.remote_server, cookies, timeout)
490+
self.client = RSConnectClient(self.remote_server, cookies)
491491
elif isinstance(self.remote_server, PositServer):
492-
self.client = PositClient(self.remote_server, timeout)
492+
self.client = PositClient(self.remote_server)
493493
else:
494494
raise RSConnectException("Unable to infer Connect client.")
495495

@@ -678,7 +678,7 @@ def check_server_capabilities(self, capability_functions):
678678
def upload_rstudio_bundle(self, prepare_deploy_result, bundle_size: int, contents):
679679
upload_url = prepare_deploy_result.presigned_url
680680
parsed_upload_url = urlparse(upload_url)
681-
with S3Client("{}://{}".format(parsed_upload_url.scheme, parsed_upload_url.netloc), timeout=120) as s3_client:
681+
with S3Client("{}://{}".format(parsed_upload_url.scheme, parsed_upload_url.netloc)) as s3_client:
682682
upload_result = s3_client.upload(
683683
"{}?{}".format(parsed_upload_url.path, parsed_upload_url.query),
684684
prepare_deploy_result.presigned_checksum,
@@ -1028,14 +1028,14 @@ class PositClient(HTTPServer):
10281028

10291029
_TERMINAL_STATUSES = {"success", "failed", "error"}
10301030

1031-
def __init__(self, rstudio_server: PositServer, timeout: int = 30):
1031+
def __init__(self, rstudio_server: PositServer):
10321032
self._token = rstudio_server.token
10331033
try:
10341034
self._key = base64.b64decode(rstudio_server.secret)
10351035
except binascii.Error as e:
10361036
raise RSConnectException("Invalid secret.") from e
10371037
self._server = rstudio_server
1038-
super().__init__(rstudio_server.url, timeout=timeout)
1038+
super().__init__(rstudio_server.url)
10391039

10401040
def _get_canonical_request(self, method, path, timestamp, content_hash):
10411041
return "\n".join([method, path, timestamp, content_hash])
@@ -1121,7 +1121,7 @@ def get_task(self, task_id):
11211121
def get_current_user(self):
11221122
return self.get("/v1/users/me")
11231123

1124-
def wait_until_task_is_successful(self, task_id, timeout=180):
1124+
def wait_until_task_is_successful(self, task_id, timeout=get_timeout()):
11251125
print()
11261126
print("Waiting for task: {}".format(task_id))
11271127
start_time = time.time()
@@ -1372,7 +1372,7 @@ def emit_task_log(
13721372
task_id,
13731373
log_callback,
13741374
abort_func=lambda: False,
1375-
timeout=None,
1375+
timeout=get_timeout(),
13761376
poll_wait=0.5,
13771377
raise_on_error=True,
13781378
):

rsconnect/http_support.py

+9-7
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,13 @@
1313
from six.moves.urllib_parse import urlparse, urlencode, urljoin
1414
import base64
1515

16+
from .timeouts import get_timeout
17+
1618
_user_agent = "rsconnect-python/%s" % VERSION
1719

1820

1921
# noinspection PyUnusedLocal,PyUnresolvedReferences
20-
def _create_plain_connection(host_name, port, disable_tls_check, ca_data, timeout):
22+
def _create_plain_connection(host_name, port, disable_tls_check, ca_data):
2123
"""
2224
This function is used to create a plain HTTP connection. Note that the 3rd and 4th
2325
parameters are ignored; they are present to make the signature match the companion
@@ -27,9 +29,10 @@ def _create_plain_connection(host_name, port, disable_tls_check, ca_data, timeou
2729
:param port: the port to connect to.
2830
:param disable_tls_check: notes whether TLS verification should be disabled (ignored).
2931
:param ca_data: any certificate authority information to use (ignored).
30-
:param timeout: the timeout value to use for socket operations.
3132
:return: a plain HTTP connection.
3233
"""
34+
timeout = get_timeout()
35+
logger.debug(f"The HTTPConnection timeout is set to '{timeout}' seconds")
3336
return http.HTTPConnection(host_name, port=(port or http.HTTP_PORT), timeout=timeout)
3437

3538

@@ -59,7 +62,7 @@ def _get_proxy_headers(*args, **kwargs):
5962

6063

6164
# noinspection PyUnresolvedReferences
62-
def _create_ssl_connection(host_name, port, disable_tls_check, ca_data, timeout):
65+
def _create_ssl_connection(host_name, port, disable_tls_check, ca_data):
6366
"""
6467
This function is used to create a TLS encrypted HTTP connection (SSL).
6568
@@ -74,6 +77,8 @@ def _create_ssl_connection(host_name, port, disable_tls_check, ca_data, timeout)
7477
raise ValueError("Cannot both disable TLS checking and provide a custom certificate")
7578
_, _, proxyHost, proxyPort = _get_proxy()
7679
headers = _get_proxy_headers()
80+
timeout = get_timeout()
81+
logger.debug(f"The HTTPSConnection timeout is set to '{timeout}' seconds")
7782
if ca_data is not None:
7883
return http.HTTPSConnection(
7984
host_name,
@@ -162,7 +167,7 @@ class HTTPServer(object):
162167
server.
163168
"""
164169

165-
def __init__(self, url, disable_tls_check=False, ca_data=None, cookies=None, timeout=30):
170+
def __init__(self, url, disable_tls_check=False, ca_data=None, cookies=None):
166171
"""
167172
Constructs an HTTPServer object.
168173
@@ -174,7 +179,6 @@ def __init__(self, url, disable_tls_check=False, ca_data=None, cookies=None, tim
174179
certificates.
175180
:param cookies: an optional cookie jar. Must be of type `CookieJar` defined in this
176181
same file (i.e., not the one Python provides).
177-
:param timeout: the timeout value to use for socket operations.
178182
"""
179183
self._url = urlparse(url)
180184

@@ -184,7 +188,6 @@ def __init__(self, url, disable_tls_check=False, ca_data=None, cookies=None, tim
184188
self._disable_tls_check = disable_tls_check
185189
self._ca_data = ca_data
186190
self._cookies = cookies if cookies is not None else CookieJar()
187-
self._timeout = timeout
188191
self._headers = {"User-Agent": _user_agent}
189192
self._conn = None
190193
self._proxy_headers = _get_proxy_headers()
@@ -216,7 +219,6 @@ def __enter__(self):
216219
self._url.port,
217220
self._disable_tls_check,
218221
self._ca_data,
219-
self._timeout,
220222
)
221223
return self
222224

rsconnect/timeouts.py

+41
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import os
2+
from typing import Union
3+
4+
from rsconnect.exception import RSConnectException
5+
6+
_CONNECT_REQUEST_TIMEOUT_KEY = "CONNECT_REQUEST_TIMEOUT"
7+
_CONNECT_REQUEST_TIMEOUT_DEFAULT_VALUE = "300"
8+
9+
10+
def get_timeout() -> int:
11+
"""Gets the timeout from the CONNECT_REQUEST_TIMEOUT env variable.
12+
13+
The timeout value is intended to be interpreted in seconds. A value of 60 is equal to sixty seconds, or one minute.
14+
15+
If CONNECT_REQUEST_TIMEOUT is unset, a default value of 300 is used.
16+
17+
If CONNECT_REQUEST_TIMEOUT is set to a value less than 0, an `RSConnectException` is raised.
18+
19+
A CONNECT_REQUEST_TIMEOUT set to 0 is logically equivalent to no timeout.
20+
21+
The primary intent for this method is for usage with the `http` module. Specifically, for setting the timeout
22+
parameter with an `http.client.HTTPConnection` or `http.client.HTTPSConnection`.
23+
24+
:raises: `RSConnectException` if CONNECT_REQUEST_TIMEOUT is not a natural number.
25+
:return: the timeout value
26+
"""
27+
timeout: Union[int, str] = os.environ.get(_CONNECT_REQUEST_TIMEOUT_KEY, _CONNECT_REQUEST_TIMEOUT_DEFAULT_VALUE)
28+
29+
try:
30+
timeout = int(timeout)
31+
except ValueError:
32+
raise RSConnectException(
33+
f"'CONNECT_REQUEST_TIMEOUT' is set to '{timeout}'. The value must be a natural number."
34+
)
35+
36+
if timeout < 0:
37+
raise RSConnectException(
38+
f"'CONNECT_REQUEST_TIMEOUT' is set to '{timeout}'. The value must be a natural number."
39+
)
40+
41+
return timeout

tests/test_http_support.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ def test_connection_factory_map(self):
1919

2020
def test_create_ssl_checks(self):
2121
with self.assertRaises(ValueError):
22-
_create_ssl_connection(None, None, True, "fake", 10)
22+
_create_ssl_connection(None, None, True, "fake")
2323

2424
def test_append_to_path(self):
2525
self.assertEqual(append_to_path("path/", "/sub"), "path/sub")

tests/test_timeouts.py

+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import os
2+
3+
from unittest import TestCase
4+
from unittest.mock import patch
5+
6+
from rsconnect.exception import RSConnectException
7+
from rsconnect.timeouts import get_timeout
8+
9+
10+
class GetTimeoutTestCase(TestCase):
11+
def test_get_default_timeout(self):
12+
timeout = get_timeout()
13+
self.assertEqual(300, timeout)
14+
15+
def test_get_valid_timeout_from_environment(self):
16+
with patch.dict(os.environ, {"CONNECT_REQUEST_TIMEOUT": "24"}):
17+
timeout = get_timeout()
18+
self.assertEqual(24, timeout)
19+
20+
def test_get_zero_timeout_from_environment(self):
21+
with patch.dict(os.environ, {"CONNECT_REQUEST_TIMEOUT": "0"}):
22+
timeout = get_timeout()
23+
self.assertEqual(0, timeout)
24+
25+
def test_get_invalid_timeout_from_environment(self):
26+
with patch.dict(os.environ, {"CONNECT_REQUEST_TIMEOUT": "foobar"}):
27+
with self.assertRaises(RSConnectException):
28+
get_timeout()
29+
30+
def test_get_negative_timeout_from_environment(self):
31+
with patch.dict(os.environ, {"CONNECT_REQUEST_TIMEOUT": "-24"}):
32+
with self.assertRaises(RSConnectException):
33+
get_timeout()

0 commit comments

Comments
 (0)