magnetar/client_builder.rs
1// SPDX-License-Identifier: Apache-2.0
2
3//! [`ClientBuilder`] — extracted from `client.rs` so the central
4//! façade module stays focused on the [`crate::PulsarClient`] surface
5//! and the per-surface builder types ([`crate::ProducerBuilder`],
6//! [`crate::ConsumerBuilder`], [`crate::ReaderBuilder`]) that still
7//! live alongside it.
8//!
9//! Re-exported via `pub use client_builder::ClientBuilder` from the
10//! façade `lib.rs` so existing call sites
11//! (`magnetar::ClientBuilder::default()`, `PulsarClient::builder()`)
12//! keep working unchanged.
13
14use std::time::Duration;
15
16use magnetar_runtime_tokio::Client;
17
18use crate::client::{MemoryLimit, MemoryLimitPolicy, PulsarClient, PulsarError};
19
20/// Result alias used inside this module, mirroring the one in
21/// `client.rs`.
22type Result<T, E = PulsarError> = std::result::Result<T, E>;
23
24/// Tri-state override for
25/// [`ConnectionConfig::ack_response_timeout`](magnetar_proto::conn::ConnectionConfig) (issue #346).
26/// A plain `Option<Duration>` can't represent "explicitly disabled" separately from "unset, inherit
27/// the default" because the underlying config field is itself `Option<Duration>` with a non-`None`
28/// default (`Some(30s)`) — unlike `operation_timeout`, whose config-level
29/// type is a bare `Duration`. `clippy::option_option` steers this shape into
30/// a named enum instead of `Option<Option<Duration>>`.
31#[derive(Debug, Clone, Copy)]
32enum AckResponseTimeoutOverride {
33 /// Explicit deadline via [`ClientBuilder::ack_response_timeout`].
34 Explicit(Duration),
35 /// Explicitly disabled via [`ClientBuilder::disable_ack_response_timeout`].
36 Disabled,
37}
38
39/// Builder for [`PulsarClient`].
40#[derive(Debug, Clone)]
41pub struct ClientBuilder {
42 service_url: Option<String>,
43 service_url_provider: Option<std::sync::Arc<dyn magnetar_proto::ServiceUrlProvider>>,
44 client_version: Option<String>,
45 keepalive: Option<Duration>,
46 /// `None` = unset, inherit `ConnectionConfig::default()`'s `stats_interval`.
47 /// `Some(Duration::ZERO)` is the Java `statsIntervalSeconds = 0` disable.
48 stats_interval: Option<Duration>,
49 /// `None` = unset, inherit `ConnectionConfig::default()`'s `None` (watchdog off).
50 /// `Some(Duration::ZERO)` disables it explicitly (issue #414).
51 consumer_stall_timeout: Option<Duration>,
52 /// `None` = unset, inherit `ConnectionConfig::default()`'s `None` (automatic recovery
53 /// off). `Some(0)` disables it explicitly (issue #414, ADR-0103).
54 consumer_stall_auto_recovery: Option<u32>,
55 operation_timeout: Option<Duration>,
56 operation_retry: Option<magnetar_proto::OperationRetryConfig>,
57 /// `None` = unset, inherit `ConnectionConfig::default()`'s `Some(30s)`.
58 ack_response_timeout: Option<AckResponseTimeoutOverride>,
59 auth_method_name: Option<String>,
60 auth_data: Option<bytes::Bytes>,
61 auth_provider: Option<std::sync::Arc<dyn magnetar_proto::AuthProvider>>,
62 tls_trust_certs_pem: Option<Vec<u8>>,
63 tls_allow_insecure_connection: bool,
64 tls_hostname_verification_enable: bool,
65 default_max_message_size: Option<usize>,
66 proxy_to_broker_url: Option<String>,
67 supervisor: Option<magnetar_proto::SupervisorConfig>,
68 memory_limit: Option<MemoryLimit>,
69 dns_resolver: Option<std::sync::Arc<dyn magnetar_runtime_tokio::DnsResolver>>,
70 connections_per_broker: Option<usize>,
71}
72
73impl Default for ClientBuilder {
74 fn default() -> Self {
75 Self {
76 service_url: None,
77 service_url_provider: None,
78 client_version: None,
79 keepalive: None,
80 stats_interval: None,
81 consumer_stall_timeout: None,
82 consumer_stall_auto_recovery: None,
83 operation_timeout: None,
84 operation_retry: None,
85 ack_response_timeout: None,
86 auth_method_name: None,
87 auth_data: None,
88 auth_provider: None,
89 tls_trust_certs_pem: None,
90 tls_allow_insecure_connection: false,
91 tls_hostname_verification_enable: true,
92 default_max_message_size: None,
93 proxy_to_broker_url: None,
94 supervisor: None,
95 memory_limit: None,
96 dns_resolver: None,
97 connections_per_broker: None,
98 }
99 }
100}
101
102impl ClientBuilder {
103 /// Set the Pulsar service URL (`pulsar://` or `pulsar+ssl://`).
104 #[must_use]
105 pub fn service_url(mut self, url: impl Into<String>) -> Self {
106 self.service_url = Some(url.into());
107 self
108 }
109
110 /// Plug in a custom DNS resolver. Mirrors Java
111 /// `ClientBuilder#dnsResolver`. Used on every connection attempt
112 /// (initial + reconnect) instead of tokio's default
113 /// [`tokio::net::lookup_host`]. Useful for service-mesh sidecar
114 /// resolution, IPv4/IPv6 preference, pinning, etc.
115 ///
116 /// Default: tokio's built-in DNS via
117 /// [`magnetar_runtime_tokio::TokioDnsResolver`].
118 #[must_use]
119 pub fn dns_resolver(
120 mut self,
121 resolver: std::sync::Arc<dyn magnetar_runtime_tokio::DnsResolver>,
122 ) -> Self {
123 self.dns_resolver = Some(resolver);
124 self
125 }
126
127 /// Set the global publish memory budget for the client. Mirrors Java
128 /// `ClientBuilder#memoryLimit(long, MemoryLimitPolicy)`. `bytes = 0`
129 /// disables the limit (matches Java default).
130 ///
131 /// **Enforcement**: under `MemoryLimitPolicy::FailImmediately`, every
132 /// `Producer::send` reserves the payload bytes against the budget via
133 /// an `AtomicU64` CAS loop on `ConnectionShared::memory_used` BEFORE
134 /// the payload reaches the sans-io state machine. Sends that would
135 /// push past the limit are rejected synchronously with
136 /// [`magnetar_runtime_tokio::ClientError::MemoryLimitExceeded`]. The
137 /// reservation is released on `SendFut` completion (success or
138 /// error) and on cancellation (via `Drop`).
139 ///
140 /// Under `MemoryLimitPolicy::ProducerBlock`, the send future parks
141 /// on a `Notify`-based wait until the budget frees up — both engines
142 /// (`TokioEngine`, `MoonpoolEngine<P>`) implement this policy; see
143 /// [`docs/memory-limit.md`](https://github.com/FlorentinDUBOIS/magnetar/blob/main/docs/memory-limit.md).
144 #[must_use]
145 pub fn memory_limit(mut self, bytes: usize, policy: MemoryLimitPolicy) -> Self {
146 self.memory_limit = Some(MemoryLimit { bytes, policy });
147 self
148 }
149
150 /// Set the number of connections the client opens to **each broker**. Mirrors
151 /// Java `ClientBuilder#connectionsPerBroker(int)` (issue #314, [ADR-0073]).
152 ///
153 /// Default (and `0`/`1`): **one** connection per broker — every producer and
154 /// consumer for a given broker shares a single TCP connection, exactly as
155 /// before. With `n > 1`, the client opens up to `n` connections per broker
156 /// and round-robins producers / consumers across them, so a single logical
157 /// producer fleet can spread its publish load over several independent
158 /// connections instead of contending on one (the per-connection driver, its
159 /// send path, and its receipt-read path are independent per connection).
160 /// This removes the send-side back-pressure that otherwise forces
161 /// applications to hand-roll a pool of [`PulsarClient`]s.
162 ///
163 /// `0` is treated as `1` (matching Java, where the floor is one connection).
164 ///
165 /// [ADR-0073]: https://github.com/CleverCloud/magnetar/blob/main/specs/adr/0073-connections-per-broker.md
166 #[must_use]
167 pub fn connections_per_broker(mut self, n: usize) -> Self {
168 self.connections_per_broker = Some(n.max(1));
169 self
170 }
171
172 /// Set a pluggable [`magnetar_proto::ServiceUrlProvider`] consulted on every
173 /// (re)connection attempt. Mirrors Java
174 /// `ClientBuilder#serviceUrlProvider(ServiceUrlProvider)` — lays the groundwork
175 /// for PIP-121 cluster failover (`AutoClusterFailover` /
176 /// `ControlledClusterFailover`). When set, the provider's
177 /// `get_service_url()` is used at connect time; the unset form retains the
178 /// legacy `service_url(...)` shortcut and is internally wrapped in a
179 /// [`magnetar_proto::StaticServiceUrlProvider`] at build time.
180 #[must_use]
181 pub fn service_url_provider(
182 mut self,
183 provider: std::sync::Arc<dyn magnetar_proto::ServiceUrlProvider>,
184 ) -> Self {
185 self.service_url_provider = Some(provider);
186 self
187 }
188
189 /// Override the advertised client version.
190 #[must_use]
191 pub fn client_version(mut self, version: impl Into<String>) -> Self {
192 self.client_version = Some(version.into());
193 self
194 }
195
196 /// Set the keep-alive (ping) interval.
197 #[must_use]
198 pub fn keepalive(mut self, dur: Duration) -> Self {
199 self.keepalive = Some(dur);
200 self
201 }
202
203 /// Set the cadence at which the client re-samples every producer's and
204 /// consumer's rolling rate window — the sampling that makes
205 /// [`magnetar_proto::ProducerStats::msgs_per_sec`] / `bytes_per_sec` and
206 /// their [`magnetar_proto::ConsumerStats`] counterparts nonzero. Mirrors
207 /// Java `ClientBuilder#statsInterval(long, TimeUnit)`.
208 ///
209 /// The tick runs inside the sans-io state machine's existing
210 /// `poll_timeout` / `handle_timeout` deadline loop (ADR-0089), so it
211 /// applies to **every** producer and consumer on the client — including
212 /// the per-partition and per-topic children behind
213 /// [`crate::PartitionedProducer`], [`crate::MultiTopicsConsumer`] and
214 /// [`crate::PatternConsumer`], whose `aggregate_stats()` folds therefore
215 /// sum real rates rather than zeros. There is deliberately no per-wrapper
216 /// fan-out method: Java's wrappers have none either, and one clock ticking
217 /// every child is what makes the folded sum well-defined.
218 ///
219 /// `Duration::ZERO` disables the sweep, spelling Java's
220 /// `statsIntervalSeconds = 0`. Leaving the knob unset inherits
221 /// [`ConnectionConfig::stats_interval`](magnetar_proto::conn::ConnectionConfig)'s
222 /// default.
223 ///
224 /// A producer or consumer created mid-window has no baseline yet, so its
225 /// first sweep only seeds one and it reports `0.0` for one further
226 /// interval. Java behaves identically.
227 #[must_use]
228 pub fn stats_interval(mut self, dur: Duration) -> Self {
229 self.stats_interval = Some(dur);
230 self
231 }
232
233 /// Arm the per-consumer stall watchdog (issue #414).
234 ///
235 /// A consumer that holds un-spent broker permits over an empty receive queue, in a
236 /// dispatch-eligible state, for `dur` without a single dispatch unit arriving surfaces
237 /// one `warn!` and one
238 /// [`ConnectionEvent::ConsumerStalled`](magnetar_proto::event::ConnectionEvent::ConsumerStalled)
239 /// — exactly one per stall episode, re-armed by the next dispatch. That is its only
240 /// effect unless [`Self::consumer_stall_auto_recovery`] is also set; otherwise
241 /// recovery stays an explicit call to `Consumer::resubscribe()`, escalating to an
242 /// operator-side `pulsar-admin topics unload` for a dispatcher-wide broker fault.
243 ///
244 /// This is the one silence the ADR-0058 connection keepalive cannot see: a broker whose
245 /// dispatcher has wedged for ONE subscription keeps answering `PING` with `PONG`.
246 ///
247 /// **Unset by default** — the mechanism ships disarmed, since an armed deadline
248 /// perturbs the moonpool engine's simulated wake schedule even when it never fires, and
249 /// Java has no per-consumer dispatch watchdog to inherit a parity value from.
250 /// `Duration::from_secs(30)` is the recommended production value: it matches the
251 /// keepalive and ack-response cadences, and is far longer than any legitimate dispatch
252 /// gap on a subscription that holds permits over an empty queue.
253 ///
254 /// `Duration::ZERO` disables it explicitly, mirroring how
255 /// [`Self::stats_interval`] spells its disable.
256 #[must_use]
257 pub fn consumer_stall_timeout(mut self, dur: Duration) -> Self {
258 self.consumer_stall_timeout = Some(dur);
259 self
260 }
261
262 /// Let the stall watchdog recover a wedged consumer by itself, at most `max_attempts`
263 /// times per stall streak (issue #414, ADR-0103).
264 ///
265 /// Each attempt is the same in-place re-attach `Consumer::resubscribe()` performs —
266 /// zero this client's permit mirrors, fail the orphaned in-flight acks, re-emit
267 /// `CommandSubscribe` for the same consumer id on the live connection, and let the
268 /// broker's `Success` release a fresh initial `CommandFlow`. No transport reconnect,
269 /// no other consumer or producer disturbed, and the receiver queue left intact.
270 ///
271 /// The `ConsumerStalled` event and its `warn!` are emitted either way, so arming this
272 /// adds a recovery attempt without ever hiding the diagnosis.
273 ///
274 /// **Requires [`Self::consumer_stall_timeout`]** — with no window there is no stall
275 /// episode, and this knob is inert. **Unset by default**, and `0` disables it
276 /// explicitly, mirroring how [`Self::consumer_stall_timeout`] spells its disable.
277 ///
278 /// # Choosing the bound
279 ///
280 /// At most one attempt is made per stall episode and an episode closes at most once
281 /// per `consumer_stall_timeout`, so `max_attempts` caps a sequence already limited to
282 /// one re-subscribe per window: with a 30 s window, `3` spends at most three
283 /// re-subscribes over ninety seconds before giving up. The counter resets on **real
284 /// progress only** — one broker dispatch unit actually arriving — so a consumer that
285 /// recovers and later wedges again gets its full budget back, while a consumer the
286 /// broker acks but never dispatches to does not.
287 ///
288 /// Keep it small. An attempt repairs **this client's own slot** in the broker's
289 /// dispatcher and lifts the subscription's aggregate permit counter by one
290 /// receiver-queue window; issue #414's production failure was dispatcher-WIDE, with
291 /// that aggregate observed at `-177300`, which no realistic number of re-subscribes
292 /// reaches. When the budget is exhausted the client stops and logs the escalation —
293 /// `pulsar-admin topics unload` — instead of re-subscribing forever against a fault it
294 /// cannot repair. See [`docs/consumer-stall-recovery.md`](https://github.com/CleverCloud/magnetar/blob/main/docs/consumer-stall-recovery.md).
295 #[must_use]
296 pub fn consumer_stall_auto_recovery(mut self, max_attempts: u32) -> Self {
297 self.consumer_stall_auto_recovery = Some(max_attempts);
298 self
299 }
300
301 /// Set the total deadline for one broker-facing setup operation.
302 ///
303 /// The budget includes partition metadata, topic-list snapshots, lookup
304 /// and redirect routing, retry backoff, producer-open or subscribe
305 /// attachment, and every child of a composite builder. The operation
306 /// preserves the newest retryable broker diagnostic so a later deadline
307 /// returns it instead of a generic timeout.
308 #[must_use]
309 pub fn operation_timeout(mut self, dur: Duration) -> Self {
310 self.operation_timeout = Some(dur);
311 self
312 }
313
314 /// Configure broker-operation retries independently from transport
315 /// reconnection.
316 ///
317 /// Applies to lookup, partition metadata, producer-open, and subscribe.
318 /// Producer-open additionally retries both producer-quota variants and
319 /// `ProducerBusy`; subscribe additionally retries `ConsumerBusy`.
320 /// Before first attachment, producer-open and subscribe retries re-run
321 /// lookup and routing with a fresh provisional handle. Established
322 /// reattachment remains driver-owned.
323 /// `max_retries` counts re-issues after the initial attempt; `None`
324 /// removes the count cap but the enclosing [`Self::operation_timeout`]
325 /// deadline still bounds the operation.
326 #[must_use]
327 pub fn operation_retry(mut self, config: magnetar_proto::OperationRetryConfig) -> Self {
328 self.operation_retry = Some(config);
329 self
330 }
331
332 /// Bound how long the client waits for a `CommandAckResponse` after
333 /// issuing a `CommandAck`. In-flight acks past `enqueued_at + timeout`
334 /// resolve with a synthetic broker error carrying `code=-1,
335 /// message="ack timeout"` on the next state-machine tick — mirrors
336 /// [`crate::ProducerBuilder::send_timeout`]'s shape and rationale.
337 ///
338 /// The default is **30 s** (mirrors the `send_timeout` Java-parity
339 /// default, ADR-0072), so an ack whose response is lost or dropped in
340 /// flight fails deterministically rather than hanging the caller's
341 /// `ack().await` forever. A same-broker `CloseConsumer` (bundle
342 /// reassignment, issue #307) additionally fails every ack pending
343 /// against the torn-down consumer id immediately, ahead of this
344 /// deadline — this knob is the generic backstop for every other cause
345 /// of a dropped response. Call [`Self::disable_ack_response_timeout`]
346 /// for the unbounded (never-times-out) behavior.
347 #[must_use]
348 pub fn ack_response_timeout(mut self, timeout: Duration) -> Self {
349 self.ack_response_timeout = Some(AckResponseTimeoutOverride::Explicit(timeout));
350 self
351 }
352
353 /// Disable the ack-response timeout: in-flight acks never resolve with a
354 /// synthetic timeout error — they wait indefinitely for the broker's
355 /// `CommandAckResponse` (or a session-loss / terminal error, or the
356 /// same-broker `CloseConsumer` orphan sweep, which is unaffected by this
357 /// knob). Overrides the 30 s default.
358 #[must_use]
359 pub fn disable_ack_response_timeout(mut self) -> Self {
360 self.ack_response_timeout = Some(AckResponseTimeoutOverride::Disabled);
361 self
362 }
363
364 /// Override the default `max_message_size` used as the chunking threshold when the
365 /// broker does not advertise one on `CommandConnected`. The Pulsar default is 5 MiB;
366 /// match the broker's configured `maxMessageSize` to avoid mis-sized chunks. Mirrors
367 /// Java `ClientBuilder#maxMessageSize`.
368 #[must_use]
369 pub fn max_message_size(mut self, size: usize) -> Self {
370 self.default_max_message_size = Some(size);
371 self
372 }
373
374 /// Set the proxy-to-broker URL for the binary proxy path. The connection then opens
375 /// against the proxy with the broker URL stamped on the `CommandConnect.proxy_to_broker_url`
376 /// field. Mirrors Java `ClientBuilder#proxyServiceUrl(... ProxyProtocol.SNI)`. Leave
377 /// unset for direct broker connections.
378 #[must_use]
379 pub fn proxy_to_broker_url(mut self, url: impl Into<String>) -> Self {
380 self.proxy_to_broker_url = Some(url.into());
381 self
382 }
383
384 /// Enable the auto-reconnect supervisor with the supplied
385 /// [`magnetar_proto::SupervisorConfig`]. When set, runtime engines wrap the driver
386 /// loop in a [`magnetar_proto::Backoff`]-driven reconnect cycle so the connection
387 /// survives transport failures. Without this knob the driver exits on the first
388 /// I/O error (matches the pre-supervisor behavior). Mirrors Java's
389 /// `PulsarClientImpl` reconnect loop.
390 ///
391 /// Note: pending in-flight producer/consumer requests issued before the drop
392 /// surface a "session lost" outcome on the new connection; transparent
393 /// re-subscription and producer reattachment across reconnects is a future
394 /// enhancement layered on top of this scaffold.
395 #[must_use]
396 pub fn enable_reconnect(mut self, config: magnetar_proto::SupervisorConfig) -> Self {
397 self.supervisor = Some(config);
398 self
399 }
400
401 /// Use the supplied auth provider to populate the initial CONNECT auth data,
402 /// and keep the provider for in-band `CommandAuthChallenge` refresh
403 /// (PIP-30 / PIP-292).
404 ///
405 /// **BREAKING CHANGE**: the provider's [`magnetar_proto::AuthProvider::initial`]
406 /// is now invoked inside [`Self::build`] and any error it returns
407 /// surfaces through [`PulsarError::Config`] — the previous behaviour
408 /// silently dropped the error via `.ok()`, which would have let an
409 /// uncached `OAuth2` flow / a missing token file / an expired credential
410 /// open an *anonymous* connection (CWE-287). Callers using a provider
411 /// whose `initial()` returns `Err(AuthError::Invalid)` until an
412 /// out-of-band warm-up runs (e.g. `OAuth2Provider::ensure_fresh`) MUST
413 /// warm the provider before calling [`Self::build`].
414 #[must_use]
415 pub fn auth(mut self, provider: std::sync::Arc<dyn magnetar_proto::AuthProvider>) -> Self {
416 self.auth_method_name = Some(provider.method().to_owned());
417 // NOTE: we deliberately do NOT call `provider.initial()` here. The
418 // previous `.ok()` swallowed errors and let an unwarmed provider
419 // produce an anonymous connection. The fetch + error propagation
420 // now lives in `build()`.
421 self.auth_provider = Some(provider);
422 self
423 }
424
425 /// Mirrors Java `ClientBuilder#tlsTrustCertsFilePath` (PEM-supplied
426 /// equivalent — magnetar keeps the façade I/O-free, callers read the
427 /// file themselves via `std::fs::read(path)?` and pass the bytes).
428 /// Supplies a PEM-encoded chain (typically a self-signed CA used by
429 /// the broker). When set, the connection's TLS handshake validates
430 /// the broker against this chain INSTEAD OF the system trust
431 /// store. Only honoured for `pulsar+ssl://` URLs.
432 #[must_use]
433 pub fn tls_trust_certs_pem(mut self, pem: impl Into<Vec<u8>>) -> Self {
434 self.tls_trust_certs_pem = Some(pem.into());
435 self
436 }
437
438 /// Mirror of Java `ClientBuilder#tlsAllowInsecureConnection`. When `true`,
439 /// the TLS handshake accepts any server certificate without verifying its
440 /// trust chain — useful for local development against a self-signed broker
441 /// or for CI / e2e against an ephemeral container. **Insecure for
442 /// production**: the client cannot tell a real broker from a MITM.
443 ///
444 /// Default: `false`. Only honoured for `pulsar+ssl://` URLs. Overrides any
445 /// `tls_trust_certs_pem` chain when set.
446 #[must_use]
447 pub fn tls_allow_insecure_connection(mut self, on: bool) -> Self {
448 self.tls_allow_insecure_connection = on;
449 self
450 }
451
452 /// Mirror of Java `ClientBuilder#enableTlsHostnameVerification`. When
453 /// `true` (the default), the handshake additionally checks the server
454 /// certificate's CN / SAN matches the broker hostname from the URL. When
455 /// `false`, the chain is still verified but the hostname mismatch is
456 /// tolerated.
457 ///
458 /// Default: `true` (matches Java's secure default). When
459 /// [`Self::tls_allow_insecure_connection`] is `true` this flag is moot —
460 /// the verifier already accepts everything.
461 ///
462 /// **Note**: today only the "off + insecure both true" combination is
463 /// runtime-enforced via [`magnetar_runtime_tokio::insecure_tls_config`].
464 /// A hostname-only-skip verifier (chain on, hostname off) is a planned
465 /// follow-up; passing `false` without also enabling
466 /// `tls_allow_insecure_connection` is currently treated as the default
467 /// (hostname verification stays on).
468 #[must_use]
469 pub fn tls_hostname_verification_enable(mut self, on: bool) -> Self {
470 self.tls_hostname_verification_enable = on;
471 self
472 }
473
474 /// Build and connect the client.
475 ///
476 /// # Errors
477 /// Returns [`PulsarError::Config`] if the service URL is missing, or
478 /// [`PulsarError::Client`] if the underlying tokio engine fails to
479 /// connect.
480 // The function is a flat config-translation: tls flavour cases on top, then config field
481 // copies, then the connect-flavour dispatch. Inlined for readability — each branch is
482 // straight-line and the dispatch is easier to follow without an extracted helper that
483 // would have to forward every config field anyway.
484 #[allow(clippy::too_many_lines)]
485 pub async fn build(self) -> Result<PulsarClient> {
486 let service_url = match (&self.service_url_provider, &self.service_url) {
487 (Some(provider), _) => provider.get_service_url(),
488 (None, Some(url)) => url.clone(),
489 (None, None) => {
490 return Err(PulsarError::Config(
491 "service_url or service_url_provider is required".to_owned(),
492 ));
493 }
494 };
495 // `connections_per_broker` is a runtime connection-pool policy (it never
496 // reaches the sans-io `magnetar-proto` core — ADR-0004/ADR-0073), so it is
497 // applied to the runtime `Client` after connect rather than threaded into
498 // `ConnectionConfig`. Captured here (it is `Copy`) before `self` is moved
499 // into the connect-flavour branches below.
500 let connections_per_broker = self.connections_per_broker;
501 let operation_retry = self.operation_retry.clone();
502 let mut config = magnetar_proto::conn::ConnectionConfig::default();
503 if let Some(v) = self.client_version {
504 config.client_version = v;
505 }
506 if let Some(d) = self.keepalive {
507 config.keepalive_interval = d;
508 }
509 // ADR-0089 / Java `statsIntervalSeconds`: zero disables the sweep, any
510 // other value arms it. Unset leaves `ConnectionConfig::default()`'s
511 // value untouched, which is Java's 60 s — so a caller who never touched
512 // this knob still gets Java-parity sampling.
513 if let Some(d) = self.stats_interval {
514 config.stats_interval = (d != Duration::ZERO).then_some(d);
515 }
516 // Issue #414: same zero-disables shape as `stats_interval` above. Unset leaves
517 // `ConnectionConfig::default()`'s `None` — the watchdog is opt-in.
518 if let Some(d) = self.consumer_stall_timeout {
519 config.consumer_stall_timeout = (d != Duration::ZERO).then_some(d);
520 }
521 // ADR-0103: the same zero-disables shape one knob up, spelled in attempts rather
522 // than in a `Duration`. Unset leaves `ConnectionConfig::default()`'s `None`, and
523 // the whole mechanism is inert anyway without `consumer_stall_timeout`.
524 if let Some(n) = self.consumer_stall_auto_recovery {
525 config.consumer_stall_auto_recovery = (n != 0).then_some(n);
526 }
527 if let Some(d) = self.operation_timeout {
528 config.operation_timeout = d;
529 }
530 // Explicit or disabled always wins over `ConnectionConfig::default()`'s
531 // `Some(30s)`; unset (`None`) leaves the default untouched.
532 match self.ack_response_timeout {
533 Some(AckResponseTimeoutOverride::Explicit(d)) => config.ack_response_timeout = Some(d),
534 Some(AckResponseTimeoutOverride::Disabled) => config.ack_response_timeout = None,
535 None => {}
536 }
537 if let Some(s) = self.default_max_message_size {
538 config.default_max_message_size = s;
539 }
540 if let Some(url) = self.proxy_to_broker_url {
541 config.proxy_to_broker_url = Some(url);
542 }
543 if let Some(sv) = self.supervisor {
544 config.supervisor = Some(sv);
545 }
546 // Java `ClientBuilder#memoryLimit` — wire the configured budget into the runtime so
547 // `Producer::send` reserves payload bytes against `ConnectionShared::memory_limit_bytes`
548 // before queueing. Both `FailImmediately` and `ProducerBlock` are honored by the
549 // tokio and moonpool engines (the latter parks the send future on a `Notify` wait
550 // until the budget frees up).
551 if let Some(limit) = self.memory_limit {
552 // Cast saturates rather than truncates so a 64-bit limit on a 32-bit usize host
553 // (effectively impossible — magnetar requires 64-bit pointers — but cheap to
554 // future-proof) stays correct.
555 config.memory_limit_bytes = limit.bytes as u64;
556 config.memory_limit_policy = limit.policy.into();
557 }
558 if let Some(name) = self.auth_method_name {
559 config.auth_method_name = name;
560 }
561 // BREAKING CHANGE: surface the provider's `initial()` failure here
562 // rather than silently dropping it via `.ok()` in `auth(...)`. A
563 // missing token file or an unwarmed OAuth2 cache used to slip
564 // through and produce an anonymous CONNECT (CWE-287). The
565 // direct-bytes `self.auth_data` set via internal call sites still
566 // wins when present (matches the prior precedence).
567 if let Some(data) = self.auth_data {
568 config.auth_data = Some(data);
569 } else if let Some(provider) = self.auth_provider.as_ref() {
570 let bytes = provider.initial().map_err(|err| {
571 PulsarError::Config(format!(
572 "auth provider initial() failed; cannot open authenticated connection: {err}"
573 ))
574 })?;
575 config.auth_data = Some(bytes);
576 }
577 // Java `ClientBuilder#dnsResolver` — when configured, every reconnect (including the
578 // initial dial) routes through `provider.resolve(host, port)` via
579 // `Client::connect_with_resolver_and_provider`. When unset, the runtime falls back to
580 // tokio's built-in `lookup_host` (and we can keep using the lighter `connect_auth`
581 // shortcut when none of TLS / provider / resolver is configured).
582 let inner = if self.tls_allow_insecure_connection {
583 let parsed = magnetar_runtime_tokio::ParsedUrl::parse(&service_url)?;
584 let tls_config = match parsed.scheme {
585 magnetar_runtime_tokio::Scheme::Tls => {
586 Some(magnetar_runtime_tokio::insecure_tls_config())
587 }
588 magnetar_runtime_tokio::Scheme::Plain => None,
589 };
590 Client::connect_with_resolver_and_provider(
591 parsed,
592 tls_config,
593 config,
594 self.auth_provider,
595 self.service_url_provider,
596 self.dns_resolver,
597 )
598 .await?
599 } else if let Some(pem) = self.tls_trust_certs_pem {
600 let parsed = magnetar_runtime_tokio::ParsedUrl::parse(&service_url)?;
601 let tls_config = match parsed.scheme {
602 magnetar_runtime_tokio::Scheme::Tls => {
603 // Java parity: `enableTlsHostnameVerification(false)` paired with a
604 // PEM trust store keeps the chain check but skips the hostname match.
605 if self.tls_hostname_verification_enable {
606 Some(Client::tls_config_from_pem(&pem)?)
607 } else {
608 Some(magnetar_runtime_tokio::tls_config_no_hostname(&pem)?)
609 }
610 }
611 magnetar_runtime_tokio::Scheme::Plain => None,
612 };
613 Client::connect_with_resolver_and_provider(
614 parsed,
615 tls_config,
616 config,
617 self.auth_provider,
618 self.service_url_provider,
619 self.dns_resolver,
620 )
621 .await?
622 } else if self.service_url_provider.is_some() || self.dns_resolver.is_some() {
623 // Provider OR resolver configured but no explicit TLS / PEM. Go through the
624 // provider+resolver-aware path so PIP-121 rotation AND custom DNS work on
625 // reconnect — `connect_auth` doesn't accept either arg.
626 let parsed = magnetar_runtime_tokio::ParsedUrl::parse(&service_url)?;
627 let tls_config = match parsed.scheme {
628 magnetar_runtime_tokio::Scheme::Tls => {
629 Some(magnetar_runtime_tokio::default_tls_config()?)
630 }
631 magnetar_runtime_tokio::Scheme::Plain => None,
632 };
633 Client::connect_with_resolver_and_provider(
634 parsed,
635 tls_config,
636 config,
637 self.auth_provider,
638 self.service_url_provider,
639 self.dns_resolver,
640 )
641 .await?
642 } else {
643 Client::connect_auth(&service_url, config, self.auth_provider).await?
644 };
645 // Java `ClientBuilder#connectionsPerBroker` — apply the fan-out to the
646 // runtime client now that the bootstrap connection is up (ADR-0073, #314).
647 let inner = match operation_retry {
648 Some(config) => inner.with_operation_retry(config),
649 None => inner,
650 };
651 let inner = match connections_per_broker {
652 Some(n) => inner.with_connections_per_broker(n),
653 None => inner,
654 };
655 Ok(PulsarClient {
656 inner,
657 memory_limit: self.memory_limit,
658 })
659 }
660}
661
662#[cfg(test)]
663mod tests {
664 use bytes::Bytes;
665 use magnetar_proto::{AuthError, AuthProvider, OperationRetryConfig};
666
667 use super::ClientBuilder;
668 use crate::{MemoryLimitPolicy, PulsarError};
669
670 #[test]
671 fn memory_limit_policy_converts_exhaustively_to_proto() {
672 assert_eq!(
673 magnetar_proto::MemoryLimitPolicy::from(MemoryLimitPolicy::FailImmediately),
674 magnetar_proto::MemoryLimitPolicy::FailImmediately,
675 );
676 assert_eq!(
677 magnetar_proto::MemoryLimitPolicy::from(MemoryLimitPolicy::ProducerBlock),
678 magnetar_proto::MemoryLimitPolicy::ProducerBlock,
679 );
680 }
681
682 /// Stub provider whose `initial()` returns `Err(AuthError::Invalid)`.
683 /// Models an unwarmed `OAuth2` cache, a missing token file, or any other
684 /// provider whose credential-fetch failed.
685 #[derive(Debug)]
686 struct FailingProvider;
687
688 impl AuthProvider for FailingProvider {
689 fn method(&self) -> &str {
690 "token"
691 }
692 fn initial(&self) -> Result<Bytes, AuthError> {
693 Err(AuthError::Invalid("forced failure (test)".to_owned()))
694 }
695 }
696
697 #[test]
698 fn operation_retry_builder_knob_stores_the_independent_policy() {
699 let policy = OperationRetryConfig {
700 initial_backoff: std::time::Duration::from_millis(25),
701 max_backoff: std::time::Duration::from_millis(200),
702 max_retries: Some(4),
703 };
704 let builder = ClientBuilder::default().operation_retry(policy.clone());
705 assert_eq!(builder.operation_retry, Some(policy));
706 assert!(
707 builder.supervisor.is_none(),
708 "operation retry must not implicitly enable transport reconnection"
709 );
710 }
711
712 /// BREAKING CHANGE regression (F6, CWE-287): `ClientBuilder::auth(...)`
713 /// used to call `provider.initial().ok()`, silently dropping the error
714 /// and leaving `auth_data = None`. The resulting CONNECT carried no
715 /// credentials and the broker happily opened an *anonymous* session
716 /// when its auth plugin allowed it — a textbook authentication-bypass
717 /// vector when the provider is the only thing standing between the
718 /// caller and an anonymous connection.
719 ///
720 /// The fix defers `provider.initial()` to `build()` and surfaces the
721 /// failure through `PulsarError::Config`. This test pins that contract:
722 /// no anonymous fallback, no broker dial, just an early `Err`.
723 #[tokio::test(flavor = "current_thread")]
724 async fn build_propagates_auth_provider_initial_error() {
725 let provider = std::sync::Arc::new(FailingProvider);
726 let result = ClientBuilder::default()
727 // Localhost target is fine — `build()` must surface the auth
728 // error BEFORE the dial, so no listener is required.
729 .service_url("pulsar://127.0.0.1:1")
730 .auth(provider)
731 .build()
732 .await;
733 let err = result.expect_err(
734 "build() must surface auth provider initial() error, not silently \
735 fall back to an anonymous CONNECT (CWE-287)",
736 );
737 match err {
738 PulsarError::Config(msg) => {
739 assert!(
740 msg.contains("auth provider initial()"),
741 "error must point at the auth path: {msg}"
742 );
743 assert!(
744 msg.contains("forced failure (test)"),
745 "error must propagate the provider's message: {msg}"
746 );
747 }
748 other => {
749 panic!("expected PulsarError::Config carrying the auth failure, got: {other:?}")
750 }
751 }
752 }
753}