Skip to content

Commit 2e38b81

Browse files
Merge pull request #112 from michaelklishin/mk-reachability
Reachability-related (connectivity + authN, authZ) API improvements
2 parents 326406b + ac215c3 commit 2e38b81

File tree

13 files changed

+489
-3
lines changed

13 files changed

+489
-3
lines changed

CHANGELOG.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,16 @@
22

33
## v0.80.0 (in development)
44

5-
(no changes yet)
5+
### Enhancements
6+
7+
* New `HttpClientError` helpers for "transport-level" connection error classification: `is_connection_error`, `is_timeout`,
8+
`is_tls_handshake_error`, `as_reqwest_error`
9+
* `Client#probe_reachability` tests whether the node is reachable and the configured credentials are accepted.
10+
Returns a `ReachabilityProbeOutcome` enum rather than a `Result` because both outcomes are expected,
11+
so using the `?` operator would be semantically wrong
12+
* Improved `TagList` ergonomics with `can_access_http_api`, `is_administrator`, `can_access_monitoring_endpoints`
13+
* `TagList` now implements `From<Vec<String>>`
14+
* `ClientBuilder#with_connect_timeout` sets the TCP connection timeout independently of the overall request timeout
615

716

817
## v0.79.0 (Feb 11, 2026)

src/api/client.rs

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ pub struct ClientBuilder<E = &'static str, U = &'static str, P = &'static str> {
5858
password: P,
5959
client: Option<HttpClient>,
6060
retry_settings: RetrySettings,
61+
connect_timeout: Option<Duration>,
6162
request_timeout: Option<Duration>,
6263
}
6364

@@ -82,6 +83,7 @@ impl ClientBuilder {
8283
password: "guest",
8384
client: None,
8485
retry_settings: RetrySettings::default(),
86+
connect_timeout: None,
8587
request_timeout: None,
8688
}
8789
}
@@ -122,6 +124,7 @@ where
122124
password,
123125
client: self.client,
124126
retry_settings: self.retry_settings,
127+
connect_timeout: self.connect_timeout,
125128
request_timeout: self.request_timeout,
126129
}
127130
}
@@ -140,6 +143,7 @@ where
140143
password: self.password,
141144
client: self.client,
142145
retry_settings: self.retry_settings,
146+
connect_timeout: self.connect_timeout,
143147
request_timeout: self.request_timeout,
144148
}
145149
}
@@ -148,7 +152,7 @@ where
148152
///
149153
/// Use a custom HTTP client to configure custom timeouts, proxy settings, TLS configuration.
150154
///
151-
/// Note: If you provide a custom client, the timeout set via [`with_request_timeout`]
155+
/// Note: If you provide a custom client, timeouts set via builder methods
152156
/// will be ignored. Configure timeouts directly on your custom client instead.
153157
pub fn with_client(self, client: HttpClient) -> Self {
154158
ClientBuilder {
@@ -157,6 +161,21 @@ where
157161
}
158162
}
159163

164+
/// Sets the TCP connection timeout.
165+
///
166+
/// This timeout applies only to the connection establishment phase (TCP + TLS handshake),
167+
/// not to the overall request. Useful for failing fast on unreachable hosts without
168+
/// cutting off slow-but-valid responses.
169+
///
170+
/// **Important**: this setting is ignored if a custom HTTP client is used via [`with_client`].
171+
/// In that case, configure the timeout on the custom client instead.
172+
pub fn with_connect_timeout(self, timeout: Duration) -> Self {
173+
ClientBuilder {
174+
connect_timeout: Some(timeout),
175+
..self
176+
}
177+
}
178+
160179
/// Sets the request timeout for HTTP operations.
161180
///
162181
/// This timeout applies to the entire request/response cycle, including connection establishment,
@@ -202,6 +221,9 @@ where
202221
Some(c) => c,
203222
None => {
204223
let mut builder = HttpClient::builder();
224+
if let Some(timeout) = self.connect_timeout {
225+
builder = builder.connect_timeout(timeout);
226+
}
205227
if let Some(timeout) = self.request_timeout {
206228
builder = builder.timeout(timeout);
207229
}

src/api/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ mod parameters;
3535
mod permissions;
3636
mod policies;
3737
mod queues_and_streams;
38+
mod reachability;
3839
mod rebalancing;
3940
mod shovels;
4041
mod tanzu;

src/api/reachability.rs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// Copyright (C) 2023-2025 RabbitMQ Core Team (teamrabbitmq@gmail.com)
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
use std::fmt::Display;
16+
use std::time::Instant;
17+
18+
use crate::responses::{ReachabilityProbeDetails, ReachabilityProbeOutcome};
19+
20+
use super::client::Client;
21+
22+
impl<E, U, P> Client<E, U, P>
23+
where
24+
E: Display,
25+
U: Display,
26+
P: Display,
27+
{
28+
/// Tests whether the node is reachable and the configured credentials are accepted.
29+
///
30+
/// Calls `GET /api/whoami`. Does not check node health, cluster identity,
31+
/// or resource alarms — the caller can compose those after a `Reached` result.
32+
pub async fn probe_reachability(&self) -> ReachabilityProbeOutcome {
33+
let start = Instant::now();
34+
35+
match self.current_user().await {
36+
Ok(current_user) => ReachabilityProbeOutcome::Reached(ReachabilityProbeDetails {
37+
current_user,
38+
duration: start.elapsed(),
39+
}),
40+
Err(e) => ReachabilityProbeOutcome::Unreachable(Box::new(e)),
41+
}
42+
}
43+
}

src/blocking_api/client.rs

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ pub struct ClientBuilder<E = &'static str, U = &'static str, P = &'static str> {
5757
password: P,
5858
client: Option<HttpClient>,
5959
retry_settings: RetrySettings,
60+
connect_timeout: Option<Duration>,
6061
request_timeout: Option<Duration>,
6162
}
6263

@@ -81,6 +82,7 @@ impl ClientBuilder {
8182
password: "guest",
8283
client: None,
8384
retry_settings: RetrySettings::default(),
85+
connect_timeout: None,
8486
request_timeout: None,
8587
}
8688
}
@@ -132,6 +134,7 @@ where
132134
password,
133135
client: self.client,
134136
retry_settings: self.retry_settings,
137+
connect_timeout: self.connect_timeout,
135138
request_timeout: self.request_timeout,
136139
}
137140
}
@@ -150,6 +153,7 @@ where
150153
password: self.password,
151154
client: self.client,
152155
retry_settings: self.retry_settings,
156+
connect_timeout: self.connect_timeout,
153157
request_timeout: self.request_timeout,
154158
}
155159
}
@@ -158,7 +162,7 @@ where
158162
///
159163
/// Use a custom HTTP client to configure custom timeouts, proxy settings, TLS configuration.
160164
///
161-
/// Note: If you provide a custom client, the timeout set via [`with_request_timeout`]
165+
/// Note: If you provide a custom client, timeouts set via builder methods
162166
/// will be ignored. Configure timeouts directly on your custom client instead.
163167
pub fn with_client(self, client: HttpClient) -> Self {
164168
ClientBuilder {
@@ -167,6 +171,21 @@ where
167171
}
168172
}
169173

174+
/// Sets the TCP connection timeout.
175+
///
176+
/// This timeout applies only to the connection establishment phase (TCP + TLS handshake),
177+
/// not to the overall request. Useful for failing fast on unreachable hosts without
178+
/// cutting off slow-but-valid responses.
179+
///
180+
/// **Important**: this setting is ignored if a custom HTTP client is used via [`with_client`].
181+
/// In that case, configure the timeout on the custom client instead.
182+
pub fn with_connect_timeout(self, timeout: Duration) -> Self {
183+
ClientBuilder {
184+
connect_timeout: Some(timeout),
185+
..self
186+
}
187+
}
188+
170189
/// Sets the request timeout for HTTP operations.
171190
///
172191
/// This timeout applies to the entire request/response cycle, including connection establishment,
@@ -212,6 +231,9 @@ where
212231
Some(c) => c,
213232
None => {
214233
let mut builder = HttpClient::builder();
234+
if let Some(timeout) = self.connect_timeout {
235+
builder = builder.connect_timeout(timeout);
236+
}
215237
if let Some(timeout) = self.request_timeout {
216238
builder = builder.timeout(timeout);
217239
}

src/blocking_api/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ mod parameters;
3535
mod permissions;
3636
mod policies;
3737
mod queues_and_streams;
38+
mod reachability;
3839
mod rebalancing;
3940
mod shovels;
4041
mod tanzu;

src/blocking_api/reachability.rs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// Copyright (C) 2023-2025 RabbitMQ Core Team (teamrabbitmq@gmail.com)
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
use std::fmt::Display;
16+
use std::time::Instant;
17+
18+
use crate::responses::{ReachabilityProbeDetails, ReachabilityProbeOutcome};
19+
20+
use super::client::Client;
21+
22+
impl<E, U, P> Client<E, U, P>
23+
where
24+
E: Display,
25+
U: Display,
26+
P: Display,
27+
{
28+
/// Tests whether the node is reachable and the configured credentials are accepted.
29+
///
30+
/// Calls `GET /api/whoami`. Does not check node health, cluster identity,
31+
/// or resource alarms — the caller can compose those after a `Reached` result.
32+
pub fn probe_reachability(&self) -> ReachabilityProbeOutcome {
33+
let start = Instant::now();
34+
35+
match self.current_user() {
36+
Ok(current_user) => ReachabilityProbeOutcome::Reached(ReachabilityProbeDetails {
37+
current_user,
38+
duration: start.elapsed(),
39+
}),
40+
Err(e) => ReachabilityProbeOutcome::Unreachable(Box::new(e)),
41+
}
42+
}
43+
}

src/error.rs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,58 @@ impl HttpClientError {
251251
}
252252
}
253253

254+
/// Returns true if the error is a connection failure
255+
/// that is NOT a TLS handshake error (a hostname resolution failure, TCP connection refused, etc).
256+
pub fn is_connection_error(&self) -> bool {
257+
matches!(
258+
self,
259+
HttpClientError::RequestError { error, .. }
260+
if error.is_connect() && !self.is_tls_handshake_error()
261+
)
262+
}
263+
264+
/// Returns true if the error is a request timeout.
265+
pub fn is_timeout(&self) -> bool {
266+
matches!(
267+
self,
268+
HttpClientError::RequestError { error, .. }
269+
if error.is_timeout()
270+
)
271+
}
272+
273+
/// Returns true if the error is likely a TLS/SSL handshake failure.
274+
/// Uses a heuristic on the error chain since reqwest doesn't expose
275+
/// a first-class TLS error type.
276+
///
277+
/// The patterns below are tested against rustls debug output.
278+
/// native-tls may produce different casing; most cases are still
279+
/// caught because the error chain typically includes at least one
280+
/// of the lowercase or variant-name matches.
281+
pub fn is_tls_handshake_error(&self) -> bool {
282+
match self {
283+
HttpClientError::RequestError { error, .. } if error.is_connect() => {
284+
let debug = format!("{error:?}");
285+
debug.contains("certificate")
286+
|| debug.contains("CertificateRequired")
287+
|| debug.contains("HandshakeFailure")
288+
|| debug.contains("InvalidCertificate")
289+
|| debug.contains("tls")
290+
|| debug.contains("TLS")
291+
|| debug.contains("ssl")
292+
|| debug.contains("SSL")
293+
}
294+
_ => false,
295+
}
296+
}
297+
298+
/// Returns the underlying `reqwest::Error` for transport-level errors.
299+
pub fn as_reqwest_error(&self) -> Option<&reqwest::Error> {
300+
match self {
301+
HttpClientError::RequestError { error, .. } => Some(error),
302+
_ => None,
303+
}
304+
}
305+
254306
/// Returns a user-friendly error message, preferring API-provided details when available.
255307
pub fn user_message(&self) -> String {
256308
match self {

src/responses.rs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,11 @@ pub use consumers::Consumer;
128128
pub mod users;
129129
pub use users::{CurrentUser, User, UserLimits};
130130

131+
#[cfg(any(feature = "async", feature = "blocking"))]
132+
pub mod reachability;
133+
#[cfg(any(feature = "async", feature = "blocking"))]
134+
pub use reachability::{ReachabilityProbeDetails, ReachabilityProbeOutcome};
135+
131136
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)]
132137
pub struct TagList(pub Vec<String>);
133138

@@ -151,6 +156,38 @@ impl TagList {
151156
pub fn iter_mut(&mut self) -> std::slice::IterMut<'_, String> {
152157
self.0.iter_mut()
153158
}
159+
160+
/// Returns true if the user can access the HTTP API.
161+
///
162+
/// RabbitMQ grants HTTP API access to users with any of
163+
/// these tags: `management`, `monitoring`, `policymaker`, or `administrator`.
164+
pub fn can_access_http_api(&self) -> bool {
165+
self.0.iter().any(|t| {
166+
matches!(
167+
t.as_str(),
168+
"management" | "monitoring" | "policymaker" | "administrator"
169+
)
170+
})
171+
}
172+
173+
/// Returns true if the user has the `administrator` tag.
174+
pub fn is_administrator(&self) -> bool {
175+
self.contains("administrator")
176+
}
177+
178+
/// Returns true if the user has monitoring-level access
179+
/// (`monitoring` or `administrator`).
180+
pub fn can_access_monitoring_endpoints(&self) -> bool {
181+
self.0
182+
.iter()
183+
.any(|t| matches!(t.as_str(), "monitoring" | "administrator"))
184+
}
185+
}
186+
187+
impl From<Vec<String>> for TagList {
188+
fn from(v: Vec<String>) -> Self {
189+
TagList(v)
190+
}
154191
}
155192

156193
impl Deref for TagList {

0 commit comments

Comments
 (0)