Skip to main content

ppoppo_sdk_core/grpc/
error.rs

1//! Shared error type + `tonic::Status` classifier for the Ppoppo gRPC SDK
2//! family (`pas-plims`, `pcs-external`).
3//!
4//! Both client SDKs re-export [`Error`] as their public error type and route
5//! every RPC failure through [`classify_status`], so the family's retry
6//! taxonomy — which `tonic::Code` is retryable ([`Error::ServerError`]) vs
7//! terminal ([`Error::Rejected`]) vs rate-limited ([`Error::RateLimited`]) —
8//! lives in exactly one place, with one test suite. Service-specific detail
9//! (which server rejected, why) travels in the `message` field, never in the
10//! variant set.
11
12use std::time::Duration;
13
14use tonic::Code;
15
16use crate::retry::retry_after;
17use crate::token_cache::TokenCacheError;
18
19/// All errors returned by the Ppoppo gRPC client SDKs.
20///
21/// - [`Error::Rejected`] — the call reached the server and was turned down at
22///   the application layer (don't retry as-is).
23/// - [`Error::ServerError`] — a 5xx-class state (retry-eligible).
24/// - [`Error::Transport`] — the call never reached the server.
25/// - [`Error::RateLimited`] — the substrate-authoritative rate-limit denial
26///   (`ResourceExhausted`), carrying a `retry_after` hint for precise retry
27///   scheduling.
28#[derive(Debug, thiserror::Error)]
29#[non_exhaustive]
30pub enum Error {
31    /// Connection, TLS, or network-level failure.
32    #[error("transport error: {0}")]
33    Transport(String),
34
35    /// Bearer token acquisition failed before the gRPC call could be made.
36    #[error("token refresh failed: {0}")]
37    TokenRefresh(#[from] TokenCacheError),
38
39    /// The configured path prefix is malformed — caught at connect time.
40    #[error("invalid path prefix '{prefix}': {reason}")]
41    InvalidPathPrefix { prefix: String, reason: String },
42
43    /// The server returned a non-OK gRPC status at the application layer
44    /// (`InvalidArgument`, `NotFound`, `PermissionDenied`, `Unauthenticated`,
45    /// `FailedPrecondition`, `AlreadyExists`, `OutOfRange`, `Aborted`,
46    /// `Cancelled`).
47    ///
48    /// Caller's input, auth, or state is bad — do not retry as-is.
49    /// `ResourceExhausted` is **not** routed here; it surfaces as
50    /// [`Error::RateLimited`] which carries the `retry_after` hint.
51    #[error("rejected: {code:?} {message}")]
52    Rejected { code: Code, message: String },
53
54    /// The rate-limit substrate denied the call (`ResourceExhausted`).
55    ///
56    /// `retry_after` carries the substrate-authoritative reset duration when
57    /// the server attached `retry-after` metadata; `None` means the substrate
58    /// failed to surface a hint and callers should fall back to a default
59    /// backoff. Consumers SHOULD wait at least `retry_after` before retrying —
60    /// retrying earlier just trips the same bucket again (the reset instant has
61    /// not passed), burning attempts without making progress.
62    #[error("rate limited: {message}")]
63    RateLimited {
64        message: String,
65        retry_after: Option<Duration>,
66    },
67
68    /// The server returned a 5xx-class status (`Internal`, `Unknown`,
69    /// `DataLoss`, `Unimplemented`, `DeadlineExceeded`). Retry-eligible.
70    #[error("server error: {code:?} {message}")]
71    ServerError { code: Code, message: String },
72
73    /// A required proto field was absent or could not be mapped to a domain type.
74    #[error("unexpected proto response: {0}")]
75    ProtoMismatch(String),
76}
77
78impl Error {
79    /// Whether retrying the same call *as-is* may succeed.
80    ///
81    /// Mirrors the house `is_retryable` idiom (cf.
82    /// `ppoppo_error::PortErrorKind::is_retryable`): transient transport loss,
83    /// 5xx-class server errors, and rate-limit denials are retryable — the
84    /// last only after honoring the `retry_after` hint (retrying earlier trips
85    /// the same bucket). Application-layer rejections, a dead credential, a
86    /// malformed prefix, and proto-shape mismatches are terminal: retrying
87    /// reproduces the same failure.
88    ///
89    /// Note `ServerError` carries `tonic::Code::Unimplemented` (see
90    /// [`classify_status`]) into the retryable set: a rolling deploy can
91    /// transiently return it before the method is wired on every replica.
92    /// A *permanently* unimplemented method still fails after the retry
93    /// budget; a mid-deploy one recovers — this is an intentional bias for
94    /// the GKE rolling-update window, not an oversight.
95    ///
96    /// The match is exhaustive (no wildcard): a future `#[non_exhaustive]`
97    /// variant forces a compile error here rather than silently defaulting to
98    /// "not retryable", keeping the family retry taxonomy drift-proof.
99    #[must_use]
100    pub fn is_retryable(&self) -> bool {
101        match self {
102            Self::Transport(_) | Self::ServerError { .. } | Self::RateLimited { .. } => true,
103            Self::TokenRefresh(_)
104            | Self::InvalidPathPrefix { .. }
105            | Self::Rejected { .. }
106            | Self::ProtoMismatch(_) => false,
107        }
108    }
109}
110
111/// Classify a `tonic::Status` into an [`Error`] variant.
112///
113/// `ResourceExhausted` is split out into [`Error::RateLimited`], which extracts
114/// the `retry-after` metadata via [`crate::retry::retry_after`]. All other
115/// application-layer rejection codes collapse to [`Error::Rejected`]; 5xx-class
116/// codes to [`Error::ServerError`]; `Unavailable` to [`Error::Transport`].
117#[must_use]
118pub fn classify_status(status: &tonic::Status) -> Error {
119    let code = status.code();
120    let message = status.message().to_string();
121    match code {
122        Code::ResourceExhausted => Error::RateLimited {
123            message,
124            retry_after: retry_after(status),
125        },
126
127        Code::InvalidArgument
128        | Code::NotFound
129        | Code::AlreadyExists
130        | Code::PermissionDenied
131        | Code::Unauthenticated
132        | Code::FailedPrecondition
133        | Code::OutOfRange
134        | Code::Aborted
135        | Code::Cancelled => Error::Rejected { code, message },
136
137        Code::Internal | Code::Unknown | Code::DataLoss | Code::Unimplemented => {
138            Error::ServerError { code, message }
139        }
140
141        // DeadlineExceeded: most often upstream overload → retry-eligible.
142        Code::DeadlineExceeded => Error::ServerError { code, message },
143
144        // Unavailable is the canonical transient-transport code.
145        Code::Unavailable => Error::Transport(message),
146
147        // Ok shouldn't reach the classifier; degrade gracefully.
148        Code::Ok => Error::Transport(format!("classify_status called on Code::Ok: {message}")),
149    }
150}
151
152#[cfg(test)]
153#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
154mod tests {
155    use super::*;
156    use tonic::Status;
157    use tonic::metadata::MetadataValue;
158
159    #[test]
160    fn resource_exhausted_with_metadata_routes_to_rate_limited() {
161        let mut status = Status::resource_exhausted("Rate limit exceeded");
162        status
163            .metadata_mut()
164            .insert("retry-after", MetadataValue::from_static("60"));
165        match classify_status(&status) {
166            Error::RateLimited { message, retry_after } => {
167                assert_eq!(message, "Rate limit exceeded");
168                assert_eq!(retry_after, Some(Duration::from_secs(60)));
169            }
170            other => panic!("expected RateLimited, got {other:?}"),
171        }
172    }
173
174    #[test]
175    fn resource_exhausted_without_metadata_routes_to_rate_limited_none() {
176        // Substrate failure-open: header absent. The SDK still routes to
177        // RateLimited so callers pattern-match by semantic, not by code.
178        let status = Status::resource_exhausted("Rate limit exceeded");
179        match classify_status(&status) {
180            Error::RateLimited { retry_after, .. } => assert_eq!(retry_after, None),
181            other => panic!("expected RateLimited, got {other:?}"),
182        }
183    }
184
185    #[test]
186    fn invalid_argument_routes_to_rejected() {
187        let status = Status::invalid_argument("bad template");
188        match classify_status(&status) {
189            Error::Rejected { code, message } => {
190                assert_eq!(code, Code::InvalidArgument);
191                assert_eq!(message, "bad template");
192            }
193            other => panic!("expected Rejected, got {other:?}"),
194        }
195    }
196
197    #[test]
198    fn permission_denied_routes_to_rejected() {
199        // A perimeter that rejects non-app-credential callers with
200        // PermissionDenied must surface as a non-retryable Rejected.
201        let status = Status::permission_denied("ops face requires an application credential");
202        match classify_status(&status) {
203            Error::Rejected { code, .. } => assert_eq!(code, Code::PermissionDenied),
204            other => panic!("expected Rejected, got {other:?}"),
205        }
206    }
207
208    #[test]
209    fn internal_routes_to_server_error() {
210        let status = Status::internal("boom");
211        match classify_status(&status) {
212            Error::ServerError { code, .. } => assert_eq!(code, Code::Internal),
213            other => panic!("expected ServerError, got {other:?}"),
214        }
215    }
216
217    #[test]
218    fn unimplemented_routes_to_server_error() {
219        // A not-yet-wired RPC returns Unimplemented; classify as a
220        // retry-eligible ServerError.
221        let status = Status::unimplemented("not yet wired");
222        match classify_status(&status) {
223            Error::ServerError { code, .. } => assert_eq!(code, Code::Unimplemented),
224            other => panic!("expected ServerError, got {other:?}"),
225        }
226    }
227
228    #[test]
229    fn deadline_exceeded_routes_to_server_error() {
230        let status = Status::deadline_exceeded("slow upstream");
231        match classify_status(&status) {
232            Error::ServerError { code, .. } => assert_eq!(code, Code::DeadlineExceeded),
233            other => panic!("expected ServerError, got {other:?}"),
234        }
235    }
236
237    #[test]
238    fn unavailable_routes_to_transport() {
239        let status = Status::unavailable("conn refused");
240        match classify_status(&status) {
241            Error::Transport(msg) => assert_eq!(msg, "conn refused"),
242            other => panic!("expected Transport, got {other:?}"),
243        }
244    }
245
246    #[test]
247    fn ok_degrades_to_transport() {
248        // Ok should never reach the classifier; if it does, degrade rather
249        // than misclassify as a success-shaped error.
250        let status = Status::ok("");
251        match classify_status(&status) {
252            Error::Transport(_) => {}
253            other => panic!("expected Transport, got {other:?}"),
254        }
255    }
256
257    #[test]
258    fn is_retryable_partitions_the_variant_set() {
259        // Retryable: transient transport, 5xx server, rate-limit (after backoff).
260        assert!(Error::Transport("conn reset".to_owned()).is_retryable());
261        assert!(
262            Error::ServerError { code: Code::Internal, message: String::new() }.is_retryable()
263        );
264        assert!(
265            Error::RateLimited {
266                message: String::new(),
267                retry_after: Some(Duration::from_secs(1)),
268            }
269            .is_retryable()
270        );
271        // Terminal: dead credential, bad prefix, app rejection, proto drift.
272        assert!(!Error::TokenRefresh(TokenCacheError::Fetch("no token".to_owned())).is_retryable());
273        assert!(
274            !Error::InvalidPathPrefix { prefix: "/x".to_owned(), reason: String::new() }
275                .is_retryable()
276        );
277        assert!(
278            !Error::Rejected { code: Code::PermissionDenied, message: String::new() }
279                .is_retryable()
280        );
281        assert!(!Error::ProtoMismatch("missing field".to_owned()).is_retryable());
282    }
283}