1use 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
12pub type Result<T> = std::result::Result<T, Error>;
14
15#[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 #[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 #[error("{0}")]
63 QueueOverflow(String),
64}
65
66pub 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 pub fn is_retryable(&self) -> bool {
110 matches!(
111 self,
112 Self::Transport(_) | Self::RateLimit { .. } | Self::Server(_)
113 )
114 }
115
116 pub fn mutation_outcome_unknown(&self) -> bool {
121 matches!(
122 self,
123 Self::Transport(_) | Self::ResponseContract { .. } | Self::Server(_)
124 )
125 }
126
127 pub fn retry_after(&self) -> Option<f64> {
129 match self {
130 Self::RateLimit { retry_after, .. } => *retry_after,
131 _ => None,
132 }
133 }
134
135 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 pub fn is_mfa_enrollment_required(&self) -> bool {
145 self.auth_error_code() == Some(auth_codes::MFA_NOT_ENROLLED)
146 }
147
148 pub fn is_step_up_required(&self) -> bool {
150 self.auth_error_code() == Some(auth_codes::STEP_UP_REQUIRED)
151 }
152
153 pub fn is_mfa_elevation_required(&self) -> bool {
155 self.auth_error_code() == Some(auth_codes::MFA_ELEVATION_REQUIRED)
156 }
157
158 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
195pub 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}