Skip to main content

rpc/
lib.rs

1//! gRPC transport primitives for rust-zero services.
2//!
3//! Service implementations remain ordinary Tonic services. [`RpcServer`] and [`RpcClient`]
4//! centralize the transport safeguards that should be applied consistently across services.
5
6use std::{
7    collections::{BTreeMap, BTreeSet},
8    error::Error as StdError,
9    fmt,
10    future::Future,
11    net::SocketAddr,
12    time::Duration,
13};
14
15use rust_zero_core::{
16    DiscoveredEndpoint, DiscoveryError, EndpointSubscription, HealthRegistry, ServiceRegistry,
17};
18use serde::{Deserialize, Serialize};
19use tonic::transport::{Channel, Endpoint, Server};
20use tower::discover::Change;
21
22pub mod auth;
23pub mod metrics;
24pub mod resilience;
25pub mod stack;
26pub mod trace;
27
28pub mod echo {
29    tonic::include_proto!("rust_zero.echo");
30}
31
32pub use auth::{BearerToken, RpcBearerAuth, RpcJwtAuth, RpcRequestSignatureAuth, RpcRequestSigner};
33pub use metrics::{RpcMetricMode, RpcMetrics, RpcMetricsLayer};
34pub use resilience::{acceptable_status, circuit_outcome, RpcCircuitBreaker, RpcLoadShedder};
35pub use rust_zero_core::{AuthFailure, JwtClaimProjection, RequestSignatureVerifier};
36pub use stack::{
37    RpcClientStack, RpcClientStackBuilder, RpcClientStackService, RpcServerStack,
38    RpcServerStackBuilder,
39};
40pub use tonic_health::server::{health_reporter, HealthReporter};
41pub use trace::RpcTrace;
42#[cfg(feature = "telemetry")]
43pub use trace::{RpcTelemetryLayer, RpcTelemetryMode};
44
45/// Transport settings applied to every gRPC service on a server.
46#[derive(Debug, Clone, Serialize, Deserialize)]
47#[serde(default)]
48pub struct RpcServerConfig {
49    address: SocketAddr,
50    #[serde(rename = "request_timeout_ms", with = "optional_duration_millis")]
51    request_timeout: Option<Duration>,
52    concurrency_limit: Option<usize>,
53    max_concurrent_streams: Option<u32>,
54    #[serde(rename = "shutdown_timeout_ms", with = "duration_millis")]
55    shutdown_timeout: Duration,
56}
57
58impl Default for RpcServerConfig {
59    fn default() -> Self {
60        Self::new(
61            "0.0.0.0:50051"
62                .parse()
63                .expect("default RPC address is valid"),
64        )
65    }
66}
67
68impl RpcServerConfig {
69    pub fn new(address: SocketAddr) -> Self {
70        Self {
71            address,
72            request_timeout: None,
73            concurrency_limit: None,
74            max_concurrent_streams: None,
75            shutdown_timeout: Duration::from_secs(30),
76        }
77    }
78
79    pub fn with_request_timeout(mut self, timeout: Duration) -> Self {
80        assert!(
81            !timeout.is_zero(),
82            "request timeout must be greater than zero"
83        );
84        self.request_timeout = Some(timeout);
85        self
86    }
87
88    pub fn with_concurrency_limit(mut self, limit: usize) -> Self {
89        assert!(limit > 0, "concurrency limit must be greater than zero");
90        self.concurrency_limit = Some(limit);
91        self
92    }
93
94    pub fn with_max_concurrent_streams(mut self, limit: u32) -> Self {
95        assert!(
96            limit > 0,
97            "maximum concurrent streams must be greater than zero"
98        );
99        self.max_concurrent_streams = Some(limit);
100        self
101    }
102
103    pub fn with_shutdown_timeout(mut self, timeout: Duration) -> Self {
104        assert!(
105            !timeout.is_zero(),
106            "shutdown timeout must be greater than zero"
107        );
108        self.shutdown_timeout = timeout;
109        self
110    }
111
112    pub fn address(&self) -> SocketAddr {
113        self.address
114    }
115
116    pub fn shutdown_timeout(&self) -> Duration {
117        self.shutdown_timeout
118    }
119
120    pub fn validate(&self) -> Result<(), RpcConfigError> {
121        if self
122            .request_timeout
123            .is_some_and(|duration| duration.is_zero())
124        {
125            return Err(RpcConfigError::Invalid(
126                "request timeout must be greater than zero",
127            ));
128        }
129        if self.concurrency_limit == Some(0) {
130            return Err(RpcConfigError::Invalid(
131                "concurrency limit must be greater than zero",
132            ));
133        }
134        if self.max_concurrent_streams == Some(0) {
135            return Err(RpcConfigError::Invalid(
136                "maximum concurrent streams must be greater than zero",
137            ));
138        }
139        if self.shutdown_timeout.is_zero() {
140            return Err(RpcConfigError::Invalid(
141                "shutdown timeout must be greater than zero",
142            ));
143        }
144        Ok(())
145    }
146}
147
148/// Builds configured Tonic server routers.
149#[derive(Debug, Clone)]
150pub struct RpcServer {
151    config: RpcServerConfig,
152}
153
154impl RpcServer {
155    pub fn new(config: RpcServerConfig) -> Self {
156        Self { config }
157    }
158
159    pub fn try_new(config: RpcServerConfig) -> Result<Self, RpcConfigError> {
160        config.validate()?;
161        Ok(Self { config })
162    }
163
164    pub fn config(&self) -> &RpcServerConfig {
165        &self.config
166    }
167
168    /// Returns a Tonic server builder with the configured timeout and load limits.
169    pub fn router(&self) -> Server {
170        let mut server = Server::builder();
171
172        if let Some(timeout) = self.config.request_timeout {
173            server = server.timeout(timeout);
174        }
175        if let Some(limit) = self.config.concurrency_limit {
176            server = server.concurrency_limit_per_connection(limit);
177        }
178        if let Some(limit) = self.config.max_concurrent_streams {
179            server = server.max_concurrent_streams(Some(limit));
180        }
181
182        server
183    }
184
185    /// Serves an assembled Tonic router and bounds draining after the shutdown signal fires.
186    pub async fn serve_with_shutdown<F>(
187        &self,
188        router: tonic::transport::server::Router,
189        signal: F,
190    ) -> Result<(), RpcServerError>
191    where
192        F: Future<Output = ()>,
193    {
194        self.config
195            .validate()
196            .map_err(RpcServerError::Configuration)?;
197        let (stop, stopped) = tokio::sync::oneshot::channel::<()>();
198        let serving = router.serve_with_shutdown(self.config.address, async move {
199            let _ = stopped.await;
200        });
201        tokio::pin!(serving);
202        tokio::pin!(signal);
203
204        tokio::select! {
205            result = &mut serving => result.map_err(RpcServerError::Transport),
206            _ = &mut signal => {
207                let _ = stop.send(());
208                tokio::time::timeout(self.config.shutdown_timeout, serving)
209                    .await
210                    .map_err(|_| RpcServerError::ShutdownTimeout)?
211                    .map_err(RpcServerError::Transport)
212            }
213        }
214    }
215}
216
217#[derive(Debug)]
218pub enum RpcServerError {
219    Configuration(RpcConfigError),
220    Transport(tonic::transport::Error),
221    ShutdownTimeout,
222}
223
224impl fmt::Display for RpcServerError {
225    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
226        match self {
227            Self::Configuration(error) => write!(formatter, "invalid gRPC configuration: {error}"),
228            Self::Transport(error) => write!(formatter, "gRPC server transport error: {error}"),
229            Self::ShutdownTimeout => formatter.write_str("gRPC graceful shutdown timed out"),
230        }
231    }
232}
233
234impl StdError for RpcServerError {
235    fn source(&self) -> Option<&(dyn StdError + 'static)> {
236        match self {
237            Self::Configuration(error) => Some(error),
238            Self::Transport(error) => Some(error),
239            Self::ShutdownTimeout => None,
240        }
241    }
242}
243
244/// Connection settings for a gRPC client.
245#[derive(Debug, Clone, Serialize, Deserialize)]
246#[serde(default)]
247pub struct RpcClientConfig {
248    uri: String,
249    #[serde(rename = "request_timeout_ms", with = "optional_duration_millis")]
250    request_timeout: Option<Duration>,
251    #[serde(rename = "connect_timeout_ms", with = "optional_duration_millis")]
252    connect_timeout: Option<Duration>,
253    concurrency_limit: Option<usize>,
254    #[serde(rename = "tcp_keepalive_ms", with = "optional_duration_millis")]
255    tcp_keepalive: Option<Duration>,
256    #[serde(
257        rename = "http2_keepalive_interval_ms",
258        with = "optional_duration_millis"
259    )]
260    http2_keepalive_interval: Option<Duration>,
261    #[serde(rename = "keepalive_timeout_ms", with = "optional_duration_millis")]
262    keepalive_timeout: Option<Duration>,
263    keepalive_while_idle: bool,
264    #[serde(
265        rename = "discovery_health_interval_ms",
266        with = "optional_duration_millis"
267    )]
268    discovery_health_interval: Option<Duration>,
269    #[serde(
270        rename = "discovery_health_timeout_ms",
271        with = "optional_duration_millis"
272    )]
273    discovery_health_timeout: Option<Duration>,
274}
275
276impl Default for RpcClientConfig {
277    fn default() -> Self {
278        Self::new(String::new())
279    }
280}
281
282impl RpcClientConfig {
283    pub fn new(uri: impl Into<String>) -> Self {
284        Self {
285            uri: uri.into(),
286            request_timeout: None,
287            connect_timeout: None,
288            concurrency_limit: None,
289            tcp_keepalive: None,
290            http2_keepalive_interval: None,
291            keepalive_timeout: None,
292            keepalive_while_idle: false,
293            discovery_health_interval: None,
294            discovery_health_timeout: None,
295        }
296    }
297
298    pub fn with_request_timeout(mut self, timeout: Duration) -> Self {
299        assert!(
300            !timeout.is_zero(),
301            "request timeout must be greater than zero"
302        );
303        self.request_timeout = Some(timeout);
304        self
305    }
306
307    pub fn with_connect_timeout(mut self, timeout: Duration) -> Self {
308        assert!(
309            !timeout.is_zero(),
310            "connect timeout must be greater than zero"
311        );
312        self.connect_timeout = Some(timeout);
313        self
314    }
315
316    pub fn with_concurrency_limit(mut self, limit: usize) -> Self {
317        assert!(limit > 0, "concurrency limit must be greater than zero");
318        self.concurrency_limit = Some(limit);
319        self
320    }
321
322    pub fn with_tcp_keepalive(mut self, interval: Duration) -> Self {
323        assert!(
324            !interval.is_zero(),
325            "TCP keepalive interval must be greater than zero"
326        );
327        self.tcp_keepalive = Some(interval);
328        self
329    }
330
331    pub fn with_http2_keepalive(mut self, interval: Duration, timeout: Duration) -> Self {
332        assert!(
333            !interval.is_zero(),
334            "HTTP/2 keepalive interval must be greater than zero"
335        );
336        assert!(
337            !timeout.is_zero(),
338            "HTTP/2 keepalive timeout must be greater than zero"
339        );
340        self.http2_keepalive_interval = Some(interval);
341        self.keepalive_timeout = Some(timeout);
342        self
343    }
344
345    pub fn keepalive_while_idle(mut self, enabled: bool) -> Self {
346        self.keepalive_while_idle = enabled;
347        self
348    }
349
350    /// Enables periodic HTTP/2 connection probes for discovered endpoints.
351    pub fn with_discovery_health_check(mut self, interval: Duration, timeout: Duration) -> Self {
352        assert!(
353            !interval.is_zero(),
354            "discovery health interval must be positive"
355        );
356        assert!(
357            !timeout.is_zero(),
358            "discovery health timeout must be positive"
359        );
360        self.discovery_health_interval = Some(interval);
361        self.discovery_health_timeout = Some(timeout);
362        self
363    }
364
365    pub fn validate(&self) -> Result<(), RpcConfigError> {
366        if self.uri.trim().is_empty() {
367            return Err(RpcConfigError::Invalid("client URI must not be empty"));
368        }
369        for (name, duration) in [
370            ("request timeout", self.request_timeout),
371            ("connect timeout", self.connect_timeout),
372            ("TCP keepalive interval", self.tcp_keepalive),
373            ("HTTP/2 keepalive interval", self.http2_keepalive_interval),
374            ("HTTP/2 keepalive timeout", self.keepalive_timeout),
375            ("discovery health interval", self.discovery_health_interval),
376            ("discovery health timeout", self.discovery_health_timeout),
377        ] {
378            if duration.is_some_and(|duration| duration.is_zero()) {
379                return Err(RpcConfigError::Invalid(match name {
380                    "request timeout" => "request timeout must be greater than zero",
381                    "connect timeout" => "connect timeout must be greater than zero",
382                    "TCP keepalive interval" => "TCP keepalive interval must be greater than zero",
383                    "HTTP/2 keepalive interval" => {
384                        "HTTP/2 keepalive interval must be greater than zero"
385                    }
386                    "discovery health interval" => {
387                        "discovery health interval must be greater than zero"
388                    }
389                    "discovery health timeout" => {
390                        "discovery health timeout must be greater than zero"
391                    }
392                    _ => "HTTP/2 keepalive timeout must be greater than zero",
393                }));
394            }
395        }
396        if self.concurrency_limit == Some(0) {
397            return Err(RpcConfigError::Invalid(
398                "concurrency limit must be greater than zero",
399            ));
400        }
401        if self.http2_keepalive_interval.is_some() != self.keepalive_timeout.is_some() {
402            return Err(RpcConfigError::Invalid(
403                "HTTP/2 keepalive interval and timeout must be configured together",
404            ));
405        }
406        if self.discovery_health_interval.is_some() != self.discovery_health_timeout.is_some() {
407            return Err(RpcConfigError::Invalid(
408                "discovery health interval and timeout must be configured together",
409            ));
410        }
411        Ok(())
412    }
413}
414
415/// Aggregate availability exposed by a discovered RPC channel.
416#[derive(Debug, Clone, Copy, PartialEq, Eq)]
417pub enum DiscoveryReadiness {
418    Empty,
419    Ready,
420    Degraded,
421}
422
423#[derive(Debug, Clone, Copy, PartialEq, Eq)]
424pub struct DiscoveryStatusSnapshot {
425    pub readiness: DiscoveryReadiness,
426    pub discovered: usize,
427    pub available: usize,
428    pub rejected: usize,
429}
430
431impl DiscoveryStatusSnapshot {
432    pub fn is_ready(self) -> bool {
433        self.readiness == DiscoveryReadiness::Ready
434    }
435}
436
437/// Watchable readiness for a channel created from service discovery.
438#[derive(Debug, Clone)]
439pub struct DiscoveryStatus {
440    receiver: tokio::sync::watch::Receiver<DiscoveryStatusSnapshot>,
441}
442
443impl DiscoveryStatus {
444    pub fn snapshot(&self) -> DiscoveryStatusSnapshot {
445        *self.receiver.borrow()
446    }
447
448    pub async fn changed(
449        &mut self,
450    ) -> Result<DiscoveryStatusSnapshot, tokio::sync::watch::error::RecvError> {
451        self.receiver.changed().await?;
452        Ok(self.snapshot())
453    }
454
455    /// Projects channel readiness into the shared HTTP/dev-server health aggregate.
456    pub fn project_to_health(
457        mut self,
458        registry: HealthRegistry,
459        dependency: impl Into<String>,
460    ) -> tokio::task::JoinHandle<()> {
461        let dependency = dependency.into();
462        tokio::spawn(async move {
463            registry.set(&dependency, self.snapshot().is_ready());
464            while self.receiver.changed().await.is_ok() {
465                registry.set(&dependency, self.snapshot().is_ready());
466            }
467            registry.set(dependency, false);
468        })
469    }
470
471    /// Projects channel readiness into a standard gRPC health service entry.
472    pub fn project_to_grpc_health(
473        mut self,
474        mut reporter: HealthReporter,
475        service_name: impl Into<String>,
476    ) -> tokio::task::JoinHandle<()> {
477        let service_name = service_name.into();
478        tokio::spawn(async move {
479            loop {
480                let serving = if self.snapshot().is_ready() {
481                    tonic_health::ServingStatus::Serving
482                } else {
483                    tonic_health::ServingStatus::NotServing
484                };
485                reporter.set_service_status(&service_name, serving).await;
486                if self.receiver.changed().await.is_err() {
487                    reporter
488                        .set_service_status(&service_name, tonic_health::ServingStatus::NotServing)
489                        .await;
490                    return;
491                }
492            }
493        })
494    }
495}
496
497/// Connects gRPC clients using a common set of deadline and concurrency safeguards.
498#[derive(Debug, Clone)]
499pub struct RpcClient {
500    config: RpcClientConfig,
501}
502
503impl RpcClient {
504    pub fn new(config: RpcClientConfig) -> Self {
505        Self { config }
506    }
507
508    pub fn try_new(config: RpcClientConfig) -> Result<Self, RpcConfigError> {
509        config.validate()?;
510        Ok(Self { config })
511    }
512
513    pub fn config(&self) -> &RpcClientConfig {
514        &self.config
515    }
516
517    pub async fn connect(&self) -> Result<Channel, RpcClientError> {
518        self.config
519            .validate()
520            .map_err(RpcClientError::Configuration)?;
521        let endpoint = self.endpoint(self.config.uri.clone())?;
522        endpoint.connect().await.map_err(RpcClientError::Transport)
523    }
524
525    /// Creates a channel that follows a service registry and balances calls across its endpoints.
526    ///
527    /// Existing endpoints are installed before this method returns. A background task applies
528    /// subsequent publications and withdrawals; it stops automatically when all channel clones
529    /// have been dropped.
530    pub fn connect_service(
531        &self,
532        registry: &ServiceRegistry,
533        service: impl Into<String>,
534    ) -> Result<Channel, RpcClientError> {
535        let subscription = registry
536            .subscribe(service)
537            .map_err(RpcClientError::Discovery)?;
538        Ok(self.connect_discovered(subscription))
539    }
540
541    /// Creates a channel driven by any discovery backend that publishes complete snapshots.
542    ///
543    /// Empty snapshots are supported and recover when endpoints appear. Malformed endpoints are
544    /// ignored without poisoning the rest of a snapshot. The watcher exits when the discovery
545    /// stream closes or all clones of the returned channel have been dropped.
546    pub fn connect_discovered<S>(&self, subscription: S) -> Channel
547    where
548        S: EndpointSubscription,
549    {
550        self.connect_discovered_with_status(subscription).0
551    }
552
553    /// Creates a weighted discovery channel and a watchable readiness handle.
554    ///
555    /// Invalid endpoint URIs are excluded and make the snapshot degraded. When active health
556    /// checking is configured, failed probes are removed until a later probe succeeds.
557    pub fn connect_discovered_with_status<S>(
558        &self,
559        mut subscription: S,
560    ) -> (Channel, DiscoveryStatus)
561    where
562        S: EndpointSubscription,
563    {
564        let initial = subscription.discovered_endpoints();
565        let (configured, rejected) = self.configure_discovered(initial);
566
567        let capacity = configured
568            .values()
569            .map(|(_, endpoint)| endpoint.weight() as usize)
570            .sum::<usize>()
571            .max(128);
572        let (channel, changes) = Channel::balance_channel(capacity);
573        let mut installed = BTreeSet::new();
574        for (uri, (endpoint, discovered)) in &configured {
575            for slot in 0..discovered.weight() {
576                let key = weighted_key(uri, slot);
577                installed.insert(key.clone());
578                changes
579                    .try_send(Change::Insert(key, endpoint.clone()))
580                    .expect(
581                        "discovery channel is sized for its weighted initial endpoint snapshot",
582                    );
583            }
584        }
585        let initial_status = discovery_status(configured.len(), configured.len(), rejected);
586        let (status_updates, status_receiver) = tokio::sync::watch::channel(initial_status);
587
588        let client = self.clone();
589        tokio::spawn(async move {
590            let mut configured = configured;
591            let mut available: BTreeSet<String> = configured.keys().cloned().collect();
592            let mut rejected = rejected;
593            let mut health_ticks = client.discovery_health_ticks();
594            loop {
595                tokio::select! {
596                    _ = changes.closed() => return,
597                    snapshot = subscription.changed() => {
598                        if snapshot.is_err() {
599                            let mut closed = discovery_status(
600                                configured.len(),
601                                0,
602                                rejected,
603                            );
604                            closed.readiness = DiscoveryReadiness::Degraded;
605                            status_updates.send_replace(closed);
606                            return;
607                        }
608                        let (next, next_rejected) =
609                            client.configure_discovered(subscription.discovered_endpoints());
610                        configured = next;
611                        rejected = next_rejected;
612                        available.retain(|uri| configured.contains_key(uri));
613                        available.extend(configured.keys().cloned());
614                    }
615                    _ = health_ticks.tick(), if client.config.discovery_health_interval.is_some() => {
616                        available = client.probe_discovered(&configured).await;
617                    }
618                }
619
620                let desired = weighted_keys(&configured, &available);
621                for key in installed.difference(&desired).cloned().collect::<Vec<_>>() {
622                    if changes.send(Change::Remove(key.clone())).await.is_err() {
623                        return;
624                    }
625                    installed.remove(&key);
626                }
627                for key in desired.difference(&installed).cloned().collect::<Vec<_>>() {
628                    let Some((uri, _)) = key.rsplit_once('\0') else {
629                        continue;
630                    };
631                    let Some((endpoint, _)) = configured.get(uri) else {
632                        continue;
633                    };
634                    if changes
635                        .send(Change::Insert(key.clone(), endpoint.clone()))
636                        .await
637                        .is_err()
638                    {
639                        return;
640                    }
641                    installed.insert(key);
642                }
643                status_updates.send_replace(discovery_status(
644                    configured.len(),
645                    available.len(),
646                    rejected,
647                ));
648            }
649        });
650
651        (
652            channel,
653            DiscoveryStatus {
654                receiver: status_receiver,
655            },
656        )
657    }
658
659    fn configure_discovered(
660        &self,
661        endpoints: Vec<DiscoveredEndpoint>,
662    ) -> (BTreeMap<String, (Endpoint, DiscoveredEndpoint)>, usize) {
663        let discovered = endpoints.len();
664        let configured: BTreeMap<_, _> = endpoints
665            .into_iter()
666            .filter_map(|discovered| {
667                self.endpoint(discovered.uri().to_owned())
668                    .ok()
669                    .map(|endpoint| (discovered.uri().to_owned(), (endpoint, discovered)))
670            })
671            .collect();
672        let rejected = discovered.saturating_sub(configured.len());
673        (configured, rejected)
674    }
675
676    fn discovery_health_ticks(&self) -> tokio::time::Interval {
677        let interval = self
678            .config
679            .discovery_health_interval
680            .unwrap_or(Duration::from_secs(86_400));
681        let mut ticks = tokio::time::interval(interval);
682        ticks.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
683        ticks
684    }
685
686    async fn probe_discovered(
687        &self,
688        configured: &BTreeMap<String, (Endpoint, DiscoveredEndpoint)>,
689    ) -> BTreeSet<String> {
690        let timeout = self
691            .config
692            .discovery_health_timeout
693            .expect("probe timeout configured");
694        let probes = configured.iter().map(|(uri, (endpoint, _))| {
695            let uri = uri.clone();
696            let endpoint = endpoint.clone();
697            async move {
698                let healthy = tokio::time::timeout(timeout, endpoint.connect())
699                    .await
700                    .is_ok_and(|result| result.is_ok());
701                (uri, healthy)
702            }
703        });
704        futures::future::join_all(probes)
705            .await
706            .into_iter()
707            .filter_map(|(uri, healthy)| healthy.then_some(uri))
708            .collect()
709    }
710
711    fn endpoint(&self, uri: String) -> Result<Endpoint, RpcClientError> {
712        let mut endpoint = Endpoint::from_shared(uri).map_err(RpcClientError::Transport)?;
713
714        if let Some(timeout) = self.config.request_timeout {
715            endpoint = endpoint.timeout(timeout);
716        }
717        if let Some(timeout) = self.config.connect_timeout {
718            endpoint = endpoint.connect_timeout(timeout);
719        }
720        if let Some(limit) = self.config.concurrency_limit {
721            endpoint = endpoint.concurrency_limit(limit);
722        }
723        if let Some(interval) = self.config.tcp_keepalive {
724            endpoint = endpoint.tcp_keepalive(Some(interval));
725        }
726        if let Some(interval) = self.config.http2_keepalive_interval {
727            endpoint = endpoint.http2_keep_alive_interval(interval);
728        }
729        if let Some(timeout) = self.config.keepalive_timeout {
730            endpoint = endpoint.keep_alive_timeout(timeout);
731        }
732        endpoint = endpoint.keep_alive_while_idle(self.config.keepalive_while_idle);
733
734        Ok(endpoint)
735    }
736}
737
738fn weighted_key(uri: &str, slot: u32) -> String {
739    format!("{uri}\0{slot}")
740}
741
742fn weighted_keys(
743    configured: &BTreeMap<String, (Endpoint, DiscoveredEndpoint)>,
744    available: &BTreeSet<String>,
745) -> BTreeSet<String> {
746    configured
747        .iter()
748        .filter(|(uri, _)| available.contains(*uri))
749        .flat_map(|(uri, (_, endpoint))| {
750            (0..endpoint.weight()).map(move |slot| weighted_key(uri, slot))
751        })
752        .collect()
753}
754
755fn discovery_status(
756    discovered: usize,
757    available: usize,
758    rejected: usize,
759) -> DiscoveryStatusSnapshot {
760    let total = discovered + rejected;
761    let readiness = if total == 0 {
762        DiscoveryReadiness::Empty
763    } else if available == discovered && rejected == 0 {
764        DiscoveryReadiness::Ready
765    } else {
766        DiscoveryReadiness::Degraded
767    };
768    DiscoveryStatusSnapshot {
769        readiness,
770        discovered: total,
771        available,
772        rejected,
773    }
774}
775
776#[derive(Debug)]
777pub enum RpcClientError {
778    Configuration(RpcConfigError),
779    Transport(tonic::transport::Error),
780    Discovery(DiscoveryError),
781}
782
783impl fmt::Display for RpcClientError {
784    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
785        match self {
786            Self::Configuration(error) => write!(formatter, "invalid gRPC configuration: {error}"),
787            Self::Transport(error) => write!(formatter, "gRPC transport error: {error}"),
788            Self::Discovery(error) => write!(formatter, "gRPC service discovery error: {error}"),
789        }
790    }
791}
792
793impl StdError for RpcClientError {
794    fn source(&self) -> Option<&(dyn StdError + 'static)> {
795        match self {
796            Self::Configuration(error) => Some(error),
797            Self::Transport(error) => Some(error),
798            Self::Discovery(error) => Some(error),
799        }
800    }
801}
802
803#[derive(Debug, Clone, PartialEq, Eq)]
804pub enum RpcConfigError {
805    Invalid(&'static str),
806}
807
808impl fmt::Display for RpcConfigError {
809    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
810        match self {
811            Self::Invalid(message) => formatter.write_str(message),
812        }
813    }
814}
815
816impl StdError for RpcConfigError {}
817
818mod duration_millis {
819    use serde::{Deserialize, Deserializer, Serializer};
820    use std::time::Duration;
821
822    pub fn serialize<S>(value: &Duration, serializer: S) -> Result<S::Ok, S::Error>
823    where
824        S: Serializer,
825    {
826        serializer.serialize_u64(value.as_millis().try_into().unwrap_or(u64::MAX))
827    }
828
829    pub fn deserialize<'de, D>(deserializer: D) -> Result<Duration, D::Error>
830    where
831        D: Deserializer<'de>,
832    {
833        u64::deserialize(deserializer).map(Duration::from_millis)
834    }
835}
836
837mod optional_duration_millis {
838    use serde::{Deserialize, Deserializer, Serialize, Serializer};
839    use std::time::Duration;
840
841    pub fn serialize<S>(value: &Option<Duration>, serializer: S) -> Result<S::Ok, S::Error>
842    where
843        S: Serializer,
844    {
845        value
846            .map(|duration| duration.as_millis().try_into().unwrap_or(u64::MAX))
847            .serialize(serializer)
848    }
849
850    pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<Duration>, D::Error>
851    where
852        D: Deserializer<'de>,
853    {
854        Option::<u64>::deserialize(deserializer).map(|value| value.map(Duration::from_millis))
855    }
856}
857
858#[cfg(test)]
859mod tests {
860    use super::*;
861    use crate::echo::{
862        echo_client::EchoClient,
863        echo_server::{Echo, EchoServer},
864        EchoRequest, EchoResponse,
865    };
866    use futures::{Stream, StreamExt};
867    use std::{pin::Pin, sync::Arc};
868    use tokio::{
869        net::TcpListener,
870        sync::{oneshot, watch, Notify},
871    };
872    use tokio_stream::wrappers::TcpListenerStream;
873    use tonic::{Request, Response, Status};
874
875    #[test]
876    fn transport_configs_deserialize_millisecond_durations() {
877        let server: RpcServerConfig = rust_zero_core::parse_config(
878            "address = \"127.0.0.1:50052\"\nrequest_timeout_ms = 750\nshutdown_timeout_ms = 5000",
879            rust_zero_core::ConfigFormat::Toml,
880        )
881        .unwrap();
882        assert_eq!(server.request_timeout, Some(Duration::from_millis(750)));
883        assert_eq!(server.shutdown_timeout(), Duration::from_secs(5));
884        server.validate().unwrap();
885
886        let client: RpcClientConfig = rust_zero_core::parse_config(
887            "uri = \"http://127.0.0.1:50052\"\nconnect_timeout_ms = 250",
888            rust_zero_core::ConfigFormat::Toml,
889        )
890        .unwrap();
891        assert_eq!(client.connect_timeout, Some(Duration::from_millis(250)));
892        client.validate().unwrap();
893    }
894
895    #[test]
896    fn transport_configs_reject_incomplete_keepalive_settings() {
897        let client: RpcClientConfig = rust_zero_core::parse_config(
898            r#"{"uri":"http://localhost:50051","http2_keepalive_interval_ms":1000}"#,
899            rust_zero_core::ConfigFormat::Json,
900        )
901        .unwrap();
902        assert!(client.validate().is_err());
903    }
904
905    #[derive(Default)]
906    struct EchoService;
907
908    type EchoStream = Pin<Box<dyn Stream<Item = Result<EchoResponse, Status>> + Send>>;
909
910    #[tonic::async_trait]
911    impl Echo for EchoService {
912        type ServerStreamStream = EchoStream;
913        type BidirectionalStreamStream = EchoStream;
914
915        async fn echo(
916            &self,
917            request: Request<EchoRequest>,
918        ) -> Result<Response<EchoResponse>, Status> {
919            Ok(Response::new(EchoResponse {
920                message: request.into_inner().message,
921            }))
922        }
923
924        async fn server_stream(
925            &self,
926            request: Request<EchoRequest>,
927        ) -> Result<Response<Self::ServerStreamStream>, Status> {
928            Ok(Response::new(Box::pin(tokio_stream::iter([Ok(
929                EchoResponse {
930                    message: request.into_inner().message,
931                },
932            )]))))
933        }
934
935        async fn client_stream(
936            &self,
937            request: Request<tonic::Streaming<EchoRequest>>,
938        ) -> Result<Response<EchoResponse>, Status> {
939            let mut input = request.into_inner();
940            let mut messages = Vec::new();
941            while let Some(message) = input.message().await? {
942                messages.push(message.message);
943            }
944            Ok(Response::new(EchoResponse {
945                message: messages.join(","),
946            }))
947        }
948
949        async fn bidirectional_stream(
950            &self,
951            request: Request<tonic::Streaming<EchoRequest>>,
952        ) -> Result<Response<Self::BidirectionalStreamStream>, Status> {
953            let replies = futures::stream::unfold(request.into_inner(), |mut input| async move {
954                match input.message().await {
955                    Ok(Some(message)) => Some((
956                        Ok(EchoResponse {
957                            message: message.message,
958                        }),
959                        input,
960                    )),
961                    Err(status) => Some((Err(status), input)),
962                    Ok(None) => None,
963                }
964            });
965            Ok(Response::new(Box::pin(replies)))
966        }
967    }
968
969    struct NamedEchoService(&'static str);
970
971    #[tonic::async_trait]
972    impl Echo for NamedEchoService {
973        type ServerStreamStream = EchoStream;
974        type BidirectionalStreamStream = EchoStream;
975
976        async fn echo(&self, _: Request<EchoRequest>) -> Result<Response<EchoResponse>, Status> {
977            Ok(Response::new(EchoResponse {
978                message: self.0.to_owned(),
979            }))
980        }
981
982        async fn server_stream(
983            &self,
984            _: Request<EchoRequest>,
985        ) -> Result<Response<Self::ServerStreamStream>, Status> {
986            Ok(Response::new(Box::pin(tokio_stream::iter([Ok(
987                EchoResponse {
988                    message: self.0.to_owned(),
989                },
990            )]))))
991        }
992
993        async fn client_stream(
994            &self,
995            _: Request<tonic::Streaming<EchoRequest>>,
996        ) -> Result<Response<EchoResponse>, Status> {
997            Ok(Response::new(EchoResponse {
998                message: self.0.to_owned(),
999            }))
1000        }
1001
1002        async fn bidirectional_stream(
1003            &self,
1004            _: Request<tonic::Streaming<EchoRequest>>,
1005        ) -> Result<Response<Self::BidirectionalStreamStream>, Status> {
1006            Ok(Response::new(Box::pin(tokio_stream::iter([Ok(
1007                EchoResponse {
1008                    message: self.0.to_owned(),
1009                },
1010            )]))))
1011        }
1012    }
1013
1014    struct StackedEchoService;
1015
1016    #[tonic::async_trait]
1017    impl Echo for StackedEchoService {
1018        type ServerStreamStream = EchoStream;
1019        type BidirectionalStreamStream = EchoStream;
1020
1021        async fn echo(
1022            &self,
1023            request: Request<EchoRequest>,
1024        ) -> Result<Response<EchoResponse>, Status> {
1025            assert_eq!(
1026                request.extensions().get::<String>().map(String::as_str),
1027                Some("caller")
1028            );
1029            assert!(request
1030                .extensions()
1031                .get::<rust_zero_core::TraceContext>()
1032                .is_some());
1033            if request.get_ref().message == "panic" {
1034                panic!("intentional handler panic");
1035            }
1036            Ok(Response::new(EchoResponse {
1037                message: request.into_inner().message,
1038            }))
1039        }
1040
1041        async fn server_stream(
1042            &self,
1043            request: Request<EchoRequest>,
1044        ) -> Result<Response<Self::ServerStreamStream>, Status> {
1045            if let Some(error) = stack_extension_error(&request) {
1046                return Err(error);
1047            }
1048            let message = request.into_inner().message;
1049            let stream: EchoStream = match message.as_str() {
1050                "status" => Box::pin(tokio_stream::iter([
1051                    Ok(EchoResponse {
1052                        message: "first".to_owned(),
1053                    }),
1054                    Err(Status::unavailable("stream failed")),
1055                ])),
1056                "cancel" => Box::pin(
1057                    tokio_stream::once(Ok(EchoResponse {
1058                        message: "first".to_owned(),
1059                    }))
1060                    .chain(futures::stream::pending()),
1061                ),
1062                _ => Box::pin(tokio_stream::iter([Ok(EchoResponse { message })])),
1063            };
1064            Ok(Response::new(stream))
1065        }
1066
1067        async fn client_stream(
1068            &self,
1069            request: Request<tonic::Streaming<EchoRequest>>,
1070        ) -> Result<Response<EchoResponse>, Status> {
1071            if let Some(error) = stack_extension_error(&request) {
1072                return Err(error);
1073            }
1074            EchoService.client_stream(request).await
1075        }
1076
1077        async fn bidirectional_stream(
1078            &self,
1079            request: Request<tonic::Streaming<EchoRequest>>,
1080        ) -> Result<Response<Self::BidirectionalStreamStream>, Status> {
1081            if let Some(error) = stack_extension_error(&request) {
1082                return Err(error);
1083            }
1084            EchoService.bidirectional_stream(request).await
1085        }
1086    }
1087
1088    fn stack_extension_error<T>(request: &Request<T>) -> Option<Status> {
1089        if request.extensions().get::<String>().map(String::as_str) != Some("caller") {
1090            return Some(Status::internal(
1091                "authentication did not run before handler",
1092            ));
1093        }
1094        if request
1095            .extensions()
1096            .get::<rust_zero_core::TraceContext>()
1097            .is_none()
1098        {
1099            return Some(Status::internal("tracing did not run before handler"));
1100        }
1101        None
1102    }
1103
1104    struct DrainEchoService {
1105        entered: Arc<Notify>,
1106        release: Arc<Notify>,
1107    }
1108
1109    #[tonic::async_trait]
1110    impl Echo for DrainEchoService {
1111        type ServerStreamStream = EchoStream;
1112        type BidirectionalStreamStream = EchoStream;
1113
1114        async fn echo(
1115            &self,
1116            request: Request<EchoRequest>,
1117        ) -> Result<Response<EchoResponse>, Status> {
1118            self.entered.notify_one();
1119            self.release.notified().await;
1120            Ok(Response::new(EchoResponse {
1121                message: request.into_inner().message,
1122            }))
1123        }
1124
1125        async fn server_stream(
1126            &self,
1127            _: Request<EchoRequest>,
1128        ) -> Result<Response<Self::ServerStreamStream>, Status> {
1129            Err(Status::unimplemented("not used by drain tests"))
1130        }
1131
1132        async fn client_stream(
1133            &self,
1134            _: Request<tonic::Streaming<EchoRequest>>,
1135        ) -> Result<Response<EchoResponse>, Status> {
1136            Err(Status::unimplemented("not used by drain tests"))
1137        }
1138
1139        async fn bidirectional_stream(
1140            &self,
1141            _: Request<tonic::Streaming<EchoRequest>>,
1142        ) -> Result<Response<Self::BidirectionalStreamStream>, Status> {
1143            Err(Status::unimplemented("not used by drain tests"))
1144        }
1145    }
1146
1147    struct TestSubscription {
1148        receiver: watch::Receiver<Vec<String>>,
1149        dropped: Option<oneshot::Sender<()>>,
1150    }
1151
1152    impl EndpointSubscription for TestSubscription {
1153        type Error = watch::error::RecvError;
1154
1155        fn endpoints(&self) -> Vec<String> {
1156            self.receiver.borrow().clone()
1157        }
1158
1159        fn changed(&mut self) -> rust_zero_core::EndpointChangeFuture<'_, Self::Error> {
1160            Box::pin(async move {
1161                self.receiver.changed().await?;
1162                Ok(self.receiver.borrow().clone())
1163            })
1164        }
1165    }
1166
1167    impl Drop for TestSubscription {
1168        fn drop(&mut self) {
1169            if let Some(dropped) = self.dropped.take() {
1170                let _ = dropped.send(());
1171            }
1172        }
1173    }
1174
1175    #[test]
1176    fn server_configuration_preserves_address_and_limits() {
1177        let address = "127.0.0.1:50051".parse().unwrap();
1178        let config = RpcServerConfig::new(address)
1179            .with_request_timeout(Duration::from_secs(2))
1180            .with_concurrency_limit(32)
1181            .with_max_concurrent_streams(16);
1182
1183        assert_eq!(config.address(), address);
1184        assert_eq!(config.request_timeout, Some(Duration::from_secs(2)));
1185        assert_eq!(config.concurrency_limit, Some(32));
1186        assert_eq!(config.max_concurrent_streams, Some(16));
1187    }
1188
1189    #[tokio::test]
1190    async fn invalid_client_uri_is_reported() {
1191        let client = RpcClient::new(RpcClientConfig::new("not a URI"));
1192        let error = client.connect().await.unwrap_err();
1193
1194        assert!(matches!(error, RpcClientError::Transport(_)));
1195    }
1196
1197    #[tokio::test]
1198    async fn client_and_server_complete_unary_call() {
1199        use std::sync::Arc;
1200        use tower::Layer;
1201
1202        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1203        let address = listener.local_addr().unwrap();
1204        let metrics = Arc::new(rust_zero_core::Metrics::new());
1205        let server_metrics = RpcMetrics::new(
1206            metrics.as_ref(),
1207            "echo",
1208            RpcMetricMode::Server,
1209            ["/rust_zero.echo.Echo/Echo"],
1210        )
1211        .unwrap();
1212        let server = RpcServer::new(
1213            RpcServerConfig::new(address)
1214                .with_request_timeout(Duration::from_secs(1))
1215                .with_concurrency_limit(8),
1216        );
1217        let server_task = tokio::spawn(async move {
1218            server
1219                .router()
1220                .layer(RpcMetricsLayer::new(server_metrics))
1221                .add_service(EchoServer::new(EchoService))
1222                .serve_with_incoming(TcpListenerStream::new(listener))
1223                .await
1224                .unwrap();
1225        });
1226
1227        let channel = RpcClient::new(
1228            RpcClientConfig::new(format!("http://{address}"))
1229                .with_connect_timeout(Duration::from_secs(1))
1230                .with_request_timeout(Duration::from_secs(1)),
1231        )
1232        .connect()
1233        .await
1234        .unwrap();
1235        let client_metrics = RpcMetrics::new(
1236            metrics.as_ref(),
1237            "echo",
1238            RpcMetricMode::Client,
1239            ["/rust_zero.echo.Echo/Echo"],
1240        )
1241        .unwrap();
1242        let client_stack = RpcClientStackBuilder::new(client_metrics)
1243            .with_default_timeout(Duration::from_secs(1))
1244            .with_circuit_breaker(rust_zero_core::CircuitBreakerConfig::new(
1245                3,
1246                Duration::from_secs(30),
1247            ))
1248            .build();
1249        let response = EchoClient::new(client_stack.layer(channel))
1250            .echo(Request::new(EchoRequest {
1251                message: "hello".to_owned(),
1252            }))
1253            .await
1254            .unwrap();
1255
1256        assert_eq!(response.into_inner().message, "hello");
1257        let rendered = metrics.render();
1258        assert!(rendered.contains(
1259            "echo_rpc_server_requests_total{method=\"/rust_zero.echo.Echo/Echo\",code=\"0\"} 1"
1260        ));
1261        assert!(rendered.contains(
1262            "echo_rpc_client_requests_total{method=\"/rust_zero.echo.Echo/Echo\",code=\"0\"} 1"
1263        ));
1264        server_task.abort();
1265    }
1266
1267    async fn connect_eventually(address: SocketAddr) -> Channel {
1268        let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
1269        loop {
1270            if let Ok(channel) = RpcClient::new(
1271                RpcClientConfig::new(format!("http://{address}"))
1272                    .with_connect_timeout(Duration::from_millis(100)),
1273            )
1274            .connect()
1275            .await
1276            {
1277                return channel;
1278            }
1279            assert!(
1280                tokio::time::Instant::now() < deadline,
1281                "gRPC server did not start listening"
1282            );
1283            tokio::time::sleep(Duration::from_millis(10)).await;
1284        }
1285    }
1286
1287    #[tokio::test]
1288    async fn configured_server_drains_in_flight_calls_and_bounds_shutdown() {
1289        let reservation = TcpListener::bind("127.0.0.1:0").await.unwrap();
1290        let address = reservation.local_addr().unwrap();
1291        drop(reservation);
1292
1293        let entered = Arc::new(Notify::new());
1294        let release = Arc::new(Notify::new());
1295        let server = RpcServer::new(
1296            RpcServerConfig::new(address).with_shutdown_timeout(Duration::from_secs(1)),
1297        );
1298        let router = server
1299            .router()
1300            .add_service(EchoServer::new(DrainEchoService {
1301                entered: Arc::clone(&entered),
1302                release: Arc::clone(&release),
1303            }));
1304        let (shutdown, shutdown_signal) = oneshot::channel();
1305        let server_task = tokio::spawn(async move {
1306            server
1307                .serve_with_shutdown(router, async {
1308                    let _ = shutdown_signal.await;
1309                })
1310                .await
1311        });
1312
1313        let channel = connect_eventually(address).await;
1314        let call = tokio::spawn(async move {
1315            EchoClient::new(channel)
1316                .echo(EchoRequest {
1317                    message: "drained".to_owned(),
1318                })
1319                .await
1320        });
1321        entered.notified().await;
1322        shutdown.send(()).unwrap();
1323        tokio::task::yield_now().await;
1324        assert!(
1325            !server_task.is_finished(),
1326            "server must wait for an in-flight call"
1327        );
1328        release.notify_one();
1329        assert_eq!(call.await.unwrap().unwrap().into_inner().message, "drained");
1330        tokio::time::timeout(Duration::from_secs(1), server_task)
1331            .await
1332            .expect("server should finish after its in-flight call")
1333            .unwrap()
1334            .unwrap();
1335
1336        let reservation = TcpListener::bind("127.0.0.1:0").await.unwrap();
1337        let address = reservation.local_addr().unwrap();
1338        drop(reservation);
1339        let entered = Arc::new(Notify::new());
1340        let release = Arc::new(Notify::new());
1341        let server = RpcServer::new(
1342            RpcServerConfig::new(address).with_shutdown_timeout(Duration::from_millis(50)),
1343        );
1344        let router = server
1345            .router()
1346            .add_service(EchoServer::new(DrainEchoService {
1347                entered: Arc::clone(&entered),
1348                release: Arc::clone(&release),
1349            }));
1350        let (shutdown, shutdown_signal) = oneshot::channel();
1351        let server_task = tokio::spawn(async move {
1352            server
1353                .serve_with_shutdown(router, async {
1354                    let _ = shutdown_signal.await;
1355                })
1356                .await
1357        });
1358        let channel = connect_eventually(address).await;
1359        let call = tokio::spawn(async move {
1360            EchoClient::new(channel)
1361                .echo(EchoRequest {
1362                    message: "too-slow".to_owned(),
1363                })
1364                .await
1365        });
1366        entered.notified().await;
1367        shutdown.send(()).unwrap();
1368        assert!(matches!(
1369            server_task.await.unwrap(),
1370            Err(RpcServerError::ShutdownTimeout)
1371        ));
1372        release.notify_one();
1373        let _ = call.await;
1374    }
1375
1376    #[tokio::test]
1377    async fn standard_server_stack_composes_auth_trace_metrics_recovery_and_health() {
1378        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1379        let address = listener.local_addr().unwrap();
1380        let registry = Arc::new(rust_zero_core::Metrics::new());
1381        let metrics = RpcMetrics::new(
1382            registry.as_ref(),
1383            "stacked",
1384            RpcMetricMode::Server,
1385            [
1386                "/rust_zero.echo.Echo/Echo",
1387                "/rust_zero.echo.Echo/ServerStream",
1388                "/rust_zero.echo.Echo/ClientStream",
1389                "/rust_zero.echo.Echo/BidirectionalStream",
1390            ],
1391        )
1392        .unwrap();
1393        let stack = RpcServerStackBuilder::new(metrics)
1394            .with_bearer_auth(|token| (token == "secret").then(|| "caller".to_owned()))
1395            .with_load_shedder(rust_zero_core::LoadShedderConfig::new(
1396                8,
1397                Duration::from_secs(1),
1398            ))
1399            .build();
1400        let (mut reporter, health) = health_reporter();
1401        reporter
1402            .set_serving::<EchoServer<StackedEchoService>>()
1403            .await;
1404        let server = tokio::spawn(async move {
1405            Server::builder()
1406                .layer(stack)
1407                .add_service(health)
1408                .add_service(EchoServer::new(StackedEchoService))
1409                .serve_with_incoming(TcpListenerStream::new(listener))
1410                .await
1411                .unwrap();
1412        });
1413
1414        let channel = RpcClient::new(RpcClientConfig::new(format!("http://{address}")))
1415            .connect()
1416            .await
1417            .unwrap();
1418        let error = EchoClient::new(channel.clone())
1419            .echo(EchoRequest {
1420                message: "denied".into(),
1421            })
1422            .await
1423            .unwrap_err();
1424        assert_eq!(error.code(), tonic::Code::Unauthenticated);
1425
1426        let mut client =
1427            EchoClient::with_interceptor(channel.clone(), BearerToken::new("secret").unwrap());
1428        let response = client
1429            .echo(EchoRequest {
1430                message: "accepted".into(),
1431            })
1432            .await
1433            .unwrap();
1434        assert_eq!(response.into_inner().message, "accepted");
1435        let error = client
1436            .echo(EchoRequest {
1437                message: "panic".into(),
1438            })
1439            .await
1440            .unwrap_err();
1441        assert_eq!(error.code(), tonic::Code::Internal);
1442        let response = client
1443            .echo(EchoRequest {
1444                message: "still-serving".into(),
1445            })
1446            .await
1447            .unwrap();
1448        assert_eq!(response.into_inner().message, "still-serving");
1449
1450        let mut health_client = tonic_health::pb::health_client::HealthClient::with_interceptor(
1451            channel.clone(),
1452            BearerToken::new("secret").unwrap(),
1453        );
1454        let health_request = || tonic_health::pb::HealthCheckRequest {
1455            service: "rust_zero.echo.Echo".to_owned(),
1456        };
1457        assert_eq!(
1458            health_client
1459                .check(health_request())
1460                .await
1461                .unwrap()
1462                .into_inner()
1463                .status,
1464            tonic_health::pb::health_check_response::ServingStatus::Serving as i32
1465        );
1466        reporter
1467            .set_not_serving::<EchoServer<StackedEchoService>>()
1468            .await;
1469        assert_eq!(
1470            health_client
1471                .check(health_request())
1472                .await
1473                .unwrap()
1474                .into_inner()
1475                .status,
1476            tonic_health::pb::health_check_response::ServingStatus::NotServing as i32
1477        );
1478
1479        let client_metrics = RpcMetrics::new(
1480            registry.as_ref(),
1481            "stacked",
1482            RpcMetricMode::Client,
1483            [
1484                "/rust_zero.echo.Echo/Echo",
1485                "/rust_zero.echo.Echo/ServerStream",
1486                "/rust_zero.echo.Echo/ClientStream",
1487                "/rust_zero.echo.Echo/BidirectionalStream",
1488            ],
1489        )
1490        .unwrap();
1491        let client_stack = RpcClientStackBuilder::new(client_metrics)
1492            .with_bearer_token(BearerToken::new("secret").unwrap())
1493            .with_default_timeout(Duration::from_secs(2))
1494            .with_circuit_breaker(rust_zero_core::CircuitBreakerConfig::new(
1495                1,
1496                Duration::from_secs(30),
1497            ))
1498            .build();
1499        let mut client = EchoClient::new(tower::Layer::layer(&client_stack, channel));
1500
1501        let client_stream =
1502            tokio_stream::iter(["one", "two", "three"].map(|message| EchoRequest {
1503                message: message.to_owned(),
1504            }));
1505        assert_eq!(
1506            client
1507                .client_stream(client_stream)
1508                .await
1509                .unwrap()
1510                .into_inner()
1511                .message,
1512            "one,two,three"
1513        );
1514
1515        let bidi_input = tokio_stream::iter(["left", "right"].map(|message| EchoRequest {
1516            message: message.to_owned(),
1517        }));
1518        let mut bidi = client
1519            .bidirectional_stream(bidi_input)
1520            .await
1521            .unwrap()
1522            .into_inner();
1523        assert_eq!(bidi.message().await.unwrap().unwrap().message, "left");
1524        assert_eq!(bidi.message().await.unwrap().unwrap().message, "right");
1525        assert!(bidi.message().await.unwrap().is_none());
1526
1527        let mut cancelled = client
1528            .server_stream(EchoRequest {
1529                message: "cancel".to_owned(),
1530            })
1531            .await
1532            .unwrap()
1533            .into_inner();
1534        assert_eq!(cancelled.message().await.unwrap().unwrap().message, "first");
1535        drop(cancelled);
1536        tokio::time::timeout(Duration::from_secs(1), async {
1537            loop {
1538                let metrics = registry.render();
1539                if metrics.contains(
1540                    "stacked_rpc_client_requests_total{method=\"/rust_zero.echo.Echo/ServerStream\",code=\"cancelled\"} 1",
1541                ) && metrics.contains(
1542                    "stacked_rpc_server_requests_total{method=\"/rust_zero.echo.Echo/ServerStream\",code=\"cancelled\"} 1",
1543                ) {
1544                    break;
1545                }
1546                tokio::task::yield_now().await;
1547            }
1548        })
1549        .await
1550        .expect("dropping a response stream should record client cancellation");
1551
1552        let mut failed = client
1553            .server_stream(EchoRequest {
1554                message: "status".to_owned(),
1555            })
1556            .await
1557            .unwrap()
1558            .into_inner();
1559        assert_eq!(failed.message().await.unwrap().unwrap().message, "first");
1560        assert_eq!(
1561            failed.message().await.unwrap_err().code(),
1562            tonic::Code::Unavailable
1563        );
1564        let rejected = client
1565            .echo(EchoRequest {
1566                message: "circuit-open".to_owned(),
1567            })
1568            .await
1569            .unwrap_err();
1570        assert_eq!(rejected.code(), tonic::Code::Unavailable);
1571
1572        assert!(registry.render().contains(
1573            "stacked_rpc_server_requests_total{method=\"/rust_zero.echo.Echo/Echo\",code=\"0\"} 2"
1574        ));
1575        assert!(registry.render().contains(
1576            "stacked_rpc_client_requests_total{method=\"/rust_zero.echo.Echo/ServerStream\",code=\"14\"} 1"
1577        ));
1578        assert!(registry.render().contains(
1579            "stacked_rpc_server_requests_total{method=\"/rust_zero.echo.Echo/ServerStream\",code=\"14\"} 1"
1580        ));
1581        server.abort();
1582    }
1583
1584    #[tokio::test]
1585    async fn discovered_client_tracks_published_rpc_endpoints() {
1586        let first_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1587        let first_address = first_listener.local_addr().unwrap();
1588        let first_server = tokio::spawn(async move {
1589            Server::builder()
1590                .add_service(EchoServer::new(EchoService))
1591                .serve_with_incoming(TcpListenerStream::new(first_listener))
1592                .await
1593                .unwrap();
1594        });
1595
1596        let registry = ServiceRegistry::new();
1597        let channel = RpcClient::new(RpcClientConfig::new("http://unused"))
1598            .connect_service(&registry, "echo")
1599            .unwrap();
1600        let first_lease = registry
1601            .publish("echo", format!("http://{first_address}"))
1602            .unwrap();
1603        let response = EchoClient::new(channel.clone())
1604            .echo(Request::new(EchoRequest {
1605                message: "discovered".to_owned(),
1606            }))
1607            .await
1608            .unwrap();
1609        assert_eq!(response.into_inner().message, "discovered");
1610
1611        drop(first_lease);
1612        drop(channel);
1613        first_server.abort();
1614    }
1615
1616    #[tokio::test]
1617    async fn generic_discovery_recovers_from_empty_and_malformed_snapshots() {
1618        let first_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1619        let first_address = first_listener.local_addr().unwrap();
1620        let first_server = tokio::spawn(async move {
1621            Server::builder()
1622                .add_service(EchoServer::new(NamedEchoService("first")))
1623                .serve_with_incoming(TcpListenerStream::new(first_listener))
1624                .await
1625                .unwrap();
1626        });
1627        let second_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1628        let second_address = second_listener.local_addr().unwrap();
1629        let second_server = tokio::spawn(async move {
1630            Server::builder()
1631                .add_service(EchoServer::new(NamedEchoService("second")))
1632                .serve_with_incoming(TcpListenerStream::new(second_listener))
1633                .await
1634                .unwrap();
1635        });
1636
1637        let (updates, receiver) = watch::channel(Vec::new());
1638        let channel = RpcClient::new(RpcClientConfig::new("http://unused")).connect_discovered(
1639            TestSubscription {
1640                receiver,
1641                dropped: None,
1642            },
1643        );
1644        updates.send_replace(vec![
1645            "not a URI".to_owned(),
1646            format!("http://{first_address}"),
1647        ]);
1648        let response = EchoClient::new(channel.clone())
1649            .echo(Request::new(EchoRequest::default()))
1650            .await
1651            .unwrap();
1652        assert_eq!(response.into_inner().message, "first");
1653
1654        updates.send_replace(vec![format!("http://{second_address}")]);
1655        let message = tokio::time::timeout(Duration::from_secs(2), async {
1656            loop {
1657                let result = EchoClient::new(channel.clone())
1658                    .echo(Request::new(EchoRequest::default()))
1659                    .await;
1660                if let Ok(response) = result {
1661                    if response.get_ref().message == "second" {
1662                        break response.into_inner().message;
1663                    }
1664                }
1665                tokio::task::yield_now().await;
1666            }
1667        })
1668        .await
1669        .unwrap();
1670        assert_eq!(message, "second");
1671
1672        drop(channel);
1673        first_server.abort();
1674        second_server.abort();
1675    }
1676
1677    #[tokio::test]
1678    async fn discovery_watcher_stops_when_channel_is_dropped() {
1679        let (_updates, receiver) = watch::channel(Vec::new());
1680        let (dropped, stopped) = oneshot::channel();
1681        let channel = RpcClient::new(RpcClientConfig::new("http://unused")).connect_discovered(
1682            TestSubscription {
1683                receiver,
1684                dropped: Some(dropped),
1685            },
1686        );
1687
1688        drop(channel);
1689        tokio::time::timeout(Duration::from_secs(1), stopped)
1690            .await
1691            .expect("discovery watcher should stop")
1692            .expect("drop notification should be delivered");
1693    }
1694
1695    #[tokio::test]
1696    async fn active_discovery_health_marks_failed_endpoints_degraded() {
1697        let healthy_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1698        let healthy_address = healthy_listener.local_addr().unwrap();
1699        let healthy_server = tokio::spawn(async move {
1700            Server::builder()
1701                .add_service(EchoServer::new(EchoService))
1702                .serve_with_incoming(TcpListenerStream::new(healthy_listener))
1703                .await
1704                .unwrap();
1705        });
1706        let unavailable_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1707        let unavailable_address = unavailable_listener.local_addr().unwrap();
1708        drop(unavailable_listener);
1709
1710        let (_updates, receiver) = watch::channel(vec![
1711            format!("http://{healthy_address}"),
1712            format!("http://{unavailable_address}"),
1713        ]);
1714        let config = RpcClientConfig::new("http://unused")
1715            .with_discovery_health_check(Duration::from_millis(20), Duration::from_millis(100));
1716        let (channel, mut status) =
1717            RpcClient::new(config).connect_discovered_with_status(TestSubscription {
1718                receiver,
1719                dropped: None,
1720            });
1721
1722        let snapshot = tokio::time::timeout(Duration::from_secs(2), async {
1723            loop {
1724                let snapshot = status.changed().await.unwrap();
1725                if snapshot.readiness == DiscoveryReadiness::Degraded {
1726                    break snapshot;
1727                }
1728            }
1729        })
1730        .await
1731        .unwrap();
1732        assert_eq!(snapshot.discovered, 2);
1733        assert_eq!(snapshot.available, 1);
1734
1735        let recovered_listener = TcpListener::bind(unavailable_address).await.unwrap();
1736        let recovered_server = tokio::spawn(async move {
1737            Server::builder()
1738                .add_service(EchoServer::new(EchoService))
1739                .serve_with_incoming(TcpListenerStream::new(recovered_listener))
1740                .await
1741                .unwrap();
1742        });
1743        let recovered = tokio::time::timeout(Duration::from_secs(2), async {
1744            loop {
1745                let snapshot = status.changed().await.unwrap();
1746                if snapshot.readiness == DiscoveryReadiness::Ready {
1747                    break snapshot;
1748                }
1749            }
1750        })
1751        .await
1752        .unwrap();
1753        assert_eq!(recovered.available, 2);
1754
1755        drop(channel);
1756        healthy_server.abort();
1757        recovered_server.abort();
1758    }
1759
1760    #[tokio::test]
1761    async fn discovery_status_projects_into_shared_health() {
1762        let (updates, receiver) = watch::channel(DiscoveryStatusSnapshot {
1763            readiness: DiscoveryReadiness::Empty,
1764            discovered: 0,
1765            available: 0,
1766            rejected: 0,
1767        });
1768        let registry = HealthRegistry::new();
1769        let mut health_updates = registry.subscribe();
1770        let task = DiscoveryStatus { receiver }.project_to_health(registry.clone(), "users-rpc");
1771        health_updates.changed().await.unwrap();
1772        assert_eq!(registry.snapshot().unhealthy(), vec!["users-rpc"]);
1773
1774        updates
1775            .send(DiscoveryStatusSnapshot {
1776                readiness: DiscoveryReadiness::Ready,
1777                discovered: 1,
1778                available: 1,
1779                rejected: 0,
1780            })
1781            .unwrap();
1782        health_updates.changed().await.unwrap();
1783        assert!(registry.snapshot().is_ready());
1784        task.abort();
1785    }
1786
1787    #[test]
1788    fn discovered_client_rejects_invalid_service_names() {
1789        let registry = ServiceRegistry::new();
1790        let client = RpcClient::new(RpcClientConfig::new("http://unused"));
1791
1792        assert!(matches!(
1793            client.connect_service(&registry, ""),
1794            Err(RpcClientError::Discovery(DiscoveryError::EmptyService))
1795        ));
1796    }
1797
1798    #[test]
1799    fn weighted_discovery_keys_preserve_relative_capacity() {
1800        let client = RpcClient::new(RpcClientConfig::new("http://unused"));
1801        let (configured, rejected) = client.configure_discovered(vec![
1802            DiscoveredEndpoint::weighted("http://one:8080", 3).unwrap(),
1803            DiscoveredEndpoint::weighted("http://two:8080", 1).unwrap(),
1804            DiscoveredEndpoint::new("not a URI").unwrap(),
1805        ]);
1806        let available = configured.keys().cloned().collect();
1807        let keys = weighted_keys(&configured, &available);
1808
1809        assert_eq!(keys.len(), 4);
1810        assert_eq!(rejected, 1);
1811        assert_eq!(
1812            discovery_status(configured.len(), configured.len(), rejected),
1813            DiscoveryStatusSnapshot {
1814                readiness: DiscoveryReadiness::Degraded,
1815                discovered: 3,
1816                available: 2,
1817                rejected: 1,
1818            }
1819        );
1820    }
1821
1822    #[test]
1823    fn discovery_status_distinguishes_empty_ready_and_degraded() {
1824        assert_eq!(
1825            discovery_status(0, 0, 0).readiness,
1826            DiscoveryReadiness::Empty
1827        );
1828        assert_eq!(
1829            discovery_status(2, 2, 0).readiness,
1830            DiscoveryReadiness::Ready
1831        );
1832        assert_eq!(
1833            discovery_status(2, 1, 0).readiness,
1834            DiscoveryReadiness::Degraded
1835        );
1836    }
1837}