Skip to main content

dingtalk_sdk/
error.rs

1use std::time::{Duration, SystemTime, SystemTimeError};
2
3use std::fmt;
4use thiserror::Error;
5
6/// SDK result type.
7pub type Result<T> = std::result::Result<T, Error>;
8
9/// Structured HTTP error context.
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct HttpError {
12    /// HTTP status code.
13    pub status: u16,
14    /// Optional short message from upstream/client.
15    pub message: Option<String>,
16    /// Optional request identifier from upstream.
17    pub request_id: Option<String>,
18    /// Optional redacted response body snippet.
19    pub body_snippet: Option<String>,
20}
21
22impl fmt::Display for HttpError {
23    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
24        write!(f, "HTTP {}", self.status)?;
25        if let Some(message) = &self.message {
26            write!(f, ": {message}")?;
27        }
28        if let Some(request_id) = &self.request_id {
29            write!(f, " [request-id: {request_id}]")?;
30        }
31        Ok(())
32    }
33}
34
35/// Structured transport error context.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct TransportError {
38    /// Optional HTTP status code if available.
39    pub status: Option<u16>,
40    /// Optional short message from client/runtime.
41    pub message: Option<String>,
42    /// Optional request identifier from upstream.
43    pub request_id: Option<String>,
44    /// Optional redacted response body snippet.
45    pub body_snippet: Option<String>,
46    /// Optional retry-after hint from upstream.
47    pub retry_after: Option<Duration>,
48    /// Whether the error is likely retryable.
49    pub retryable: bool,
50    /// Stable transport error code derived from reqx.
51    pub code: &'static str,
52    /// Optional HTTP method for the failed request.
53    pub method: Option<String>,
54    /// Optional redacted request URI/path for the failed request.
55    pub uri: Option<String>,
56    /// Optional timeout phase when the transport failed due to timeout.
57    pub timeout_phase: Option<&'static str>,
58    /// Optional lower-level transport kind when available.
59    pub transport_kind: Option<&'static str>,
60}
61
62impl fmt::Display for TransportError {
63    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
64        if let Some(status) = self.status {
65            write!(f, "HTTP {status}")?;
66        } else {
67            write!(f, "request failed")?;
68        }
69        if let Some(message) = &self.message {
70            write!(f, ": {message}")?;
71        }
72        if let Some(request_id) = &self.request_id {
73            write!(f, " [request-id: {request_id}]")?;
74        }
75        Ok(())
76    }
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80#[non_exhaustive]
81/// Stable high-level error category.
82pub enum ErrorKind {
83    /// Authentication and authorization errors.
84    Auth,
85    /// Resource not found.
86    NotFound,
87    /// Resource conflict or precondition conflict.
88    Conflict,
89    /// Request rate-limited.
90    RateLimited,
91    /// DingTalk API business error (`errcode != 0`).
92    Api,
93    /// Transport-level client/network error.
94    Transport,
95    /// Serialization or deserialization error.
96    Serialization,
97    /// Timestamp generation failure.
98    Timestamp,
99    /// Signature generation failure.
100    Signature,
101    /// Invalid SDK configuration.
102    InvalidConfig,
103}
104
105#[derive(Debug, Error)]
106#[non_exhaustive]
107/// Unified SDK error type.
108pub enum Error {
109    /// API business error returned by DingTalk.
110    #[error("API error (code={code}): {message}")]
111    Api {
112        /// DingTalk error code.
113        code: i64,
114        /// DingTalk error message.
115        message: String,
116        /// Optional request identifier.
117        request_id: Option<String>,
118        /// Optional redacted response body snippet.
119        body_snippet: Option<String>,
120    },
121
122    /// Authentication/authorization HTTP error.
123    #[error("Authentication failed: {0}")]
124    Auth(HttpError),
125
126    /// Not-found HTTP error.
127    #[error("Resource not found: {0}")]
128    NotFound(HttpError),
129
130    /// Conflict HTTP error.
131    #[error("Resource conflict: {0}")]
132    Conflict(HttpError),
133
134    /// Rate limit error with optional retry hint.
135    #[error("Rate limited: {error}")]
136    RateLimited {
137        /// Underlying HTTP error.
138        error: HttpError,
139        /// Retry-after hint parsed from response headers if available.
140        retry_after: Option<Duration>,
141    },
142
143    /// Transport error from HTTP runtime/client.
144    #[error("HTTP transport error: {0}")]
145    Transport(Box<TransportError>),
146
147    /// JSON serialization/deserialization error.
148    #[error("Serialization error: {0}")]
149    Serialization(#[from] serde_json::Error),
150
151    /// System timestamp retrieval error.
152    #[error("Timestamp generation failed: {0}")]
153    Timestamp(#[from] SystemTimeError),
154
155    /// Signature generation error.
156    #[error("Signature generation failed")]
157    Signature,
158
159    /// Invalid runtime configuration.
160    #[error("Invalid configuration: {message}")]
161    InvalidConfig {
162        /// Human-readable reason.
163        message: String,
164        /// Optional source error.
165        source: Option<Box<dyn std::error::Error + Send + Sync>>,
166    },
167}
168
169fn reqx_timeout_phase_name(error: &reqx::Error) -> Option<&'static str> {
170    match error {
171        reqx::Error::Timeout { phase, .. } => Some(match phase {
172            reqx::TimeoutPhase::Transport => "transport",
173            reqx::TimeoutPhase::ResponseBody => "response_body",
174        }),
175        _ => None,
176    }
177}
178
179fn reqx_transport_kind_name(error: &reqx::Error) -> Option<&'static str> {
180    match error {
181        reqx::Error::Transport { kind, .. } => Some(match kind {
182            reqx::TransportErrorKind::Dns => "dns",
183            reqx::TransportErrorKind::Connect => "connect",
184            reqx::TransportErrorKind::Tls => "tls",
185            reqx::TransportErrorKind::Read => "read",
186            reqx::TransportErrorKind::Other => "other",
187        }),
188        _ => None,
189    }
190}
191
192impl From<reqx::Error> for Error {
193    fn from(source: reqx::Error) -> Self {
194        let code = source.code().as_str();
195        let status = source.status_code();
196        let request_id = source.request_id().map(ToOwned::to_owned);
197        let retry_after = source.retry_after(SystemTime::now());
198        let method = source.request_method().map(ToString::to_string);
199        let uri = source.request_uri_redacted_owned();
200        let timeout_phase = reqx_timeout_phase_name(&source);
201        let transport_kind = reqx_transport_kind_name(&source);
202        let retryable = match source.code() {
203            reqx::ErrorCode::Timeout
204            | reqx::ErrorCode::DeadlineExceeded
205            | reqx::ErrorCode::Transport
206            | reqx::ErrorCode::RetryBudgetExhausted
207            | reqx::ErrorCode::CircuitOpen => true,
208            reqx::ErrorCode::HttpStatus => matches!(status, Some(429 | 500..=599)),
209            _ => false,
210        };
211
212        if let Some(status) = status {
213            let error = HttpError {
214                status,
215                message: None,
216                request_id: request_id.clone(),
217                body_snippet: None,
218            };
219
220            return match status {
221                401 | 403 => Self::Auth(error),
222                404 => Self::NotFound(error),
223                409 | 412 => Self::Conflict(error),
224                429 => Self::RateLimited { retry_after, error },
225                _ => Self::Transport(Box::new(TransportError {
226                    status: Some(status),
227                    message: None,
228                    request_id,
229                    body_snippet: None,
230                    retry_after,
231                    retryable,
232                    code,
233                    method,
234                    uri,
235                    timeout_phase,
236                    transport_kind,
237                })),
238            };
239        }
240
241        Self::Transport(Box::new(TransportError {
242            status: None,
243            message: Some(source.to_string()),
244            request_id,
245            body_snippet: None,
246            retry_after,
247            retryable,
248            code,
249            method,
250            uri,
251            timeout_phase,
252            transport_kind,
253        }))
254    }
255}
256
257impl Error {
258    /// Returns a stable high-level error category.
259    #[must_use]
260    pub fn kind(&self) -> ErrorKind {
261        match self {
262            Self::Auth(_) => ErrorKind::Auth,
263            Self::NotFound(_) => ErrorKind::NotFound,
264            Self::Conflict(_) => ErrorKind::Conflict,
265            Self::RateLimited { .. } => ErrorKind::RateLimited,
266            Self::Api { .. } => ErrorKind::Api,
267            Self::Transport(_) => ErrorKind::Transport,
268            Self::Serialization(_) => ErrorKind::Serialization,
269            Self::Timestamp(_) => ErrorKind::Timestamp,
270            Self::Signature => ErrorKind::Signature,
271            Self::InvalidConfig { .. } => ErrorKind::InvalidConfig,
272        }
273    }
274
275    /// Returns HTTP status code when present.
276    #[must_use]
277    pub fn status(&self) -> Option<u16> {
278        match self {
279            Self::Auth(error) | Self::NotFound(error) | Self::Conflict(error) => Some(error.status),
280            Self::RateLimited { error, .. } => Some(error.status),
281            Self::Transport(error) => error.status,
282            _ => None,
283        }
284    }
285
286    /// Returns DingTalk/transport request-id when present.
287    #[must_use]
288    pub fn request_id(&self) -> Option<&str> {
289        match self {
290            Self::Api { request_id, .. } => request_id.as_deref(),
291            Self::Auth(error) | Self::NotFound(error) | Self::Conflict(error) => {
292                error.request_id.as_deref()
293            }
294            Self::RateLimited { error, .. } => error.request_id.as_deref(),
295            Self::Transport(error) => error.request_id.as_deref(),
296            _ => None,
297        }
298    }
299
300    /// Returns redacted body snippet when retained by the SDK.
301    #[must_use]
302    pub fn body_snippet(&self) -> Option<&str> {
303        match self {
304            Self::Api { body_snippet, .. } => body_snippet.as_deref(),
305            Self::Auth(error) | Self::NotFound(error) | Self::Conflict(error) => {
306                error.body_snippet.as_deref()
307            }
308            Self::RateLimited { error, .. } => error.body_snippet.as_deref(),
309            Self::Transport(error) => error.body_snippet.as_deref(),
310            _ => None,
311        }
312    }
313
314    /// Returns `true` if the error is an auth/authz failure.
315    #[must_use]
316    pub fn is_auth_error(&self) -> bool {
317        matches!(self, Self::Auth(_))
318    }
319
320    /// Returns `true` if the error is likely transient and safe to retry.
321    #[must_use]
322    pub fn is_retryable(&self) -> bool {
323        match self {
324            Self::RateLimited { .. } => true,
325            Self::Transport(error) => error.retryable,
326            Self::Api { code, .. } => matches!(*code, 130101 | 130102),
327            _ => false,
328        }
329    }
330
331    /// Returns retry-after hint when the upstream provided one.
332    #[must_use]
333    pub fn retry_after(&self) -> Option<Duration> {
334        match self {
335            Self::RateLimited { retry_after, .. } => *retry_after,
336            Self::Transport(error) => error.retry_after,
337            _ => None,
338        }
339    }
340}