Skip to main content

apify_client/
error.rs

1//! Error types returned by the Apify client.
2
3use serde::Deserialize;
4
5/// The result type used throughout this crate.
6pub type ApifyClientResult<T> = Result<T, ApifyClientError>;
7
8/// Shape of the `error` object returned by the Apify API on failure.
9///
10/// The API encodes errors as `{ "error": { "type": "...", "message": "..." } }`.
11#[derive(Debug, Clone, Deserialize)]
12pub(crate) struct ApiErrorBody {
13    pub error: ApiErrorDetail,
14}
15
16#[derive(Debug, Clone, Deserialize)]
17pub(crate) struct ApiErrorDetail {
18    #[serde(rename = "type")]
19    pub error_type: Option<String>,
20    pub message: Option<String>,
21    pub data: Option<serde_json::Value>,
22}
23
24/// An error response returned by the Apify API.
25///
26/// This is raised for HTTP requests that reach the API but return a non-success
27/// status code. It mirrors the `ApifyApiError` of the reference clients and exposes
28/// the parsed error `type`, the human-readable `message`, the HTTP `status_code`,
29/// the number of the final `attempt`, and the request `http_method`/`path`.
30#[derive(Debug, Clone)]
31pub struct ApiError {
32    /// HTTP status code of the error response.
33    pub status_code: u16,
34    /// The machine-readable error type returned by the API (e.g. `record-not-found`).
35    pub error_type: Option<String>,
36    /// Human-readable description of the error returned by the API.
37    pub message: String,
38    /// Number of the API call attempt that produced this error (1-based).
39    pub attempt: u32,
40    /// HTTP method of the API call (e.g. `GET`, `POST`).
41    pub http_method: Option<String>,
42    /// Full path of the API endpoint (URL excluding origin).
43    pub path: Option<String>,
44    /// Additional structured data provided by the API about the error, if any.
45    pub data: Option<serde_json::Value>,
46}
47
48impl std::fmt::Display for ApiError {
49    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        write!(
51            f,
52            "Apify API error (status {}, type {}): {}",
53            self.status_code,
54            self.error_type.as_deref().unwrap_or("unknown"),
55            self.message,
56        )
57    }
58}
59
60impl std::error::Error for ApiError {}
61
62impl ApiError {
63    /// `true` for HTTP 400 Bad Request, typically because the request failed validation.
64    ///
65    /// The reference clients throw a distinct `InvalidRequestError` subclass for this status;
66    /// since Rust has no exception hierarchy to mirror, these `is_*` predicates are the
67    /// idiomatic equivalent classification on the one [`ApiError`] type.
68    pub fn is_invalid_request(&self) -> bool {
69        self.status_code == 400
70    }
71
72    /// `true` for HTTP 401 Unauthorized: the token is missing or invalid.
73    pub fn is_unauthorized(&self) -> bool {
74        self.status_code == 401
75    }
76
77    /// `true` for HTTP 403 Forbidden: the token lacks permission for the operation.
78    pub fn is_forbidden(&self) -> bool {
79        self.status_code == 403
80    }
81
82    /// `true` for HTTP 404 Not Found.
83    ///
84    /// Most `get`-style methods already map a `404` to `None` rather than an error (see e.g.
85    /// [`crate::clients::actor::ActorClient::get`]), so this is mainly useful for the methods
86    /// that return other errors directly, e.g. [`crate::clients::actor::ActorClient::start`]
87    /// with a nonexistent Actor ID.
88    pub fn is_not_found(&self) -> bool {
89        self.status_code == 404
90    }
91
92    /// `true` for HTTP 409 Conflict.
93    pub fn is_conflict(&self) -> bool {
94        self.status_code == 409
95    }
96
97    /// `true` for HTTP 429 Too Many Requests. The client already retries these internally (see
98    /// [`crate::ApifyClientBuilder::max_retries`]), so this surfaces only once retries are
99    /// exhausted.
100    pub fn is_rate_limited(&self) -> bool {
101        self.status_code == 429
102    }
103
104    /// `true` for an HTTP 5xx status. Like [`is_rate_limited`](Self::is_rate_limited), the
105    /// client already retries these internally, so this surfaces only once retries are
106    /// exhausted.
107    pub fn is_server_error(&self) -> bool {
108        self.status_code >= 500
109    }
110}
111
112/// The top-level error type for all client operations.
113#[derive(Debug, thiserror::Error)]
114pub enum ApifyClientError {
115    /// The API returned a non-success status code with a structured error body.
116    ///
117    /// Boxed to keep the overall `Result` size small (the error path is rare).
118    #[error(transparent)]
119    Api(Box<ApiError>),
120
121    /// A network/transport-level error occurred (connection failure, timeout, etc.).
122    #[error("HTTP transport error: {0}")]
123    Http(String),
124
125    /// The request timed out.
126    #[error("Request timed out")]
127    Timeout,
128
129    /// Failed to serialize the request body or deserialize the response body.
130    #[error("(De)serialization error: {0}")]
131    Serde(#[from] serde_json::Error),
132
133    /// The response body could not be interpreted as expected.
134    #[error("Invalid response: {0}")]
135    InvalidResponse(String),
136
137    /// A required configuration value or argument was missing or invalid.
138    #[error("Invalid argument: {0}")]
139    InvalidArgument(String),
140}
141
142impl From<ApiError> for ApifyClientError {
143    fn from(err: ApiError) -> Self {
144        ApifyClientError::Api(Box::new(err))
145    }
146}
147
148impl ApifyClientError {
149    /// Returns the underlying [`ApiError`] if this is an API error, otherwise `None`.
150    pub fn as_api_error(&self) -> Option<&ApiError> {
151        match self {
152            ApifyClientError::Api(e) => Some(e),
153            _ => None,
154        }
155    }
156
157    /// Returns the HTTP status code if this error originated from an API response.
158    pub fn status_code(&self) -> Option<u16> {
159        self.as_api_error().map(|e| e.status_code)
160    }
161}
162
163impl From<reqwest::Error> for ApifyClientError {
164    fn from(err: reqwest::Error) -> Self {
165        if err.is_timeout() {
166            ApifyClientError::Timeout
167        } else {
168            ApifyClientError::Http(err.to_string())
169        }
170    }
171}