Skip to main content

webscrape_ai/
error.rs

1//! Error taxonomy.
2
3use std::fmt;
4use std::time::Duration;
5
6use serde_json::Value;
7
8use crate::response::{RunStatus, SmartBrowseRun};
9
10/// Convenience alias for `Result<T, webscrape_ai::Error>`.
11pub type Result<T> = std::result::Result<T, Error>;
12
13/// Top-level error returned by every SDK method.
14#[derive(Debug, thiserror::Error)]
15#[non_exhaustive]
16pub enum Error {
17    /// The server returned a structured API error envelope (or a bare-401 body).
18    #[error(transparent)]
19    Api(#[from] ApiError),
20
21    /// A network/transport failure that is not a timeout (connection reset,
22    /// DNS, body-read failure, TLS, …).
23    #[error("transport error: {0}")]
24    Transport(#[source] reqwest::Error),
25
26    /// The request exceeded the configured per-request timeout.
27    #[error("request timed out after {0:?}")]
28    Timeout(Duration),
29
30    /// A SmartBrowse run reached a terminal `failed`/`cancelled` state while
31    /// waiting. Carries the full run so `error`, `pages_extracted`, and
32    /// `credits_used` are inspectable.
33    #[error("smartbrowse run {} ended in state {}", .run.id, .run.run_status)]
34    RunFailed {
35        /// The terminal run.
36        run: SmartBrowseRun,
37    },
38
39    /// [`SmartBrowse::wait_for_run`](crate::SmartBrowse::wait_for_run) exceeded
40    /// its deadline before the run finished. Carries the last-seen run.
41    #[error("timed out waiting for smartbrowse run {} (last state: {})", .last.id, .last.run_status)]
42    WaitTimeout {
43        /// The most recent run state observed before the deadline.
44        last: SmartBrowseRun,
45    },
46
47    /// Client construction failed (e.g. missing API key, bad HTTP client).
48    #[error("configuration error: {0}")]
49    Config(String),
50
51    /// A well-formed HTTP response could not be decoded into the expected shape.
52    #[error("decode error: {0}")]
53    Decode(String),
54}
55
56impl Error {
57    /// Returns the inner [`ApiError`] if this is an [`Error::Api`].
58    pub fn as_api(&self) -> Option<&ApiError> {
59        match self {
60            Error::Api(e) => Some(e),
61            _ => None,
62        }
63    }
64
65    /// True when this is an API error with the given [`ErrorCode`].
66    pub fn is_code(&self, code: &ErrorCode) -> bool {
67        self.as_api().map(|e| e.code() == code).unwrap_or(false)
68    }
69
70    /// True for `unauthorized` API errors (both envelope and bare-401 shapes).
71    pub fn is_unauthorized(&self) -> bool {
72        self.as_api()
73            .map(ApiError::is_unauthorized)
74            .unwrap_or(false)
75    }
76
77    /// True for `insufficient_credits` API errors.
78    pub fn is_insufficient_credits(&self) -> bool {
79        self.as_api()
80            .map(ApiError::is_insufficient_credits)
81            .unwrap_or(false)
82    }
83
84    /// True for `rate_limited` API errors.
85    pub fn is_rate_limited(&self) -> bool {
86        self.as_api()
87            .map(ApiError::is_rate_limited)
88            .unwrap_or(false)
89    }
90
91    /// True for `not_found` API errors.
92    pub fn is_not_found(&self) -> bool {
93        self.as_api().map(ApiError::is_not_found).unwrap_or(false)
94    }
95}
96
97/// A structured API error decoded from the response envelope.
98///
99/// Branch on [`ApiError::kind`] (a stable [`ErrorCode`]); never pattern-match the
100/// human-readable [`ApiError::message`], which may change between releases.
101#[derive(Debug, Clone, thiserror::Error)]
102#[error("api error [{status}] {code}: {message}")]
103pub struct ApiError {
104    status: u16,
105    code: ErrorCode,
106    message: String,
107    details: Option<Value>,
108    request_id: Option<String>,
109    retry_after: Option<Duration>,
110}
111
112impl ApiError {
113    #[allow(clippy::too_many_arguments)]
114    pub(crate) fn new(
115        status: u16,
116        code: ErrorCode,
117        message: String,
118        details: Option<Value>,
119        request_id: Option<String>,
120        retry_after: Option<Duration>,
121    ) -> Self {
122        Self {
123            status,
124            code,
125            message,
126            details,
127            request_id,
128            retry_after,
129        }
130    }
131
132    /// HTTP status code that carried this error.
133    pub fn status(&self) -> u16 {
134        self.status
135    }
136
137    /// The stable error code. Alias for [`ApiError::kind`].
138    pub fn code(&self) -> &ErrorCode {
139        &self.code
140    }
141
142    /// The stable error code (the "kind" of API error).
143    pub fn kind(&self) -> &ErrorCode {
144        &self.code
145    }
146
147    /// Human-readable message. Log it; do not pattern-match it.
148    pub fn message(&self) -> &str {
149        &self.message
150    }
151
152    /// Optional structured `error.details` payload as raw JSON.
153    pub fn details(&self) -> Option<&Value> {
154        self.details.as_ref()
155    }
156
157    /// The support-facing `request_id` (`req_…`), from the envelope or the
158    /// `X-Request-ID` response header.
159    pub fn request_id(&self) -> Option<&str> {
160        self.request_id.as_deref()
161    }
162
163    /// A honored `Retry-After` value, if the server sent one.
164    pub fn retry_after(&self) -> Option<Duration> {
165        self.retry_after
166    }
167
168    /// For `insufficient_credits`: the caller's current balance, if present.
169    pub fn balance(&self) -> Option<i64> {
170        self.detail_i64("balance")
171    }
172
173    /// For `insufficient_credits`: the credits required, if present.
174    pub fn required(&self) -> Option<i64> {
175        self.detail_i64("required")
176    }
177
178    /// For `rate_limited`: the `reason` discriminator
179    /// (`rate_limit_per_min` | `max_concurrent_requests` | `sb_runs_per_month`).
180    pub fn reason(&self) -> Option<&str> {
181        self.detail_str("reason")
182    }
183
184    /// For `account_deletion_pending`: the scheduled deletion timestamp.
185    pub fn deletion_scheduled_for(&self) -> Option<&str> {
186        self.detail_str("deletion_scheduled_for")
187    }
188
189    /// True for `unauthorized`.
190    pub fn is_unauthorized(&self) -> bool {
191        matches!(self.code, ErrorCode::Unauthorized)
192    }
193
194    /// True for `insufficient_credits`.
195    pub fn is_insufficient_credits(&self) -> bool {
196        matches!(self.code, ErrorCode::InsufficientCredits)
197    }
198
199    /// True for `rate_limited`.
200    pub fn is_rate_limited(&self) -> bool {
201        matches!(self.code, ErrorCode::RateLimited)
202    }
203
204    /// True for `not_found`.
205    pub fn is_not_found(&self) -> bool {
206        matches!(self.code, ErrorCode::NotFound)
207    }
208
209    /// True for `validation_failed`.
210    pub fn is_validation_failed(&self) -> bool {
211        matches!(self.code, ErrorCode::ValidationFailed)
212    }
213
214    fn detail_i64(&self, key: &str) -> Option<i64> {
215        self.details.as_ref()?.get(key)?.as_i64()
216    }
217
218    fn detail_str(&self, key: &str) -> Option<&str> {
219        self.details.as_ref()?.get(key)?.as_str()
220    }
221}
222
223/// Stable API error code. Unknown codes decode to
224/// [`ErrorCode::Unknown`] with the raw string preserved.
225#[derive(Debug, Clone, PartialEq, Eq)]
226#[non_exhaustive]
227pub enum ErrorCode {
228    /// `invalid_request` (HTTP 400)
229    InvalidRequest,
230    /// `unauthorized` (HTTP 401)
231    Unauthorized,
232    /// `insufficient_credits` (HTTP 402)
233    InsufficientCredits,
234    /// `email_verification_required` (HTTP 402)
235    EmailVerificationRequired,
236    /// `forbidden` (HTTP 403)
237    Forbidden,
238    /// `not_found` (HTTP 404)
239    NotFound,
240    /// `conflict` (HTTP 409)
241    Conflict,
242    /// `account_deletion_pending` (HTTP 409)
243    AccountDeletionPending,
244    /// `validation_failed` (HTTP 422)
245    ValidationFailed,
246    /// `rate_limited` (HTTP 429)
247    RateLimited,
248    /// `internal_error` (HTTP 500)
249    InternalError,
250    /// `service_unavailable` (HTTP 502)
251    ServiceUnavailable,
252    /// Any code not known to this SDK version. The raw string is preserved.
253    Unknown(String),
254}
255
256impl ErrorCode {
257    /// The canonical wire string for this code.
258    pub fn as_str(&self) -> &str {
259        match self {
260            ErrorCode::InvalidRequest => "invalid_request",
261            ErrorCode::Unauthorized => "unauthorized",
262            ErrorCode::InsufficientCredits => "insufficient_credits",
263            ErrorCode::EmailVerificationRequired => "email_verification_required",
264            ErrorCode::Forbidden => "forbidden",
265            ErrorCode::NotFound => "not_found",
266            ErrorCode::Conflict => "conflict",
267            ErrorCode::AccountDeletionPending => "account_deletion_pending",
268            ErrorCode::ValidationFailed => "validation_failed",
269            ErrorCode::RateLimited => "rate_limited",
270            ErrorCode::InternalError => "internal_error",
271            ErrorCode::ServiceUnavailable => "service_unavailable",
272            ErrorCode::Unknown(s) => s.as_str(),
273        }
274    }
275
276    /// Parse a wire string into an [`ErrorCode`], falling back to
277    /// [`ErrorCode::Unknown`].
278    pub(crate) fn parse(s: &str) -> Self {
279        match s {
280            "invalid_request" => ErrorCode::InvalidRequest,
281            "unauthorized" => ErrorCode::Unauthorized,
282            "insufficient_credits" => ErrorCode::InsufficientCredits,
283            "email_verification_required" => ErrorCode::EmailVerificationRequired,
284            "forbidden" => ErrorCode::Forbidden,
285            "not_found" => ErrorCode::NotFound,
286            "conflict" => ErrorCode::Conflict,
287            "account_deletion_pending" => ErrorCode::AccountDeletionPending,
288            "validation_failed" => ErrorCode::ValidationFailed,
289            "rate_limited" => ErrorCode::RateLimited,
290            "internal_error" => ErrorCode::InternalError,
291            "service_unavailable" => ErrorCode::ServiceUnavailable,
292            other => ErrorCode::Unknown(other.to_string()),
293        }
294    }
295
296    /// Best-effort mapping from an HTTP status when the body carried no code
297    /// (e.g. a non-envelope error body).
298    pub(crate) fn from_status(status: u16) -> Self {
299        match status {
300            400 => ErrorCode::InvalidRequest,
301            401 => ErrorCode::Unauthorized,
302            403 => ErrorCode::Forbidden,
303            404 => ErrorCode::NotFound,
304            409 => ErrorCode::Conflict,
305            422 => ErrorCode::ValidationFailed,
306            429 => ErrorCode::RateLimited,
307            500 => ErrorCode::InternalError,
308            502 | 503 => ErrorCode::ServiceUnavailable,
309            other => ErrorCode::Unknown(format!("http_{other}")),
310        }
311    }
312}
313
314impl fmt::Display for ErrorCode {
315    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
316        f.write_str(self.as_str())
317    }
318}
319
320impl fmt::Display for RunStatus {
321    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
322        let s = match self {
323            RunStatus::Queued => "queued",
324            RunStatus::Running => "running",
325            RunStatus::Completed => "completed",
326            RunStatus::Failed => "failed",
327            RunStatus::Cancelled => "cancelled",
328            RunStatus::Unknown => "unknown",
329        };
330        f.write_str(s)
331    }
332}