Skip to main content

lastid_sdk/error/
http.rs

1//! HTTP client errors with retry classification.
2
3use thiserror::Error;
4
5/// HTTP client errors with retry classification.
6///
7/// Categorizes HTTP errors for proper retry handling:
8/// - Network errors and timeouts are retryable
9/// - 5xx server errors are retryable
10/// - 429 rate limit responses include `retry_after_seconds`
11/// - 4xx client errors are non-retryable
12/// - CORS errors (WASM only) with remediation hints
13#[derive(Debug, Error)]
14#[non_exhaustive]
15pub enum HttpError {
16    /// Network connectivity error (retryable)
17    #[error("Network error: {0}")]
18    Network(String),
19
20    /// HTTP status error
21    #[error("HTTP {status}: {message}")]
22    Status {
23        /// HTTP status code
24        status: u16,
25        /// Error message from response
26        message: String,
27    },
28
29    /// Rate limited (retryable with backoff)
30    #[error("Rate limited: retry after {retry_after_seconds}s")]
31    RateLimited {
32        /// Seconds to wait before retrying
33        retry_after_seconds: u64,
34    },
35
36    /// Request timeout (retryable)
37    #[error("Request timeout")]
38    Timeout,
39
40    /// CORS error (WASM only, not retryable)
41    ///
42    /// This error occurs when the browser blocks a cross-origin request.
43    /// The IDP server must include proper CORS headers for the SDK to work in
44    /// browsers.
45    #[error("CORS error: {message}. {remediation}")]
46    Cors {
47        /// Error description
48        message: String,
49        /// Remediation hint
50        remediation: String,
51    },
52}
53
54impl HttpError {
55    /// Check if this error is retryable.
56    ///
57    /// Returns `true` for:
58    /// - Network errors
59    /// - Timeouts
60    /// - 5xx server errors
61    /// - Rate limiting (after waiting)
62    ///
63    /// CORS errors are NOT retryable - they require server configuration
64    /// changes.
65    #[must_use]
66    pub const fn is_retryable(&self) -> bool {
67        match self {
68            Self::Status { status, .. } => *status >= 500,
69            Self::Network(_) | Self::Timeout | Self::RateLimited { .. } => true,
70            Self::Cors { .. } => false, // CORS requires server config, not retryable
71        }
72    }
73
74    /// Check if this error indicates rate limiting.
75    #[must_use]
76    pub const fn is_rate_limited(&self) -> bool {
77        matches!(
78            self,
79            Self::RateLimited { .. } | Self::Status { status: 429, .. }
80        )
81    }
82
83    /// Get retry-after seconds if rate limited.
84    #[must_use]
85    pub const fn retry_after(&self) -> Option<u64> {
86        match self {
87            Self::RateLimited {
88                retry_after_seconds,
89            } => Some(*retry_after_seconds),
90            _ => None,
91        }
92    }
93
94    /// Create a network error.
95    #[must_use]
96    pub fn network(message: impl Into<String>) -> Self {
97        Self::Network(message.into())
98    }
99
100    /// Create a status error.
101    #[must_use]
102    pub fn status(status: u16, message: impl Into<String>) -> Self {
103        Self::Status {
104            status,
105            message: message.into(),
106        }
107    }
108
109    /// Create a rate limited error.
110    #[must_use]
111    pub const fn rate_limited(retry_after_seconds: u64) -> Self {
112        Self::RateLimited {
113            retry_after_seconds,
114        }
115    }
116
117    /// Get suggested retry delay in milliseconds.
118    ///
119    /// Returns appropriate delay based on error type:
120    /// - Network errors: 500ms (short delay, transient issues)
121    /// - Timeout: 1000ms (moderate delay)
122    /// - 5xx errors: 2000ms (server needs time to recover)
123    /// - Rate limited: `retry_after_seconds * 1000` (respect server's hint)
124    /// - 4xx errors: None (not retryable)
125    /// - CORS errors: None (requires server configuration)
126    #[must_use]
127    pub const fn suggested_retry_delay_ms(&self) -> Option<u64> {
128        match self {
129            Self::Network(_) => Some(500), // Short delay for transient network issues
130            Self::Timeout => Some(1000),   // Moderate delay for timeouts
131            Self::Status { status, .. } if *status >= 500 => Some(2000), // Server errors
132            Self::RateLimited {
133                retry_after_seconds,
134            } => Some(*retry_after_seconds * 1000),
135            Self::Status { .. } | Self::Cors { .. } => None, // Not retryable
136        }
137    }
138
139    /// Get the HTTP status code if this is a status error.
140    #[must_use]
141    pub const fn status_code(&self) -> Option<u16> {
142        match self {
143            Self::Status { status, .. } => Some(*status),
144            Self::RateLimited { .. } => Some(429),
145            _ => None,
146        }
147    }
148
149    /// Get the error category for metrics and logging.
150    #[must_use]
151    pub const fn category(&self) -> &'static str {
152        match self {
153            Self::Network(_) => "network",
154            Self::Timeout => "timeout",
155            Self::RateLimited { .. } => "rate_limited",
156            Self::Status { status, .. } if *status >= 500 => "server_error",
157            Self::Status { status, .. } if *status >= 400 => "client_error",
158            Self::Status { .. } => "unknown_status",
159            Self::Cors { .. } => "cors",
160        }
161    }
162
163    /// Create a CORS error with remediation hints.
164    ///
165    /// Use this when a request fails due to CORS policy in WASM environments.
166    ///
167    /// # Example
168    ///
169    /// ```rust
170    /// use lastid_sdk::HttpError;
171    ///
172    /// let err = HttpError::cors(
173    ///     "Request blocked by CORS policy",
174    ///     "https://human.lastid.co",
175    /// );
176    /// assert!(!err.is_retryable());
177    /// ```
178    #[must_use]
179    pub fn cors(message: impl Into<String>, origin: &str) -> Self {
180        Self::Cors {
181            message: message.into(),
182            remediation: format!(
183                "Ensure the IDP server includes CORS headers: \
184                 Access-Control-Allow-Origin: {origin}, \
185                 Access-Control-Allow-Headers: Authorization, Content-Type, DPoP, \
186                 Access-Control-Allow-Methods: GET, POST, OPTIONS"
187            ),
188        }
189    }
190
191    /// Create a CORS error with custom remediation.
192    #[must_use]
193    pub fn cors_with_remediation(
194        message: impl Into<String>,
195        remediation: impl Into<String>,
196    ) -> Self {
197        Self::Cors {
198            message: message.into(),
199            remediation: remediation.into(),
200        }
201    }
202
203    /// Check if this error is a CORS error.
204    #[must_use]
205    pub const fn is_cors(&self) -> bool {
206        matches!(self, Self::Cors { .. })
207    }
208}