Skip to main content

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}