Skip to main content

linera_rpc/grpc/
client.rs

1// Copyright (c) Zefchain Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4use std::{
5    collections::BTreeSet,
6    fmt,
7    future::Future,
8    iter,
9    sync::{
10        atomic::{AtomicU32, Ordering},
11        Arc,
12    },
13};
14
15use futures::{future, stream, StreamExt};
16use linera_base::{
17    crypto::CryptoHash,
18    data_types::{BlobContent, BlockHeight, NetworkDescription},
19    ensure,
20    identifiers::{BlobId, ChainId, EventId},
21    time::{Duration, Instant},
22};
23use linera_chain::{
24    data_types::{self},
25    types::{
26        self, Certificate, ConfirmedBlock, ConfirmedBlockCertificate, GenericCertificate,
27        LiteCertificate, Timeout, ValidatedBlockCertificate,
28    },
29};
30#[cfg(with_metrics)]
31pub(crate) mod metrics {
32    use linera_base::prometheus_util::register_int_counter_vec;
33    use prometheus::IntCounterVec;
34
35    linera_base::declare_metrics! {
36        pub static VALIDATOR_SUBSCRIPTION_ERRORS: IntCounterVec =
37            register_int_counter_vec(
38                "validator_subscription_errors",
39                "Number of notification subscription stream errors per validator",
40                &["address"],
41            );
42    }
43}
44
45use linera_core::{
46    data_types::{CertificatesByHeightRequest, ChainInfoResponse},
47    node::{BlobStream, CrossChainMessageDelivery, NodeError, NotificationStream, ValidatorNode},
48    worker::Notification,
49};
50use linera_storage::Arc as CacheArc;
51use linera_version::VersionInfo;
52use tonic::{Code, IntoRequest, Request, Status};
53use tracing::{debug, instrument, trace, Level};
54
55use super::{
56    api::{self, validator_node_client::ValidatorNodeClient, SubscriptionRequest},
57    transport, GRPC_MAX_MESSAGE_SIZE,
58};
59#[cfg(feature = "opentelemetry")]
60use crate::propagation::{get_context_with_traffic_type, inject_context};
61use crate::{
62    grpc::api::RawCertificate, HandleConfirmedCertificateRequest, HandleLiteCertRequest,
63    HandleTimeoutCertificateRequest, HandleValidatedCertificateRequest,
64};
65
66/// The text tonic puts before the HTTP status when it fails to decode a non-gRPC response body.
67const RECEIVED_STATUS_PREFIX: &str = "while receiving response with status: ";
68
69/// Recovers the HTTP status from a proxy's non-gRPC response, which tonic reports as
70/// `Code::Internal` instead of applying its HTTP-to-gRPC status mapping (tonic#2365).
71fn proxy_http_status(message: &str) -> Option<u16> {
72    message
73        .rsplit_once(RECEIVED_STATUS_PREFIX)
74        .and_then(|(_, rest)| rest.split_whitespace().next())
75        .and_then(|code| code.parse().ok())
76}
77
78fn is_server_error(status: u16) -> bool {
79    (500..600).contains(&status)
80}
81
82/// A gRPC client for communicating with a validator node.
83#[derive(Clone)]
84pub struct GrpcClient {
85    address: String,
86    client: ValidatorNodeClient<transport::Channel>,
87    retry_delay: Duration,
88    max_retries: u32,
89    max_backoff: Duration,
90    /// Shared across all `GrpcClient` instances created by the same `GrpcNodeProvider`.
91    /// Tracks when each validator address last had a subscription failure, so that
92    /// other chains don't independently retry the same dead validator.
93    subscription_cooldowns: Arc<papaya::HashMap<String, Instant>>,
94}
95
96impl GrpcClient {
97    /// Creates a new gRPC client for the validator at the given address.
98    pub fn new(
99        address: String,
100        channel: transport::Channel,
101        retry_delay: Duration,
102        max_retries: u32,
103        max_backoff: Duration,
104        subscription_cooldowns: Arc<papaya::HashMap<String, Instant>>,
105    ) -> Self {
106        let client = ValidatorNodeClient::new(channel)
107            .max_encoding_message_size(GRPC_MAX_MESSAGE_SIZE)
108            .max_decoding_message_size(GRPC_MAX_MESSAGE_SIZE);
109        Self {
110            address,
111            client,
112            retry_delay,
113            max_retries,
114            max_backoff,
115            subscription_cooldowns,
116        }
117    }
118
119    /// Returns the address of the validator this client connects to.
120    pub fn address(&self) -> &str {
121        &self.address
122    }
123
124    /// Returns whether this gRPC status means the server stream should be reconnected to, or not.
125    /// Logs a warning on unexpected status codes.
126    fn is_retryable(status: &Status) -> bool {
127        match status.code() {
128            Code::DeadlineExceeded | Code::Aborted | Code::Unavailable | Code::Unknown => {
129                trace!("gRPC request interrupted: {status:?}; retrying");
130                true
131            }
132            Code::Ok | Code::Cancelled | Code::ResourceExhausted => {
133                trace!("Unexpected gRPC status: {status:?}; retrying");
134                true
135            }
136            Code::Internal if status.message().contains("h2 protocol error") => {
137                // HTTP/2 connection reset errors are transient network issues, not real
138                // internal errors. This happens when the server restarts and the
139                // connection is forcibly closed.
140                trace!("gRPC connection reset: {status:?}; retrying");
141                true
142            }
143            Code::Internal if proxy_http_status(status.message()).is_some_and(is_server_error) => {
144                // Retry the whole 5xx class: enumerating reason phrases is what let 503 regress
145                // after 502 was special-cased. tonic maps 502/503/504 to UNAVAILABLE and other
146                // 5xx to UNKNOWN, but only when it can parse the body — tonic#2365 is when it
147                // cannot.
148                trace!("gRPC proxy error: {status:?}; retrying");
149                true
150            }
151            Code::NotFound => false, // This code is used if e.g. the validator is missing blobs.
152            Code::InvalidArgument
153            | Code::AlreadyExists
154            | Code::PermissionDenied
155            | Code::FailedPrecondition
156            | Code::OutOfRange
157            | Code::Unimplemented
158            | Code::Internal
159            | Code::DataLoss
160            | Code::Unauthenticated => {
161                trace!("Unexpected gRPC status: {status:?}");
162                false
163            }
164        }
165    }
166
167    async fn delegate<F, Fut, R, S>(
168        &self,
169        f: F,
170        request: impl TryInto<R> + fmt::Debug + Clone,
171        handler: &str,
172    ) -> Result<S, NodeError>
173    where
174        F: Fn(ValidatorNodeClient<transport::Channel>, Request<R>) -> Fut,
175        Fut: Future<Output = Result<tonic::Response<S>, Status>>,
176        R: IntoRequest<R> + Clone,
177    {
178        let mut retry_count = 0;
179        let request_inner = request.try_into().map_err(|_| NodeError::GrpcError {
180            error: "could not convert request to proto".to_string(),
181        })?;
182        loop {
183            #[allow(unused_mut)]
184            let mut request = Request::new(request_inner.clone());
185            // Inject OpenTelemetry context (trace context + baggage) into gRPC metadata.
186            // This uses get_context_with_traffic_type() to also check the LINERA_TRAFFIC_TYPE
187            // environment variable, allowing benchmark tools to mark their traffic as synthetic.
188            #[cfg(feature = "opentelemetry")]
189            inject_context(&get_context_with_traffic_type(), request.metadata_mut());
190            match f(self.client.clone(), request).await {
191                Err(s) if Self::is_retryable(&s) && retry_count < self.max_retries => {
192                    let delay = crate::jittered_backoff_delay(
193                        self.retry_delay,
194                        retry_count,
195                        self.max_backoff,
196                    );
197                    retry_count += 1;
198                    linera_base::time::timer::sleep(delay).await;
199                    continue;
200                }
201                Err(s) => {
202                    return Err(NodeError::GrpcError {
203                        error: format!("remote request [{handler}] failed with status: {s:?}"),
204                    });
205                }
206                Ok(result) => return Ok(result.into_inner()),
207            };
208        }
209    }
210
211    fn try_into_chain_info(
212        result: api::ChainInfoResult,
213    ) -> Result<linera_core::data_types::ChainInfoResponse, NodeError> {
214        let inner = result.inner.ok_or_else(|| NodeError::GrpcError {
215            error: "missing body from response".to_string(),
216        })?;
217        match inner {
218            api::chain_info_result::Inner::ChainInfoResponse(response) => {
219                Ok(response.try_into().map_err(|err| NodeError::GrpcError {
220                    error: format!("failed to unmarshal response: {err}"),
221                })?)
222            }
223            api::chain_info_result::Inner::Error(error) => Err(bincode::deserialize(&error)
224                .map_err(|err| NodeError::GrpcError {
225                    error: format!("failed to unmarshal error message: {err}"),
226                })?),
227        }
228    }
229}
230
231impl TryFrom<api::PendingBlobResult> for BlobContent {
232    type Error = NodeError;
233
234    fn try_from(result: api::PendingBlobResult) -> Result<Self, Self::Error> {
235        let inner = result.inner.ok_or_else(|| NodeError::GrpcError {
236            error: "missing body from response".to_string(),
237        })?;
238        match inner {
239            api::pending_blob_result::Inner::Blob(blob) => {
240                Ok(blob.try_into().map_err(|err| NodeError::GrpcError {
241                    error: format!("failed to unmarshal response: {err}"),
242                })?)
243            }
244            api::pending_blob_result::Inner::Error(error) => Err(bincode::deserialize(&error)
245                .map_err(|err| NodeError::GrpcError {
246                    error: format!("failed to unmarshal error message: {err}"),
247                })?),
248        }
249    }
250}
251
252macro_rules! client_delegate {
253    ($self:ident, $handler:ident, $req:ident) => {{
254        debug!(
255            handler = stringify!($handler),
256            request = ?$req,
257            "sending gRPC request"
258        );
259        $self
260            .delegate(
261                |mut client, req| async move { client.$handler(req).await },
262                $req,
263                stringify!($handler),
264            )
265            .await
266    }};
267}
268
269impl ValidatorNode for GrpcClient {
270    type NotificationStream = NotificationStream;
271
272    fn address(&self) -> String {
273        self.address.clone()
274    }
275
276    #[instrument(target = "grpc_client", skip_all, err(level = Level::DEBUG), fields(address = self.address))]
277    async fn handle_block_proposal(
278        &self,
279        proposal: data_types::BlockProposal,
280    ) -> Result<linera_core::data_types::ChainInfoResponse, NodeError> {
281        GrpcClient::try_into_chain_info(client_delegate!(self, handle_block_proposal, proposal)?)
282    }
283
284    #[instrument(target = "grpc_client", skip_all, fields(address = self.address))]
285    async fn handle_lite_certificate(
286        &self,
287        certificate: types::LiteCertificate<'_>,
288        delivery: CrossChainMessageDelivery,
289    ) -> Result<linera_core::data_types::ChainInfoResponse, NodeError> {
290        let wait_for_outgoing_messages = delivery.wait_for_outgoing_messages();
291        let request = HandleLiteCertRequest {
292            certificate,
293            wait_for_outgoing_messages,
294        };
295        GrpcClient::try_into_chain_info(client_delegate!(self, handle_lite_certificate, request)?)
296    }
297
298    #[instrument(target = "grpc_client", skip_all, err(level = Level::DEBUG), fields(address = self.address))]
299    async fn handle_confirmed_certificate(
300        &self,
301        certificate: CacheArc<ConfirmedBlockCertificate>,
302        delivery: CrossChainMessageDelivery,
303    ) -> Result<linera_core::data_types::ChainInfoResponse, NodeError> {
304        let wait_for_outgoing_messages: bool = delivery.wait_for_outgoing_messages();
305        let request = HandleConfirmedCertificateRequest {
306            certificate: CacheArc::unwrap_or_clone(certificate),
307            wait_for_outgoing_messages,
308        };
309        GrpcClient::try_into_chain_info(client_delegate!(
310            self,
311            handle_confirmed_certificate,
312            request
313        )?)
314    }
315
316    #[instrument(target = "grpc_client", skip_all, err(level = Level::DEBUG), fields(address = self.address))]
317    async fn handle_validated_certificate(
318        &self,
319        certificate: ValidatedBlockCertificate,
320    ) -> Result<linera_core::data_types::ChainInfoResponse, NodeError> {
321        let request = HandleValidatedCertificateRequest { certificate };
322        GrpcClient::try_into_chain_info(client_delegate!(
323            self,
324            handle_validated_certificate,
325            request
326        )?)
327    }
328
329    #[instrument(target = "grpc_client", skip_all, err(level = Level::DEBUG), fields(address = self.address))]
330    async fn handle_timeout_certificate(
331        &self,
332        certificate: GenericCertificate<Timeout>,
333    ) -> Result<linera_core::data_types::ChainInfoResponse, NodeError> {
334        let request = HandleTimeoutCertificateRequest { certificate };
335        GrpcClient::try_into_chain_info(client_delegate!(
336            self,
337            handle_timeout_certificate,
338            request
339        )?)
340    }
341
342    #[instrument(target = "grpc_client", skip_all, err(level = Level::DEBUG), fields(address = self.address))]
343    async fn handle_chain_info_query(
344        &self,
345        query: linera_core::data_types::ChainInfoQuery,
346    ) -> Result<linera_core::data_types::ChainInfoResponse, NodeError> {
347        GrpcClient::try_into_chain_info(client_delegate!(self, handle_chain_info_query, query)?)
348    }
349
350    #[instrument(target = "grpc_client", skip_all, err(level = Level::DEBUG), fields(address = self.address))]
351    async fn subscribe(&self, chains: Vec<ChainId>) -> Result<Self::NotificationStream, NodeError> {
352        let retry_delay = self.retry_delay;
353        let max_retries = self.max_retries;
354        let max_backoff = self.max_backoff;
355        let address = self.address.clone();
356        let subscription_cooldowns = self.subscription_cooldowns.clone();
357
358        // Fast-fail if another subscription to this address recently failed.
359        // Prevents N chains from independently retrying the same dead validator.
360        {
361            let pinned = subscription_cooldowns.pin();
362            if let Some(&last_failure) = pinned.get(&address) {
363                if last_failure.elapsed() < max_backoff {
364                    return Err(NodeError::SubscriptionFailed {
365                        status: format!(
366                            "validator {address} on cooldown after recent subscription failure"
367                        ),
368                    });
369                }
370            }
371        }
372
373        // Use shared atomic counter so unfold can reset it on successful reconnection.
374        let retry_count = Arc::new(AtomicU32::new(0));
375        let subscription_request = SubscriptionRequest {
376            chain_ids: chains.into_iter().map(|chain| chain.into()).collect(),
377        };
378        let mut client = self.client.clone();
379
380        // Make the first connection attempt before returning from this method.
381        let mut stream = Some(
382            client
383                .subscribe(subscription_request.clone())
384                .await
385                .map_err(|status| {
386                    subscription_cooldowns
387                        .pin()
388                        .insert(address.clone(), Instant::now());
389                    NodeError::SubscriptionFailed {
390                        status: status.to_string(),
391                    }
392                })?
393                .into_inner(),
394        );
395
396        // A stream of `Result<grpc::Notification, tonic::Status>` that keeps calling
397        // `client.subscribe(request)` endlessly and without delay.
398        let retry_count_for_unfold = retry_count.clone();
399        let cooldowns_for_unfold = subscription_cooldowns.clone();
400        let address_for_unfold = address.clone();
401        let endlessly_retrying_notification_stream = stream::unfold((), move |()| {
402            let mut client = client.clone();
403            let subscription_request = subscription_request.clone();
404            let mut stream = stream.take();
405            let retry_count = retry_count_for_unfold.clone();
406            let cooldowns = cooldowns_for_unfold.clone();
407            let cooldown_address = address_for_unfold.clone();
408            async move {
409                let stream = if let Some(stream) = stream.take() {
410                    future::Either::Right(stream)
411                } else {
412                    match client.subscribe(subscription_request.clone()).await {
413                        Err(err) => future::Either::Left(stream::iter(iter::once(Err(err)))),
414                        Ok(response) => {
415                            // Reset retry count on successful reconnection.
416                            retry_count.store(0, Ordering::Relaxed);
417                            cooldowns.pin().remove(&cooldown_address);
418                            trace!("Successfully reconnected subscription stream");
419                            future::Either::Right(response.into_inner())
420                        }
421                    }
422                };
423                Some((stream, ()))
424            }
425        })
426        .flatten();
427
428        let span = tracing::info_span!("notification stream");
429        #[cfg(with_metrics)]
430        let address_for_metrics = self.address.clone();
431        let cooldowns_for_take_while = subscription_cooldowns;
432        let address_for_take_while = self.address.clone();
433        // The stream of `Notification`s that inserts increasing delays after retriable errors, and
434        // terminates after unexpected or fatal errors.
435        let notification_stream = endlessly_retrying_notification_stream
436            .map(|result| {
437                Option::<Notification>::try_from(result?).map_err(|err| {
438                    let message = format!("Could not deserialize notification: {err}");
439                    tonic::Status::new(Code::Internal, message)
440                })
441            })
442            .take_while(move |result| {
443                let Err(status) = result else {
444                    retry_count.store(0, Ordering::Relaxed);
445                    return future::Either::Left(future::ready(true));
446                };
447
448                #[cfg(with_metrics)]
449                metrics::VALIDATOR_SUBSCRIPTION_ERRORS
450                    .with_label_values(&[&address_for_metrics])
451                    .inc();
452
453                let current_retry_count = retry_count.load(Ordering::Relaxed);
454                if !span.in_scope(|| Self::is_retryable(status))
455                    || current_retry_count >= max_retries
456                {
457                    cooldowns_for_take_while
458                        .pin()
459                        .insert(address_for_take_while.clone(), Instant::now());
460                    return future::Either::Left(future::ready(false));
461                }
462                let delay =
463                    crate::jittered_backoff_delay(retry_delay, current_retry_count, max_backoff);
464                retry_count.fetch_add(1, Ordering::Relaxed);
465                future::Either::Right(async move {
466                    linera_base::time::timer::sleep(delay).await;
467                    true
468                })
469            })
470            .filter_map(move |result| {
471                future::ready(match result {
472                    Ok(notification @ Some(_)) => notification,
473                    Ok(None) => None,
474                    Err(err) => {
475                        debug!(%address, "{}", err);
476                        None
477                    }
478                })
479            });
480
481        Ok(Box::pin(notification_stream))
482    }
483
484    #[instrument(target = "grpc_client", skip_all, err(level = Level::DEBUG), fields(address = self.address))]
485    async fn get_version_info(&self) -> Result<VersionInfo, NodeError> {
486        let req = ();
487        Ok(client_delegate!(self, get_version_info, req)?.into())
488    }
489
490    #[instrument(target = "grpc_client", skip_all, err(level = Level::DEBUG), fields(address = self.address))]
491    async fn get_network_description(&self) -> Result<NetworkDescription, NodeError> {
492        let req = ();
493        Ok(client_delegate!(self, get_network_description, req)?.try_into()?)
494    }
495
496    #[instrument(target = "grpc_client", skip(self), err(level = Level::DEBUG), fields(address = self.address))]
497    async fn upload_blob(&self, content: BlobContent) -> Result<BlobId, NodeError> {
498        Ok(client_delegate!(self, upload_blob, content)?.try_into()?)
499    }
500
501    #[instrument(target = "grpc_client", skip(self), err(level = Level::DEBUG), fields(address = self.address))]
502    async fn download_blob(&self, blob_id: BlobId) -> Result<BlobContent, NodeError> {
503        Ok(client_delegate!(self, download_blob, blob_id)?.try_into()?)
504    }
505
506    #[instrument(target = "grpc_client", skip(self), err(level = Level::DEBUG), fields(address = self.address))]
507    async fn download_blobs(&self, blob_ids: Vec<BlobId>) -> Result<BlobStream, NodeError> {
508        debug!(
509            handler = "download_blobs",
510            num_blobs = blob_ids.len(),
511            "sending gRPC request"
512        );
513        let request = api::BlobIds::try_from(blob_ids)?;
514        let stream = self
515            .client
516            .clone()
517            .download_blobs(request)
518            .await
519            .map_err(|status| NodeError::GrpcError {
520                error: status.to_string(),
521            })?
522            .into_inner();
523        let blob_stream = stream.map(|result| match result {
524            Ok(proto_blob) => BlobContent::try_from(proto_blob).map_err(NodeError::from),
525            Err(status) => Err(NodeError::GrpcError {
526                error: status.to_string(),
527            }),
528        });
529        Ok(Box::pin(blob_stream))
530    }
531
532    #[instrument(target = "grpc_client", skip(self), err(level = Level::DEBUG), fields(address = self.address))]
533    async fn download_pending_blob(
534        &self,
535        chain_id: ChainId,
536        blob_id: BlobId,
537    ) -> Result<BlobContent, NodeError> {
538        let req = (chain_id, blob_id);
539        client_delegate!(self, download_pending_blob, req)?.try_into()
540    }
541
542    #[instrument(target = "grpc_client", skip(self), err(level = Level::DEBUG), fields(address = self.address))]
543    async fn handle_pending_blob(
544        &self,
545        chain_id: ChainId,
546        blob: BlobContent,
547    ) -> Result<ChainInfoResponse, NodeError> {
548        let req = (chain_id, blob);
549        GrpcClient::try_into_chain_info(client_delegate!(self, handle_pending_blob, req)?)
550    }
551
552    #[instrument(target = "grpc_client", skip_all, err(level = Level::DEBUG), fields(address = self.address))]
553    async fn download_certificate(
554        &self,
555        hash: CryptoHash,
556    ) -> Result<ConfirmedBlockCertificate, NodeError> {
557        ConfirmedBlockCertificate::try_from(Certificate::try_from(client_delegate!(
558            self,
559            download_certificate,
560            hash
561        )?)?)
562        .map_err(|_| NodeError::UnexpectedCertificateValue)
563    }
564
565    #[instrument(target = "grpc_client", skip_all, err(level = Level::DEBUG), fields(address = self.address))]
566    async fn download_certificates(
567        &self,
568        hashes: Vec<CryptoHash>,
569    ) -> Result<Vec<ConfirmedBlockCertificate>, NodeError> {
570        let mut missing_hashes = hashes;
571        let mut certs_collected = Vec::with_capacity(missing_hashes.len());
572        while !missing_hashes.is_empty() {
573            // Macro doesn't compile if we pass `missing_hashes.clone()` directly to `client_delegate!`.
574            let missing = missing_hashes.clone();
575            let mut received: Vec<_> = Vec::<Certificate>::try_from(client_delegate!(
576                self,
577                download_certificates,
578                missing
579            )?)?
580            .into_iter()
581            .map(|cert| {
582                ConfirmedBlockCertificate::try_from(cert)
583                    .map_err(|_| NodeError::UnexpectedCertificateValue)
584            })
585            .collect::<Result<_, _>>()?;
586
587            // In the case of the server not returning any certificates, we break the loop.
588            if received.is_empty() {
589                break;
590            }
591
592            // Honest validator should return certificates in the same order as the requested hashes.
593            missing_hashes = missing_hashes[received.len()..].to_vec();
594            certs_collected.append(&mut received);
595        }
596        ensure!(
597            missing_hashes.is_empty(),
598            NodeError::MissingCertificates(missing_hashes)
599        );
600        Ok(certs_collected)
601    }
602
603    #[instrument(target = "grpc_client", skip(self), err(level = Level::DEBUG), fields(address = self.address))]
604    async fn download_certificates_by_heights(
605        &self,
606        chain_id: ChainId,
607        heights: Vec<BlockHeight>,
608    ) -> Result<Vec<ConfirmedBlockCertificate>, NodeError> {
609        let mut missing = heights.into_iter().collect::<BTreeSet<_>>();
610        let mut certs_collected = vec![];
611        while !missing.is_empty() {
612            let request = CertificatesByHeightRequest {
613                chain_id,
614                heights: missing.iter().copied().collect(),
615            };
616            let mut received: Vec<_> =
617                client_delegate!(self, download_raw_certificates_by_heights, request)?
618                    .certificates
619                    .into_iter()
620                    .map(
621                        |RawCertificate {
622                             lite_certificate,
623                             confirmed_block,
624                         }|
625                         -> Result<ConfirmedBlockCertificate, NodeError> {
626                            let cert = bcs::from_bytes::<LiteCertificate>(&lite_certificate)
627                                .map_err(|_| NodeError::UnexpectedCertificateValue)?;
628
629                            let block = bcs::from_bytes::<ConfirmedBlock>(&confirmed_block)
630                                .map_err(|_| NodeError::UnexpectedCertificateValue)?;
631
632                            cert.into_confirmed_certificate(block)
633                                .ok_or(NodeError::UnexpectedCertificateValue)
634                        },
635                    )
636                    .collect::<Result<_, _>>()?;
637
638            if received.is_empty() {
639                break;
640            }
641
642            // Remove only the heights we actually received from missing set.
643            for cert in &received {
644                missing.remove(&cert.inner().height());
645            }
646            certs_collected.append(&mut received);
647        }
648        certs_collected.sort_by_key(|cert| cert.inner().height());
649        Ok(certs_collected)
650    }
651
652    #[instrument(target = "grpc_client", skip(self), err(level = Level::DEBUG), fields(address = self.address))]
653    async fn blob_last_used_by(&self, blob_id: BlobId) -> Result<CryptoHash, NodeError> {
654        Ok(client_delegate!(self, blob_last_used_by, blob_id)?.try_into()?)
655    }
656
657    #[instrument(target = "grpc_client", skip(self), err(level = Level::DEBUG), fields(address = self.address))]
658    async fn blob_last_used_by_certificate(
659        &self,
660        blob_id: BlobId,
661    ) -> Result<ConfirmedBlockCertificate, NodeError> {
662        Ok(client_delegate!(self, blob_last_used_by_certificate, blob_id)?.try_into()?)
663    }
664
665    #[instrument(target = "grpc_client", skip(self), err(level = Level::DEBUG), fields(address = self.address))]
666    async fn event_block_heights(
667        &self,
668        event_ids: Vec<EventId>,
669    ) -> Result<Vec<Option<BlockHeight>>, NodeError> {
670        let request = api::EventBlockHeightsRequest::from(event_ids);
671        Ok(client_delegate!(self, event_block_heights, request)?.try_into()?)
672    }
673
674    #[instrument(target = "grpc_client", skip(self), err(level = Level::DEBUG), fields(address = self.address))]
675    async fn missing_blob_ids(&self, blob_ids: Vec<BlobId>) -> Result<Vec<BlobId>, NodeError> {
676        Ok(client_delegate!(self, missing_blob_ids, blob_ids)?.try_into()?)
677    }
678
679    #[expect(
680        clippy::cast_possible_truncation,
681        reason = "shard counts are bounded by validator config and fit in usize on supported targets"
682    )]
683    #[instrument(target = "grpc_client", skip(self), err(level = Level::DEBUG), fields(address = self.address))]
684    async fn get_shard_info(
685        &self,
686        chain_id: ChainId,
687    ) -> Result<linera_core::data_types::ShardInfo, NodeError> {
688        let response = client_delegate!(self, get_shard_info, chain_id)?;
689        Ok(linera_core::data_types::ShardInfo {
690            shard_id: response.shard_id as usize,
691            total_shards: response.total_shards as usize,
692        })
693    }
694}
695
696#[cfg(test)]
697mod tests {
698    use tonic::{Code, Status};
699
700    use super::GrpcClient;
701
702    /// Verbatim from a PM worker on 2026-09-16, when validator-1's ingress answered a
703    /// `handle_block_proposal` with an HTML error page. Its body began with a newline, so
704    /// tonic read `0x0a` as the compression flag.
705    const PROXY_503: &str = "protocol error: received message with invalid compression flag: 10 \
706         (valid flags are 0 and 1) while receiving response with status: 503 Service Unavailable";
707
708    /// The other message tonic builds with the same suffix, when the body parses as compressed
709    /// but does not decompress (`codec/decode.rs`). Both sites are the only ones that carry the
710    /// HTTP status, so both have to be recognized.
711    const PROXY_503_DECOMPRESS: &str = "Error decompressing: corrupt deflate stream, \
712         while receiving response with status: 503 Service Unavailable";
713
714    fn is_retryable(message: &str) -> bool {
715        GrpcClient::is_retryable(&Status::new(Code::Internal, message))
716    }
717
718    #[test]
719    fn proxy_server_errors_are_retryable() {
720        assert!(is_retryable(PROXY_503));
721        assert!(is_retryable(PROXY_503_DECOMPRESS));
722        for status in [
723            "500 Internal Server Error",
724            "502 Bad Gateway",
725            "504 Gateway Timeout",
726        ] {
727            let message = format!("while receiving response with status: {status}");
728            assert!(is_retryable(&message), "{status} should be retryable");
729        }
730    }
731
732    #[test]
733    fn proxy_client_errors_are_not_retryable() {
734        for status in ["400 Bad Request", "404 Not Found", "429 Too Many Requests"] {
735            let message = format!("while receiving response with status: {status}");
736            assert!(!is_retryable(&message), "{status} should not be retryable");
737        }
738    }
739
740    #[test]
741    fn unrelated_internal_errors_are_not_retryable() {
742        assert!(!is_retryable("something else went wrong"));
743        // A 5xx that is not the status of the response must not be mistaken for one.
744        assert!(!is_retryable("the chain is at height 503"));
745    }
746
747    #[test]
748    fn connection_resets_are_retryable() {
749        assert!(is_retryable("h2 protocol error: stream closed"));
750    }
751}