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}