Skip to main content

lodestone_client/
subscription.rs

1use std::collections::BTreeMap;
2use std::error::Error;
3use std::fmt::{self, Display, Formatter};
4use std::io;
5use std::sync::Arc;
6use std::time::Duration;
7
8use http::{Uri, uri::PathAndQuery};
9use hyper_util::rt::TokioIo;
10use lodestone_wire::{
11    Digest, LODESTONE_API_SPEC_SHA256, LODESTONE_API_SUBSCRIPTION_ACCEPTED_FORMAT_VERSION,
12    LODESTONE_API_SUBSCRIPTION_EVENT_FORMAT_VERSION, LODESTONE_API_SUBSCRIPTION_GAP_FORMAT_VERSION,
13    LODESTONE_API_SUBSCRIPTION_HEARTBEAT_FORMAT_VERSION, LODESTONE_API_SUBSCRIPTION_START_FORMAT_VERSION,
14    LODESTONE_API_SUBSCRIPTION_TERMINAL_FORMAT_VERSION, Resource, SubscriptionAccepted, SubscriptionCursorEntry,
15    SubscriptionEvent, SubscriptionFrame, SubscriptionRoute, SubscriptionStart, SubscriptionStartPosition,
16};
17use rustls::pki_types::CertificateDer;
18use tokio::net::TcpStream;
19use tokio_rustls::TlsConnector;
20use tonic::Code;
21use tonic::metadata::MetadataValue;
22use tonic::transport::{Channel, Endpoint};
23use tower::service_fn;
24
25use crate::config::parse_u64;
26use crate::transport::authorization_value;
27use crate::{CallOptions, Client, ClientError};
28
29const SUBSCRIPTION_METHOD: &str = "/lodestone.api.FilterSubscription/Subscribe";
30const MAX_SUBSCRIPTION_MESSAGE_BYTES: usize = 2 * 1024 * 1024;
31
32#[derive(Clone, PartialEq, prost::Message)]
33struct GrpcSubscriptionRequest {
34    #[prost(bytes = "vec", tag = "1")]
35    json_request: Vec<u8>,
36}
37
38#[derive(Clone, PartialEq, prost::Message)]
39struct GrpcSubscriptionFrame {
40    #[prost(bytes = "vec", tag = "1")]
41    json_frame: Vec<u8>,
42}
43
44#[derive(Clone, Debug, PartialEq)]
45pub struct SubscriptionCheckpoint {
46    pub index: Resource,
47    pub routes: Vec<SubscriptionRoute>,
48    pub cut: Vec<SubscriptionCursorEntry>,
49}
50
51pub struct Subscription {
52    client: Client,
53    template: SubscriptionStart,
54    options: CallOptions,
55    stream: tonic::Streaming<GrpcSubscriptionFrame>,
56    checkpoint: SubscriptionCheckpoint,
57    pending: Option<SubscriptionFrame>,
58    reconnects: u8,
59    ended: bool,
60    authorization_revision: Option<u64>,
61    last_events: BTreeMap<CursorKey, SubscriptionEvent>,
62}
63
64impl Client {
65    pub async fn subscribe(
66        &self,
67        start: SubscriptionStart,
68        options: CallOptions,
69    ) -> Result<Subscription, SubscriptionError> {
70        if self.profile_verification().is_none() {
71            return Err(SubscriptionError::ProfileNotVerified);
72        }
73        validate_start(&start, self.connection_profile().as_wire().schema_digest.clone())?;
74        let (stream, accepted) = open_stream(self, &start, &options).await?;
75        let checkpoint = checkpoint_from_accepted(&start.index, &accepted)?;
76        Ok(Subscription {
77            client: self.clone(),
78            template: start,
79            options,
80            stream,
81            checkpoint,
82            pending: Some(SubscriptionFrame::Accepted { body: accepted }),
83            reconnects: 0,
84            ended: false,
85            authorization_revision: None,
86            last_events: BTreeMap::new(),
87        })
88    }
89}
90
91impl Subscription {
92    #[must_use]
93    pub const fn checkpoint(&self) -> &SubscriptionCheckpoint {
94        &self.checkpoint
95    }
96
97    pub async fn next_frame(&mut self) -> Result<Option<SubscriptionFrame>, SubscriptionError> {
98        if self.ended {
99            return Ok(None);
100        }
101        if let Some(frame) = self.pending.take() {
102            return Ok(Some(frame));
103        }
104        loop {
105            let deadline = self.options.local_deadline().map_err(SubscriptionError::Client)?;
106            let next = tokio::select! {
107                result = tokio::time::timeout_at(deadline, self.stream.message()) => {
108                    match result {
109                        Ok(result) => result.map_err(SubscriptionError::Status),
110                        Err(_) => Err(SubscriptionError::FrameDeadline),
111                    }
112                }
113                () = self.options.cancellation().cancelled() => return Err(SubscriptionError::Cancelled),
114            };
115            let reconnect = match next {
116                Ok(Some(message)) => {
117                    let frame = decode_frame(message.json_frame)?;
118                    self.validate_and_advance(&frame)?;
119                    return Ok(Some(frame));
120                }
121                Err(SubscriptionError::FrameDeadline) => {
122                    self.ended = true;
123                    return Err(SubscriptionError::FrameDeadline);
124                }
125                Ok(None) => true,
126                Err(SubscriptionError::Status(status)) if reconnectable_status(&status) => true,
127                Err(SubscriptionError::Status(status)) => {
128                    self.ended = true;
129                    return Err(SubscriptionError::Status(status));
130                }
131                Err(error) => return Err(error),
132            };
133            if reconnect {
134                if self.reconnects >= self.client.pool_config().maximum_subscription_reconnects {
135                    self.ended = true;
136                    return Err(SubscriptionError::ReconnectExhausted);
137                }
138                self.reconnects = self.reconnects.saturating_add(1);
139                let backoff = Duration::from_millis(25_u64.saturating_mul(1_u64 << self.reconnects.min(3)));
140                tokio::select! {
141                    result = tokio::time::timeout_at(deadline, tokio::time::sleep(backoff)) => {
142                        if result.is_err() {
143                            self.ended = true;
144                            return Err(SubscriptionError::FrameDeadline);
145                        }
146                    }
147                    () = self.options.cancellation().cancelled() => {
148                        return Err(SubscriptionError::Cancelled);
149                    }
150                }
151                let resume = self.resume_start()?;
152                let (stream, accepted) = open_stream(&self.client, &resume, &self.options).await?;
153                let checkpoint = checkpoint_from_accepted(&resume.index, &accepted)?;
154                if checkpoint.routes != self.checkpoint.routes || checkpoint.cut != self.checkpoint.cut {
155                    self.ended = true;
156                    return Err(SubscriptionError::CheckpointMismatch);
157                }
158                self.template = resume;
159                self.stream = stream;
160            }
161        }
162    }
163
164    fn resume_start(&self) -> Result<SubscriptionStart, SubscriptionError> {
165        let mut start = self.template.clone();
166        start.request_id = random_request_id()?;
167        start.reconnect_attempt = u32::from(self.reconnects);
168        start.start = SubscriptionStartPosition::Resume {
169            routes: self.checkpoint.routes.clone(),
170            cut: self.checkpoint.cut.clone(),
171        };
172        Ok(start)
173    }
174
175    fn validate_and_advance(&mut self, frame: &SubscriptionFrame) -> Result<(), SubscriptionError> {
176        match frame {
177            SubscriptionFrame::Accepted { .. } => Err(SubscriptionError::UnexpectedAccepted),
178            SubscriptionFrame::Event { body } => {
179                validate_event(body, &self.checkpoint.index)?;
180                let cursor = cursor_from_event(body);
181                let key = CursorKey::from_cursor(&cursor);
182                if let Some(previous) = cursor_for_key(&self.checkpoint.cut, &key) {
183                    let prior = parse_u64(&previous.sequence.value).ok_or(SubscriptionError::InvalidFrame)?;
184                    let next = parse_u64(&cursor.sequence.value).ok_or(SubscriptionError::InvalidFrame)?;
185                    if next < prior {
186                        return Err(SubscriptionError::CursorRegression);
187                    }
188                    if next == prior {
189                        if self.last_events.get(&key) == Some(body) {
190                            return Ok(());
191                        }
192                        return Err(SubscriptionError::DuplicateIdentityMismatch);
193                    }
194                }
195                upsert_cursor(&mut self.checkpoint.cut, cursor)?;
196                self.last_events.insert(key, body.clone());
197                Ok(())
198            }
199            SubscriptionFrame::Heartbeat { body } => {
200                if body.format_version != u32::from(LODESTONE_API_SUBSCRIPTION_HEARTBEAT_FORMAT_VERSION) {
201                    return Err(SubscriptionError::InvalidFrame);
202                }
203                let revision = parse_u64(&body.authorization_revision.value).ok_or(SubscriptionError::InvalidFrame)?;
204                if parse_u64(&body.route_revision.value).is_none()
205                    || parse_u64(&body.authorization_valid_until_millis.value).is_none()
206                {
207                    return Err(SubscriptionError::InvalidFrame);
208                }
209                if self.authorization_revision.is_some_and(|previous| revision < previous) {
210                    return Err(SubscriptionError::AuthorizationRegression);
211                }
212                merge_cut(&mut self.checkpoint.cut, &body.confirmed_cut, &self.checkpoint.index)?;
213                self.authorization_revision = Some(revision);
214                Ok(())
215            }
216            SubscriptionFrame::Gap { body } => {
217                if body.format_version != u32::from(LODESTONE_API_SUBSCRIPTION_GAP_FORMAT_VERSION) || !body.terminal {
218                    return Err(SubscriptionError::InvalidFrame);
219                }
220                for cursor in body
221                    .affected
222                    .iter()
223                    .chain(&body.last_confirmed)
224                    .chain(&body.earliest_available)
225                {
226                    if cursor.index != self.checkpoint.index {
227                        return Err(SubscriptionError::InvalidFrame);
228                    }
229                }
230                self.ended = true;
231                Ok(())
232            }
233            SubscriptionFrame::Terminal { body } => {
234                if body.format_version != u32::from(LODESTONE_API_SUBSCRIPTION_TERMINAL_FORMAT_VERSION) {
235                    return Err(SubscriptionError::InvalidFrame);
236                }
237                self.ended = true;
238                Ok(())
239            }
240        }
241    }
242}
243
244async fn open_stream(
245    client: &Client,
246    start: &SubscriptionStart,
247    options: &CallOptions,
248) -> Result<(tonic::Streaming<GrpcSubscriptionFrame>, SubscriptionAccepted), SubscriptionError> {
249    let payload = serde_json::to_vec(start).map_err(|_| SubscriptionError::RequestCodec)?;
250    if payload.is_empty() || payload.len() > MAX_SUBSCRIPTION_MESSAGE_BYTES {
251        return Err(SubscriptionError::RequestTooLarge);
252    }
253    let deadline = options.local_deadline().map_err(SubscriptionError::Client)?;
254    let credential = tokio::select! {
255        result = tokio::time::timeout_at(deadline, client.credential()) => {
256            result.map_err(|_| SubscriptionError::OpenDeadline)?
257                .map_err(SubscriptionError::Client)?
258        }
259        () = options.cancellation().cancelled() => return Err(SubscriptionError::Cancelled),
260    };
261    let authorization = authorization_value(&credential).map_err(SubscriptionError::Client)?;
262    let channel = connect_channel(client, options, deadline).await?;
263    let mut grpc = tonic::client::Grpc::new(channel)
264        .max_decoding_message_size(MAX_SUBSCRIPTION_MESSAGE_BYTES)
265        .max_encoding_message_size(MAX_SUBSCRIPTION_MESSAGE_BYTES);
266    let mut request = tonic::Request::new(GrpcSubscriptionRequest { json_request: payload });
267    let authorization =
268        std::str::from_utf8(authorization.as_slice()).map_err(|_| SubscriptionError::InvalidMetadata)?;
269    request.metadata_mut().insert(
270        "authorization",
271        MetadataValue::try_from(authorization).map_err(|_| SubscriptionError::InvalidMetadata)?,
272    );
273    request.metadata_mut().insert(
274        "x-lodestone-collection-id",
275        MetadataValue::try_from(
276            client
277                .connection_profile()
278                .as_wire()
279                .expected_collection_id
280                .value
281                .as_str(),
282        )
283        .map_err(|_| SubscriptionError::InvalidMetadata)?,
284    );
285    request.metadata_mut().insert(
286        "x-lodestone-index-name",
287        MetadataValue::try_from(start.index.name.as_str()).map_err(|_| SubscriptionError::InvalidMetadata)?,
288    );
289    // INVARIANT: Tower's buffered channel reserves capacity through poll_ready;
290    // invoking the RPC without this reservation can panic instead of refusing.
291    tokio::select! {
292        result = tokio::time::timeout_at(deadline, grpc.ready()) => {
293            result.map_err(|_| SubscriptionError::OpenDeadline)?
294                .map_err(|_| SubscriptionError::Connect)?;
295        }
296        () = options.cancellation().cancelled() => return Err(SubscriptionError::Cancelled),
297    }
298    let response = tokio::select! {
299        result = tokio::time::timeout_at(
300            deadline,
301            grpc.server_streaming(
302                request,
303                PathAndQuery::from_static(SUBSCRIPTION_METHOD),
304                tonic_prost::ProstCodec::default(),
305            ),
306        ) => result.map_err(|_| SubscriptionError::OpenDeadline)?
307            .map_err(SubscriptionError::Status)?,
308        () = options.cancellation().cancelled() => return Err(SubscriptionError::Cancelled),
309    };
310    let mut stream = response.into_inner();
311    let first: GrpcSubscriptionFrame = tokio::select! {
312        result = tokio::time::timeout_at(deadline, stream.message()) => {
313            result.map_err(|_| SubscriptionError::OpenDeadline)?
314                .map_err(SubscriptionError::Status)?
315        }
316        () = options.cancellation().cancelled() => return Err(SubscriptionError::Cancelled),
317    }
318    .ok_or(SubscriptionError::AcceptedAbsent)?;
319    let frame = decode_frame(first.json_frame)?;
320    let SubscriptionFrame::Accepted { body } = frame else {
321        return Err(SubscriptionError::AcceptedAbsent);
322    };
323    Ok((stream, body))
324}
325
326async fn connect_channel(
327    client: &Client,
328    options: &CallOptions,
329    deadline: tokio::time::Instant,
330) -> Result<Channel, SubscriptionError> {
331    let config = client.transport_config();
332    let mut roots = rustls::RootCertStore::empty();
333    for certificate in config.trust.certificates_der() {
334        roots
335            .add(CertificateDer::from(certificate.clone()))
336            .map_err(|_| SubscriptionError::InvalidTls)?;
337    }
338    let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider());
339    let mut tls = rustls::ClientConfig::builder_with_provider(provider)
340        .with_protocol_versions(&[&rustls::version::TLS13])
341        .map_err(|_| SubscriptionError::InvalidTls)?
342        .with_root_certificates(roots)
343        .with_no_client_auth();
344    tls.alpn_protocols = vec![b"h2".to_vec()];
345    let connector = TlsConnector::from(Arc::new(tls));
346    let host = config.stream_endpoint.host().to_owned();
347    let port = config.stream_endpoint.port();
348    let server_name = rustls::pki_types::ServerName::try_from(config.identity.tls_server_name().to_owned())
349        .map_err(|_| SubscriptionError::InvalidTls)?;
350    let connect = service_fn(move |_uri: Uri| {
351        let connector = connector.clone();
352        let host = host.clone();
353        let server_name = server_name.clone();
354        async move {
355            let tcp = TcpStream::connect((host.as_str(), port)).await?;
356            let tls = connector.connect(server_name, tcp).await.map_err(io::Error::other)?;
357            if tls.get_ref().1.alpn_protocol() != Some(b"h2".as_slice()) {
358                return Err(io::Error::other("Core subscription TLS did not negotiate h2"));
359            }
360            Ok::<_, io::Error>(TokioIo::new(tls))
361        }
362    });
363    let transport_uri = format!(
364        "http://{}:{}",
365        config.stream_endpoint.host(),
366        config.stream_endpoint.port()
367    );
368    let origin: Uri = config
369        .stream_endpoint
370        .as_str()
371        .parse()
372        .map_err(|_| SubscriptionError::InvalidEndpoint)?;
373    let endpoint = Endpoint::from_shared(transport_uri)
374        .map_err(|_| SubscriptionError::InvalidEndpoint)?
375        .connect_timeout(config.pool.connect_timeout)
376        .origin(origin);
377    tokio::select! {
378        result = tokio::time::timeout_at(deadline, endpoint.connect_with_connector(connect)) => {
379            result.map_err(|_| SubscriptionError::OpenDeadline)?
380                .map_err(|_| SubscriptionError::Connect)
381        }
382        () = options.cancellation().cancelled() => Err(SubscriptionError::Cancelled),
383    }
384}
385
386fn validate_start(start: &SubscriptionStart, schema: Digest) -> Result<(), SubscriptionError> {
387    if start.format_version != u32::from(LODESTONE_API_SUBSCRIPTION_START_FORMAT_VERSION)
388        || start.request_id.is_empty()
389        || start.request_id.len() > 128
390        || start.request_id.bytes().any(|byte| byte.is_ascii_control())
391        || start.index.kind != "index"
392        || start.index.name.is_empty()
393        || start.index.incarnation.validate().is_err()
394        || start.schema_digest != schema
395        || start.schema_digest.value != LODESTONE_API_SPEC_SHA256
396        || parse_u64(&start.heartbeat_millis.value).is_none_or(|value| value == 0)
397    {
398        return Err(SubscriptionError::InvalidStart);
399    }
400    Ok(())
401}
402
403fn checkpoint_from_accepted(
404    index: &Resource,
405    accepted: &SubscriptionAccepted,
406) -> Result<SubscriptionCheckpoint, SubscriptionError> {
407    if accepted.format_version != u32::from(LODESTONE_API_SUBSCRIPTION_ACCEPTED_FORMAT_VERSION)
408        || accepted.schema_digest.value != LODESTONE_API_SPEC_SHA256
409        || accepted.routes.is_empty()
410    {
411        return Err(SubscriptionError::InvalidFrame);
412    }
413    for route in &accepted.routes {
414        if &route.index != index || route.shard_ids.is_empty() {
415            return Err(SubscriptionError::InvalidFrame);
416        }
417    }
418    let mut cut = Vec::new();
419    merge_cut(&mut cut, &accepted.accepted_cut, index)?;
420    Ok(SubscriptionCheckpoint {
421        index: index.clone(),
422        routes: accepted.routes.clone(),
423        cut,
424    })
425}
426
427fn decode_frame(bytes: Vec<u8>) -> Result<SubscriptionFrame, SubscriptionError> {
428    if bytes.is_empty() || bytes.len() > MAX_SUBSCRIPTION_MESSAGE_BYTES {
429        return Err(SubscriptionError::InvalidFrame);
430    }
431    let frame: SubscriptionFrame = serde_json::from_slice(&bytes).map_err(|_| SubscriptionError::FrameCodec)?;
432    if serde_json::to_vec(&frame).map_err(|_| SubscriptionError::FrameCodec)? != bytes {
433        return Err(SubscriptionError::NonCanonicalFrame);
434    }
435    Ok(frame)
436}
437
438fn validate_event(event: &SubscriptionEvent, index: &Resource) -> Result<(), SubscriptionError> {
439    if event.format_version != u32::from(LODESTONE_API_SUBSCRIPTION_EVENT_FORMAT_VERSION)
440        || &event.index != index
441        || event.event_id.validate().is_err()
442        || event.recovery_era.validate().is_err()
443        || event.writer_fence.recovery_era != event.recovery_era
444        || parse_u64(&event.routing_revision.value).is_none()
445        || parse_u64(&event.shard_id.value).is_none()
446        || parse_u64(&event.sequence.value).is_none()
447    {
448        return Err(SubscriptionError::InvalidFrame);
449    }
450    Ok(())
451}
452
453fn cursor_from_event(event: &SubscriptionEvent) -> SubscriptionCursorEntry {
454    SubscriptionCursorEntry {
455        index: event.index.clone(),
456        routing_revision: event.routing_revision.clone(),
457        recovery_era: event.recovery_era.clone(),
458        shard_id: event.shard_id.clone(),
459        sequence: event.sequence.clone(),
460    }
461}
462
463fn merge_cut(
464    current: &mut Vec<SubscriptionCursorEntry>,
465    observed: &[SubscriptionCursorEntry],
466    index: &Resource,
467) -> Result<(), SubscriptionError> {
468    for cursor in observed {
469        if &cursor.index != index {
470            return Err(SubscriptionError::InvalidFrame);
471        }
472        upsert_cursor(current, cursor.clone())?;
473    }
474    Ok(())
475}
476
477fn upsert_cursor(
478    current: &mut Vec<SubscriptionCursorEntry>,
479    observed: SubscriptionCursorEntry,
480) -> Result<(), SubscriptionError> {
481    let key = CursorKey::from_cursor(&observed);
482    let next = parse_u64(&observed.sequence.value).ok_or(SubscriptionError::InvalidFrame)?;
483    if let Some(existing) = current.iter_mut().find(|cursor| CursorKey::from_cursor(cursor) == key) {
484        let prior = parse_u64(&existing.sequence.value).ok_or(SubscriptionError::InvalidFrame)?;
485        if next < prior {
486            return Err(SubscriptionError::CursorRegression);
487        }
488        *existing = observed;
489    } else {
490        current.push(observed);
491    }
492    current.sort_by_key(CursorKey::from_cursor);
493    Ok(())
494}
495
496fn cursor_for_key<'a>(cut: &'a [SubscriptionCursorEntry], key: &CursorKey) -> Option<&'a SubscriptionCursorEntry> {
497    cut.iter().find(|cursor| &CursorKey::from_cursor(cursor) == key)
498}
499
500#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
501struct CursorKey {
502    routing_revision: String,
503    recovery_era: String,
504    shard_id: String,
505}
506
507impl CursorKey {
508    fn from_cursor(cursor: &SubscriptionCursorEntry) -> Self {
509        Self {
510            routing_revision: cursor.routing_revision.value.clone(),
511            recovery_era: cursor.recovery_era.value.clone(),
512            shard_id: cursor.shard_id.value.clone(),
513        }
514    }
515}
516
517fn random_request_id() -> Result<String, SubscriptionError> {
518    let mut bytes = [0_u8; 16];
519    getrandom::fill(&mut bytes).map_err(|_| SubscriptionError::RandomUnavailable)?;
520    if bytes == [0; 16] {
521        return Err(SubscriptionError::RandomUnavailable);
522    }
523    const HEX: &[u8; 16] = b"0123456789abcdef";
524    let mut output = String::with_capacity(34);
525    output.push_str("s-");
526    for byte in bytes {
527        output.push(char::from(HEX[usize::from(byte >> 4)]));
528        output.push(char::from(HEX[usize::from(byte & 0x0f)]));
529    }
530    Ok(output)
531}
532
533fn reconnectable_status(status: &tonic::Status) -> bool {
534    matches!(
535        status.code(),
536        Code::Cancelled | Code::Unknown | Code::DeadlineExceeded | Code::Aborted | Code::Internal | Code::Unavailable
537    )
538}
539
540#[derive(Debug)]
541pub enum SubscriptionError {
542    Client(ClientError),
543    ProfileNotVerified,
544    InvalidStart,
545    RequestCodec,
546    RequestTooLarge,
547    InvalidEndpoint,
548    InvalidTls,
549    InvalidMetadata,
550    Connect,
551    OpenDeadline,
552    FrameDeadline,
553    Cancelled,
554    Status(tonic::Status),
555    AcceptedAbsent,
556    UnexpectedAccepted,
557    FrameCodec,
558    NonCanonicalFrame,
559    InvalidFrame,
560    CursorRegression,
561    AuthorizationRegression,
562    DuplicateIdentityMismatch,
563    CheckpointMismatch,
564    ReconnectExhausted,
565    RandomUnavailable,
566}
567
568impl Display for SubscriptionError {
569    fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
570        formatter.write_str(match self {
571            Self::Client(_) => "Core subscription client dependency failed",
572            Self::ProfileNotVerified => "Core subscription is refused until discovery and binding are verified",
573            Self::InvalidStart => "Core subscription start is invalid",
574            Self::RequestCodec => "Core subscription start could not be encoded",
575            Self::RequestTooLarge => "Core subscription start exceeds its byte bound",
576            Self::InvalidEndpoint => "Core subscription endpoint is invalid",
577            Self::InvalidTls => "Core subscription TLS configuration is invalid",
578            Self::InvalidMetadata => "Core subscription admission metadata is invalid",
579            Self::Connect => "Core subscription connection failed",
580            Self::OpenDeadline => "Core subscription open deadline elapsed",
581            Self::FrameDeadline => "Core subscription frame deadline elapsed",
582            Self::Cancelled => "Core subscription was cancelled",
583            Self::Status(_) => "Core subscription transport returned a gRPC error",
584            Self::AcceptedAbsent => "Core subscription did not begin with an accepted checkpoint",
585            Self::UnexpectedAccepted => "Core subscription repeated its accepted checkpoint",
586            Self::FrameCodec => "Core subscription frame is malformed",
587            Self::NonCanonicalFrame => "Core subscription frame encoding is noncanonical",
588            Self::InvalidFrame => "Core subscription frame violates its current semantic contract",
589            Self::CursorRegression => "Core subscription cursor regressed",
590            Self::AuthorizationRegression => "Core subscription authorization revision regressed",
591            Self::DuplicateIdentityMismatch => "Core subscription reused a cursor with different event identity",
592            Self::CheckpointMismatch => "Core subscription resume accepted a different checkpoint",
593            Self::ReconnectExhausted => "Core subscription reconnect budget is exhausted",
594            Self::RandomUnavailable => "secure subscription request identity generation is unavailable",
595        })
596    }
597}
598
599impl Error for SubscriptionError {}
600
601#[cfg(test)]
602mod tests {
603    use super::*;
604
605    #[test]
606    fn authorization_and_policy_statuses_are_terminal() {
607        for code in [Code::Unauthenticated, Code::PermissionDenied, Code::FailedPrecondition] {
608            assert!(!reconnectable_status(&tonic::Status::new(code, "terminal")));
609        }
610        for code in [Code::Unavailable, Code::DeadlineExceeded, Code::Internal] {
611            assert!(reconnectable_status(&tonic::Status::new(code, "transient")));
612        }
613    }
614}