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}