jenkins_sdk/
error.rs

1use http::{Method, StatusCode};
2use std::{error::Error as StdError, fmt, time::Duration};
3use thiserror::Error;
4use url::Url;
5
6pub type Result<T> = std::result::Result<T, Error>;
7
8#[derive(Debug, Clone, Copy)]
9pub struct BodySnippetConfig {
10    pub enabled: bool,
11    pub max_bytes: usize,
12}
13
14impl Default for BodySnippetConfig {
15    fn default() -> Self {
16        Self {
17            enabled: true,
18            max_bytes: 4096,
19        }
20    }
21}
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24#[non_exhaustive]
25pub enum ErrorKind {
26    Auth,
27    NotFound,
28    Conflict,
29    RateLimited,
30    Api,
31    Transport,
32    Decode,
33    InvalidConfig,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37#[non_exhaustive]
38pub enum TransportErrorKind {
39    Timeout,
40    Connect,
41    Other,
42}
43
44#[derive(Debug, Clone)]
45pub struct HttpError {
46    pub status: StatusCode,
47    pub method: Method,
48    /// Sanitized URL: no query/fragment/userinfo.
49    pub url: Box<Url>,
50    pub message: Option<Box<str>>,
51    pub request_id: Option<Box<str>>,
52    pub body_snippet: Option<Box<str>>,
53}
54
55impl HttpError {
56    #[must_use]
57    pub fn path(&self) -> &str {
58        self.url.path()
59    }
60}
61
62/// All errors returned by the SDK.
63#[derive(Debug, Error)]
64#[non_exhaustive]
65pub enum Error {
66    #[error("{0}")]
67    Auth(HttpError),
68
69    #[error("{0}")]
70    NotFound(HttpError),
71
72    #[error("{0}")]
73    Conflict(HttpError),
74
75    #[error("{error}")]
76    RateLimited {
77        error: HttpError,
78        retry_after: Option<Duration>,
79    },
80
81    #[error("{0}")]
82    Api(HttpError),
83
84    #[error("Transport error during {method} {path}: {source}")]
85    Transport {
86        method: Method,
87        path: Box<str>,
88        kind: TransportErrorKind,
89        #[source]
90        source: Box<dyn StdError + Send + Sync>,
91    },
92
93    #[error("Decode error (HTTP {status}) during {method} {path}: {source}")]
94    Decode {
95        status: StatusCode,
96        method: Method,
97        path: Box<str>,
98        request_id: Option<Box<str>>,
99        body_snippet: Option<Box<str>>,
100        #[source]
101        source: Box<dyn StdError + Send + Sync>,
102    },
103
104    #[error("Invalid configuration: {message}")]
105    InvalidConfig {
106        message: Box<str>,
107        #[source]
108        source: Option<Box<dyn StdError + Send + Sync>>,
109    },
110}
111
112impl Error {
113    #[must_use]
114    pub fn kind(&self) -> ErrorKind {
115        match self {
116            Self::Auth(_) => ErrorKind::Auth,
117            Self::NotFound(_) => ErrorKind::NotFound,
118            Self::Conflict(_) => ErrorKind::Conflict,
119            Self::RateLimited { .. } => ErrorKind::RateLimited,
120            Self::Api(_) => ErrorKind::Api,
121            Self::Transport { .. } => ErrorKind::Transport,
122            Self::Decode { .. } => ErrorKind::Decode,
123            Self::InvalidConfig { .. } => ErrorKind::InvalidConfig,
124        }
125    }
126
127    #[must_use]
128    pub fn status(&self) -> Option<StatusCode> {
129        match self {
130            Self::Auth(e) | Self::NotFound(e) | Self::Conflict(e) | Self::Api(e) => Some(e.status),
131            Self::RateLimited { error, .. } => Some(error.status),
132            Self::Decode { status, .. } => Some(*status),
133            Self::Transport { .. } | Self::InvalidConfig { .. } => None,
134        }
135    }
136
137    #[must_use]
138    pub fn request_id(&self) -> Option<&str> {
139        match self {
140            Self::Auth(e) | Self::NotFound(e) | Self::Conflict(e) | Self::Api(e) => {
141                e.request_id.as_deref()
142            }
143            Self::RateLimited { error, .. } => error.request_id.as_deref(),
144            Self::Decode { request_id, .. } => request_id.as_deref(),
145            Self::Transport { .. } | Self::InvalidConfig { .. } => None,
146        }
147    }
148
149    #[must_use]
150    pub fn retry_after(&self) -> Option<Duration> {
151        match self {
152            Self::RateLimited { retry_after, .. } => *retry_after,
153            _ => None,
154        }
155    }
156
157    #[must_use]
158    pub fn is_auth_error(&self) -> bool {
159        matches!(self, Self::Auth(_))
160    }
161
162    #[must_use]
163    pub fn is_retryable(&self) -> bool {
164        match self {
165            Self::RateLimited { .. } => true,
166            Self::Api(e) => matches!(
167                e.status,
168                StatusCode::BAD_GATEWAY
169                    | StatusCode::SERVICE_UNAVAILABLE
170                    | StatusCode::GATEWAY_TIMEOUT
171            ),
172            Self::Transport { kind, .. } => matches!(
173                kind,
174                TransportErrorKind::Timeout | TransportErrorKind::Connect
175            ),
176            _ => false,
177        }
178    }
179
180    pub(crate) fn from_http(error: HttpError, retry_after: Option<Duration>) -> Self {
181        match error.status {
182            StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => Self::Auth(error),
183            StatusCode::NOT_FOUND => Self::NotFound(error),
184            StatusCode::CONFLICT | StatusCode::PRECONDITION_FAILED => Self::Conflict(error),
185            StatusCode::TOO_MANY_REQUESTS => Self::RateLimited { error, retry_after },
186            _ => Self::Api(error),
187        }
188    }
189}
190
191impl fmt::Display for HttpError {
192    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
193        write!(f, "HTTP {} ({} {})", self.status, self.method, self.path())?;
194        if let Some(message) = self.message.as_deref() {
195            write!(f, ": {message}")?;
196        }
197        if let Some(request_id) = self.request_id.as_deref() {
198            write!(f, " [request-id: {request_id}]")?;
199        }
200        Ok(())
201    }
202}