Skip to content

Commit 46df2e8

Browse files
authored
ref(clients): Change defaults for timeouts (#246)
Our read timeouts have been causing issues on the server side since they cause the connection to drop, which can leave operations in a dangling state. This has to be addressed separately, but also uncovered that a default read timeout is likely not desired by use cases. This changes timeout functionality in both clients equally: 1. There is a default connect timeout of 100ms 2. There is no default read timeout anymore 3. The `timeout` / `timeout_ms` setting only affects read timeouts 4. To set connection timeouts, one has to use the lower-level client config. This is a BREAKING CHANGE
1 parent 20b1071 commit 46df2e8

File tree

7 files changed

+109
-34
lines changed

7 files changed

+109
-34
lines changed

.vscode/settings.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,5 +19,8 @@
1919
"[markdown]": {
2020
"editor.rulers": [80],
2121
"editor.tabSize": 2,
22+
},
23+
"[python]": {
24+
"editor.rulers": [88],
2225
}
2326
}

clients/python/docs/objectstore_client.rst

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,14 @@ objectstore\_client package
99
Submodules
1010
----------
1111

12+
objectstore\_client.auth module
13+
-------------------------------
14+
15+
.. automodule:: objectstore_client.auth
16+
:members:
17+
:show-inheritance:
18+
:undoc-members:
19+
1220
objectstore\_client.client module
1321
---------------------------------
1422

@@ -32,3 +40,19 @@ objectstore\_client.metrics module
3240
:members:
3341
:show-inheritance:
3442
:undoc-members:
43+
44+
objectstore\_client.scope module
45+
--------------------------------
46+
47+
.. automodule:: objectstore_client.scope
48+
:members:
49+
:show-inheritance:
50+
:undoc-members:
51+
52+
objectstore\_client.utils module
53+
--------------------------------
54+
55+
.. automodule:: objectstore_client.utils
56+
:members:
57+
:show-inheritance:
58+
:undoc-members:

clients/python/pyproject.toml

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,7 @@ name = "objectstore-client"
33
version = "0.0.14"
44
description = "Client SDK for Objectstore, the Sentry object storage platform"
55
readme = "README.md"
6-
authors = [
7-
{name = "Sentry", email = "[email protected]"},
8-
]
9-
homepage = "https://getsentry.github.io/objectstore/"
10-
repository = "https://github.com/getsentry/objectstore"
6+
authors = [{ name = "Sentry", email = "[email protected]" }]
117
license = { file = "LICENSE.md" }
128
requires-python = ">=3.11"
139
dependencies = [

clients/python/src/objectstore_client/client.py

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -67,23 +67,61 @@ def __init__(
6767
self._expiration_policy = expiration_policy
6868

6969

70+
# Connect timeout used unless overridden in connection parameters.
71+
DEFAULT_CONNECT_TIMEOUT = 0.1
72+
73+
7074
@dataclass
7175
class _ConnectionDefaults:
7276
retries: urllib3.Retry = urllib3.Retry(connect=3, read=0)
7377
"""We only retry connection problems, as we cannot rewind our compression stream."""
7478

75-
timeout: urllib3.Timeout = urllib3.Timeout(connect=0.5, read=0.5)
79+
timeout: urllib3.Timeout = urllib3.Timeout(
80+
connect=DEFAULT_CONNECT_TIMEOUT, read=None
81+
)
7682
"""
77-
The read timeout is defined to be "between consecutive read operations",
78-
which should mean one chunk of the response, with a large response being
79-
split into multiple chunks.
80-
We define both as 500ms which is still very conservative,
81-
given that we are in the same network, and expect our backends to respond in <100ms.
83+
The read timeout is defined to be "between consecutive read operations", which
84+
should mean one chunk of the response, with a large response being split into
85+
multiple chunks.
86+
87+
By default, the client limits the connection phase to 100ms, and has no read
88+
timeout.
8289
"""
8390

8491

8592
class Client:
86-
"""A client for Objectstore. Constructing it initializes a connection pool."""
93+
"""
94+
A client for Objectstore. Constructing it initializes a connection pool.
95+
96+
Args:
97+
base_url: The base URL of the Objectstore server (e.g.,
98+
"http://objectstore:8888"). metrics_backend: Optional metrics backend for
99+
tracking storage operations. Defaults to ``NoOpMetricsBackend`` if not
100+
provided.
101+
propagate_traces: Whether to propagate Sentry trace headers in requests to
102+
objectstore. Defaults to ``False``.
103+
retries: Number of connection retries for failed requests.
104+
Defaults to ``3`` if not specified. **Note:** only connection failures are
105+
retried, not read failures (as compression streams cannot be rewound).
106+
timeout_ms: Read timeout in milliseconds for API requests. The read timeout
107+
is the maximum time to wait between consecutive read operations on the
108+
socket (i.e., between receiving chunks of data). Defaults to no read timeout
109+
if not specified. The connection timeout is always 100ms. To override the
110+
connection timeout, pass a custom ``urllib3.Timeout`` object via
111+
``connection_kwargs``. For example:
112+
113+
.. code-block:: python
114+
115+
client = Client(
116+
"http://objectstore:8888", connection_kwargs={
117+
"timeout": urllib3.Timeout(connect=1.0, read=5.0)
118+
}
119+
)
120+
121+
connection_kwargs: Additional keyword arguments to pass to the underlying
122+
urllib3 connection pool (e.g., custom headers, SSL settings, advanced
123+
timeouts).
124+
"""
87125

88126
def __init__(
89127
self,
@@ -107,7 +145,7 @@ def __init__(
107145

108146
if timeout_ms:
109147
connection_kwargs_to_use["timeout"] = urllib3.Timeout(
110-
connect=timeout_ms / 1000, read=timeout_ms / 1000
148+
connect=DEFAULT_CONNECT_TIMEOUT, read=timeout_ms / 1000
111149
)
112150

113151
if connection_kwargs:

clients/python/tests/test_e2e.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -231,14 +231,36 @@ def test_fails_with_insufficient_auth_perms(server_url: str) -> None:
231231
_object_key = session.put(b"test data")
232232

233233

234-
def test_connect_timeout() -> None:
234+
def test_read_timeout() -> None:
235235
# this server accepts the connection
236236
# (even though the backlog is 0 and we never call `accept`),
237237
# but will never reply with anything, thus causing a read timeout
238238
s = socket.create_server(("127.0.0.1", 0), backlog=0)
239239
addr = s.getsockname()
240240
url = f"http://127.0.0.1:{addr[1]}"
241241

242+
client = Client(url, timeout_ms=500, token_generator=TestTokenGenerator.get())
243+
test_usecase = Usecase(
244+
"test-usecase",
245+
compression="zstd",
246+
expiration_policy=TimeToLive(timedelta(days=1)),
247+
)
248+
249+
session = client.session(
250+
test_usecase, org=12345, project=1337, app_slug="email_app"
251+
)
252+
253+
with pytest.raises(urllib3.exceptions.MaxRetryError):
254+
session.get("foo")
255+
256+
257+
def test_connect_timeout() -> None:
258+
# Connect to a blackhole address that should not reply to SYN packets,
259+
# causing a connect timeout with the client's default connect timeout.
260+
# 10.255.255.1 is commonly unroutable in most environments.
261+
url = "http://10.255.255.1:9"
262+
263+
# Do NOT set timeout_ms to ensure we exercise default timeouts
242264
client = Client(url, token_generator=TestTokenGenerator.get())
243265
test_usecase = Usecase(
244266
"test-usecase",

clients/rust/src/client.rs

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -56,13 +56,10 @@ impl ClientBuilder {
5656
}
5757

5858
let reqwest_builder = reqwest::Client::builder()
59-
// The read timeout "applies to each read operation", so should work fine for larger
60-
// transfers that are split into multiple chunks.
61-
// We define both as 500ms which is still very conservative, given that we are in the same network,
62-
// and expect our backends to respond in <100ms.
63-
// This can be overridden by the caller.
64-
.connect_timeout(Duration::from_millis(500))
65-
.read_timeout(Duration::from_millis(500))
59+
// We define just a connection timeout by default but do not limit reads. A connect
60+
// timeout of 100ms is still very conservative, but should provide a sensible upper
61+
// bound to expected request latencies.
62+
.connect_timeout(Duration::from_millis(100))
6663
.user_agent(USER_AGENT);
6764

6865
Self(Ok(ClientBuilderInner {
@@ -84,16 +81,16 @@ impl ClientBuilder {
8481
self
8582
}
8683

87-
/// Sets both the connect and the read timeout for the [`reqwest::Client`].
88-
/// For more fine-grained configuration, use [`Self::configure_reqwest`].
84+
/// Defines a read timeout for the [`reqwest::Client`].
8985
///
90-
/// By default, a connect and read timeout of 500ms is set.
86+
/// The read timeout is defined to be "between consecutive read operations", for example between
87+
/// chunks of a streaming response. For more fine-grained configuration of this and other
88+
/// timeouts, use [`Self::configure_reqwest`].
89+
///
90+
/// By default, no read timeout and a connect timeout of 100ms is set.
9191
pub fn timeout(self, timeout: Duration) -> Self {
9292
let Ok(mut inner) = self.0 else { return self };
93-
inner.reqwest_builder = inner
94-
.reqwest_builder
95-
.connect_timeout(timeout)
96-
.read_timeout(timeout);
93+
inner.reqwest_builder = inner.reqwest_builder.read_timeout(timeout);
9794
Self(Ok(inner))
9895
}
9996

uv.lock

Lines changed: 1 addition & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)