Skip to main content

polyester/
errors.rs

1//! SDK error types (parity with Go/Python).
2
3use base64::Engine as _;
4use base64::engine::general_purpose::{STANDARD, STANDARD_NO_PAD};
5use buffa::{Enumeration, Message};
6use connectrpc::{ConnectError, ErrorCode, ErrorDetail};
7use thiserror::Error;
8
9use crate::proto::auth::v1::AuthErrorDetail;
10use crate::user_agent::{cloudflare_1010_message, is_cloudflare_browser_ban};
11
12/// Root result alias for the SDK.
13pub type Result<T> = std::result::Result<T, Error>;
14
15/// Polyester SDK error.
16#[derive(Debug, Clone, Error)]
17pub enum Error {
18    #[error("{0}")]
19    Auth(String),
20    #[error("{context}: permission denied (HTTP {status}, code {code}): {message} [{endpoint}]")]
21    PermissionDenied {
22        message: String,
23        status: u16,
24        code: String,
25        context: String,
26        endpoint: String,
27    },
28    #[error("{0}")]
29    Validation(String),
30    #[error("{0}")]
31    Transport(String),
32    /// A successful RPC returned a payload that violates the documented
33    /// response contract.
34    ///
35    /// This is not retryable: repeating a mutation blindly can duplicate work.
36    /// Because the server may already have accepted the mutation, callers must
37    /// reconcile when [`Self::mutation_outcome_unknown`] returns true.
38    #[error("{context}: response contract violation: {message}")]
39    ResponseContract { context: String, message: String },
40    #[error("{message}")]
41    RateLimit {
42        message: String,
43        retry_after: Option<f64>,
44    },
45    #[error("{0}")]
46    Server(String),
47    #[error("{message}")]
48    Api {
49        message: String,
50        code: String,
51        metadata: Vec<(String, String)>,
52    },
53    #[error(
54        "RPC not exposed on this API host{procedure}. The procedure may be unimplemented on \
55         devnet or disabled in this environment."
56    )]
57    RouteNotFound { procedure: String },
58    #[error("{0}")]
59    Realtime(String),
60    /// Realtime subscription queue was full; the subscription fails instead of
61    /// silently dropping updates.
62    #[error("{0}")]
63    QueueOverflow(String),
64}
65
66/// Stable auth.v1.AuthErrorDetail codes used for MFA control flow.
67/// Prefer these over ConnectError message text.
68pub mod auth_codes {
69    pub const MFA_NOT_ENROLLED: &str = "AUTH_MFA_NOT_ENROLLED";
70    pub const STEP_UP_REQUIRED: &str = "AUTH_STEP_UP_REQUIRED";
71    pub const MFA_ELEVATION_REQUIRED: &str = "AUTH_MFA_ELEVATION_REQUIRED";
72    pub const MFA_LAST_FACTOR_REQUIRED: &str = "AUTH_MFA_LAST_FACTOR_REQUIRED";
73}
74
75impl Error {
76    pub fn auth(msg: impl Into<String>) -> Self {
77        Self::Auth(msg.into())
78    }
79
80    pub fn validation(msg: impl Into<String>) -> Self {
81        Self::Validation(msg.into())
82    }
83
84    pub fn transport(msg: impl Into<String>) -> Self {
85        Self::Transport(msg.into())
86    }
87
88    pub fn response_contract(context: impl Into<String>, message: impl Into<String>) -> Self {
89        Self::ResponseContract {
90            context: context.into(),
91            message: message.into(),
92        }
93    }
94
95    pub fn realtime(msg: impl Into<String>) -> Self {
96        Self::Realtime(msg.into())
97    }
98
99    pub fn queue_overflow(msg: impl Into<String>) -> Self {
100        Self::QueueOverflow(msg.into())
101    }
102
103    /// Whether retrying may succeed after backoff.
104    ///
105    /// This is a transport-level classification, not a guarantee that a
106    /// mutation was not applied. For mutations, preserve the same idempotency
107    /// key and reconcile server state before retrying when
108    /// [`Self::mutation_outcome_unknown`] is true.
109    pub fn is_retryable(&self) -> bool {
110        matches!(
111            self,
112            Self::Transport(_) | Self::RateLimit { .. } | Self::Server(_)
113        )
114    }
115
116    /// Whether this error can occur after the server accepted a mutation.
117    ///
118    /// Callers must treat these failures as ambiguous: reconcile first and
119    /// reuse the original idempotency key if a retry is necessary.
120    pub fn mutation_outcome_unknown(&self) -> bool {
121        matches!(
122            self,
123            Self::Transport(_) | Self::ResponseContract { .. } | Self::Server(_)
124        )
125    }
126
127    /// Server-requested retry delay in seconds, when supplied.
128    pub fn retry_after(&self) -> Option<f64> {
129        match self {
130            Self::RateLimit { retry_after, .. } => *retry_after,
131            _ => None,
132        }
133    }
134
135    /// Structured auth.v1.AuthErrorDetail code when this is an [`Error::Api`].
136    pub fn auth_error_code(&self) -> Option<&str> {
137        match self {
138            Self::Api { code, .. } => Some(code.as_str()),
139            _ => None,
140        }
141    }
142
143    /// True when the caller must enroll an MFA factor before continuing.
144    pub fn is_mfa_enrollment_required(&self) -> bool {
145        self.auth_error_code() == Some(auth_codes::MFA_NOT_ENROLLED)
146    }
147
148    /// True when the caller must retry with a fresh `X-Auth-Step-Up` proof.
149    pub fn is_step_up_required(&self) -> bool {
150        self.auth_error_code() == Some(auth_codes::STEP_UP_REQUIRED)
151    }
152
153    /// True when the caller needs a recent MFA-elevated interactive session.
154    pub fn is_mfa_elevation_required(&self) -> bool {
155        self.auth_error_code() == Some(auth_codes::MFA_ELEVATION_REQUIRED)
156    }
157
158    /// True when the final active MFA factor cannot be removed.
159    pub fn is_mfa_last_factor_required(&self) -> bool {
160        self.auth_error_code() == Some(auth_codes::MFA_LAST_FACTOR_REQUIRED)
161    }
162}
163
164fn decode_auth_error_detail(detail: &ErrorDetail) -> Option<AuthErrorDetail> {
165    if !detail.type_url.ends_with("auth.v1.AuthErrorDetail") {
166        return None;
167    }
168    let value = detail.value.as_ref()?;
169    let bytes = STANDARD_NO_PAD
170        .decode(value)
171        .or_else(|_| STANDARD.decode(value))
172        .ok()?;
173    AuthErrorDetail::decode_from_slice(&bytes).ok()
174}
175
176fn parse_nonnegative_f64(value: &http::HeaderValue) -> Option<f64> {
177    let parsed = value.to_str().ok()?.trim().parse::<f64>().ok()?;
178    (parsed.is_finite() && parsed >= 0.0).then_some(parsed)
179}
180
181fn retry_after_seconds(err: &ConnectError) -> Option<f64> {
182    for headers in [err.response_headers(), err.trailers()] {
183        if let Some(seconds) = headers.get("retry-after").and_then(parse_nonnegative_f64) {
184            return Some(seconds);
185        }
186        for name in ["retry-after-ms", "grpc-retry-pushback-ms"] {
187            if let Some(milliseconds) = headers.get(name).and_then(parse_nonnegative_f64) {
188                return Some(milliseconds / 1_000.0);
189            }
190        }
191    }
192    None
193}
194
195/// Map a ConnectRPC error into an SDK error.
196pub fn map_connect_error(err: ConnectError) -> Error {
197    let fallback_message = {
198        let message = err.to_string();
199        if message.trim().is_empty() {
200            "request failed without server error details".to_owned()
201        } else {
202            message
203        }
204    };
205    for detail in &err.details {
206        if let Some(auth_detail) = decode_auth_error_detail(detail) {
207            let code = auth_detail
208                .code
209                .as_known()
210                .map(|c| c.proto_name().to_owned())
211                .unwrap_or_else(|| "AUTH_UNSPECIFIED".to_owned());
212            let message = if auth_detail.message.is_empty() {
213                fallback_message.clone()
214            } else {
215                auth_detail.message
216            };
217            return Error::Api {
218                message,
219                code,
220                metadata: Vec::new(),
221            };
222        }
223    }
224    let code = err.code;
225    let retry_after = retry_after_seconds(&err);
226    let message = fallback_message;
227    if is_cloudflare_browser_ban(&message) {
228        return Error::Transport(cloudflare_1010_message());
229    }
230    match code {
231        ErrorCode::Unauthenticated | ErrorCode::PermissionDenied => Error::Auth(message),
232        ErrorCode::ResourceExhausted => Error::RateLimit {
233            message,
234            retry_after,
235        },
236        ErrorCode::Unavailable | ErrorCode::Internal => Error::Server(message),
237        ErrorCode::DeadlineExceeded => Error::Transport(message),
238        ErrorCode::Unimplemented => {
239            if message.contains("not found")
240                || message.contains("unimplemented")
241                || message.contains("404")
242            {
243                Error::RouteNotFound {
244                    procedure: String::new(),
245                }
246            } else {
247                Error::Api {
248                    message,
249                    code: format!("{code:?}"),
250                    metadata: Vec::new(),
251                }
252            }
253        }
254        _ => Error::Api {
255            message,
256            code: format!("{code:?}"),
257            metadata: Vec::new(),
258        },
259    }
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265    use crate::proto::auth::v1::AuthErrorCode;
266    use buffa::EnumValue;
267
268    fn map_auth(code: AuthErrorCode, message: &str) -> Error {
269        let detail_msg = AuthErrorDetail {
270            code: EnumValue::Known(code),
271            message: message.into(),
272            ..Default::default()
273        };
274        map_connect_error(ConnectError::permission_denied("denied").with_detail(
275            ErrorDetail::from_message("auth.v1.AuthErrorDetail", &detail_msg),
276        ))
277    }
278
279    #[test]
280    fn map_connect_error_surfaces_auth_revision_conflict() {
281        match map_auth(AuthErrorCode::AUTH_REVISION_CONFLICT, "resource changed") {
282            Error::Api { message, code, .. } => {
283                assert_eq!(code, "AUTH_REVISION_CONFLICT");
284                assert_eq!(message, "resource changed");
285            }
286            other => panic!("unexpected error: {other:?}"),
287        }
288    }
289
290    #[test]
291    fn map_connect_error_never_returns_an_empty_auth_message() {
292        let mapped = map_connect_error(ConnectError::unauthenticated(""));
293        match mapped {
294            Error::Auth(message) => assert!(!message.trim().is_empty()),
295            other => panic!("unexpected error: {other:?}"),
296        }
297    }
298
299    #[test]
300    fn map_connect_error_surfaces_rate_limits() {
301        let mut headers = http::HeaderMap::new();
302        headers.insert("retry-after", http::HeaderValue::from_static("2.5"));
303        let mapped = map_connect_error(
304            ConnectError::new(ErrorCode::ResourceExhausted, "request rate exceeded")
305                .with_headers(headers),
306        );
307        match mapped {
308            Error::RateLimit {
309                message,
310                retry_after,
311            } => {
312                assert_eq!(message, "resource_exhausted: request rate exceeded");
313                assert_eq!(retry_after, Some(2.5));
314            }
315            other => panic!("unexpected error: {other:?}"),
316        }
317    }
318
319    #[test]
320    fn map_connect_error_reads_retry_pushback_milliseconds_from_trailers() {
321        let mut trailers = http::HeaderMap::new();
322        trailers.insert(
323            "grpc-retry-pushback-ms",
324            http::HeaderValue::from_static("1250"),
325        );
326        let mapped = map_connect_error(
327            ConnectError::new(ErrorCode::ResourceExhausted, "slow down").with_trailers(trailers),
328        );
329        assert_eq!(mapped.retry_after(), Some(1.25));
330    }
331
332    #[test]
333    fn retry_classification_is_conservative_for_mutations() {
334        let timeout = Error::transport("deadline exceeded");
335        assert!(timeout.is_retryable());
336        assert!(timeout.mutation_outcome_unknown());
337
338        let limited = Error::RateLimit {
339            message: "slow down".into(),
340            retry_after: Some(1.0),
341        };
342        assert!(limited.is_retryable());
343        assert!(!limited.mutation_outcome_unknown());
344
345        let contract =
346            Error::response_contract("BatchCreateOrders", "reported counts do not match items");
347        assert!(!contract.is_retryable());
348        assert!(contract.mutation_outcome_unknown());
349
350        assert!(!Error::validation("bad price").is_retryable());
351    }
352
353    #[test]
354    fn map_connect_error_surfaces_stable_mfa_codes() {
355        let cases = [
356            (
357                AuthErrorCode::AUTH_MFA_NOT_ENROLLED,
358                auth_codes::MFA_NOT_ENROLLED,
359                Error::is_mfa_enrollment_required as fn(&Error) -> bool,
360            ),
361            (
362                AuthErrorCode::AUTH_STEP_UP_REQUIRED,
363                auth_codes::STEP_UP_REQUIRED,
364                Error::is_step_up_required,
365            ),
366            (
367                AuthErrorCode::AUTH_MFA_ELEVATION_REQUIRED,
368                auth_codes::MFA_ELEVATION_REQUIRED,
369                Error::is_mfa_elevation_required,
370            ),
371            (
372                AuthErrorCode::AUTH_MFA_LAST_FACTOR_REQUIRED,
373                auth_codes::MFA_LAST_FACTOR_REQUIRED,
374                Error::is_mfa_last_factor_required,
375            ),
376        ];
377        for (proto_code, want, predicate) in cases {
378            let mapped = map_auth(proto_code, "mfa control flow");
379            assert_eq!(mapped.auth_error_code(), Some(want));
380            assert!(predicate(&mapped));
381            for (_, other_code, other_predicate) in cases {
382                if other_code == want {
383                    continue;
384                }
385                assert!(!other_predicate(&mapped));
386            }
387        }
388    }
389
390    #[test]
391    fn mfa_predicates_ignore_message_text() {
392        assert!(!Error::Auth("must enroll mfa".into()).is_mfa_enrollment_required());
393        assert!(
394            !Error::Api {
395                message: "step-up required".into(),
396                code: "permission_denied".into(),
397                metadata: Vec::new(),
398            }
399            .is_step_up_required()
400        );
401        assert!(
402            !Error::Api {
403                message: "api key mfa".into(),
404                code: "AUTH_API_KEY_MFA_REQUIRED".into(),
405                metadata: Vec::new(),
406            }
407            .is_mfa_enrollment_required()
408        );
409    }
410}