Skip to main content

ezcapsolver/
error.rs

1use std::{collections::BTreeMap, fmt, time::Duration};
2
3use serde_json::Value;
4
5/// Result type returned by this crate.
6pub type Result<T> = std::result::Result<T, Error>;
7
8/// Structured error returned by the EzCaptchaSolver API.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct EzError {
11    /// Optional server request identifier.
12    pub request_id: Option<String>,
13    /// Optional task identifier for errors returned after task creation.
14    pub task_id: Option<String>,
15    /// Stable machine-readable error code.
16    pub error_code: Option<String>,
17    /// Human-readable error description.
18    pub error_description: Option<String>,
19    /// Field-level validation errors.
20    pub errors: BTreeMap<String, String>,
21    /// HTTP status code that carried the API error.
22    pub http_status: u16,
23}
24
25/// Codes that feed the service's per-key ban counter.
26///
27/// `/createTask` bans a key for three minutes after thirty of these within one
28/// minute; `/getTaskResult` bans it for one minute after thirty of the first
29/// two. Retrying is therefore worse than useless — it is what triggers the ban.
30const AUTHENTICATION_ERROR_CODES: &[&str] = &[
31    "ERROR_KEY_DOES_NOT_EXIST",
32    "ERROR_KEY_NOT_AVAILABLE",
33    "ERROR_ZERO_BALANCE",
34];
35
36/// Codes for which resending the identical request yields the identical failure.
37///
38/// The service's remaining codes are left out on purpose: an internal error, a
39/// rate limit, a ban, and the two synchronous worker faults
40/// (`ERROR_SERVICE_UNAVAILABLE`, `ERROR_SERVICE_TIMEOUT`) can all clear on their
41/// own.
42const TERMINAL_ERROR_CODES: &[&str] = &[
43    "ERROR_CONTENT_TYPE_ERROR",
44    "ERROR_KEY_DOES_NOT_EXIST",
45    "ERROR_KEY_NOT_AVAILABLE",
46    "ERROR_NOT_FOUND",
47    "ERROR_PACKAGE_NOT_EXIST",
48    "ERROR_PACKAGE_TASK_TYPE_NOT_SUPPORTED",
49    "ERROR_REQUEST_METHOD",
50    "ERROR_REQUEST_PARAMETERS",
51    "ERROR_REQUEST_PROXY_MISSING",
52    "ERROR_SUBSCRIPTION_EXPIRED",
53    "ERROR_TASK_NOT_EXIST",
54    "ERROR_TASK_TYPE_NOT_ALLOWED",
55    "ERROR_TASK_TYPE_NOT_AVAILABLE",
56    "ERROR_TASK_TYPE_NOT_SUPPORTED",
57    "ERROR_WEBSITE_NOT_ALLOWED",
58    "ERROR_ZERO_BALANCE",
59];
60
61/// The service's two throttling codes, both HTTP 429.
62///
63/// Neither says anything about a task: the query is refused before the service
64/// looks it up, so the task keeps running and the next poll can still find it.
65/// `/getTaskResult` counts only the first two authentication codes toward its
66/// ban counter, so polling through a refusal does not dig the hole deeper.
67const RATE_LIMITED_ERROR_CODES: &[&str] = &["ERROR_REQUEST_LIMIT", "ERROR_REQUEST_BANNED"];
68
69impl EzError {
70    /// Returns true when this is a credential or balance problem.
71    ///
72    /// A caller that retries one of these is not merely wasting a call: the
73    /// service counts them per key, and thirty within a minute earn a
74    /// three-minute ban. Stop rather than back off.
75    #[must_use]
76    pub fn is_authentication_error(&self) -> bool {
77        self.error_code
78            .as_deref()
79            .is_some_and(|code| AUTHENTICATION_ERROR_CODES.contains(&code))
80    }
81
82    /// Returns true when resending the identical request would fail identically.
83    ///
84    /// An unrecognised code returns `false`, because a code this release has not
85    /// seen may well be transient and the SDK should not talk a caller out of a
86    /// retry that would have worked. `false` is not a promise that retrying is
87    /// safe — the retry policy stays with the caller.
88    #[must_use]
89    pub fn is_terminal(&self) -> bool {
90        self.error_code
91            .as_deref()
92            .is_some_and(|code| TERMINAL_ERROR_CODES.contains(&code))
93    }
94
95    /// Returns true when the service refused the request for throttling rather
96    /// than for anything about the request itself.
97    ///
98    /// Both codes are transient by construction: a rate limit resets with its
99    /// window and a ban expires on its own. The asynchronous polling loop
100    /// treats one as a skipped attempt instead of a failed task, and a caller
101    /// polling by hand with `get_task_result` should do the same.
102    ///
103    /// This is narrower than the complement of [`Self::is_terminal`], which is
104    /// false for every unrecognised code as well — including the worker codes
105    /// that report a genuinely failed task.
106    #[must_use]
107    pub fn is_rate_limited(&self) -> bool {
108        self.error_code
109            .as_deref()
110            .is_some_and(|code| RATE_LIMITED_ERROR_CODES.contains(&code))
111    }
112}
113
114impl fmt::Display for EzError {
115    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
116        let code = self.error_code.as_deref().unwrap_or("UNKNOWN_API_ERROR");
117        let description = self
118            .error_description
119            .as_deref()
120            .unwrap_or("The API returned an unspecified error");
121        write!(
122            formatter,
123            "{code}: {description} (HTTP {})",
124            self.http_status
125        )?;
126        // Validation failures are only actionable with the offending fields, so
127        // they belong in the message rather than only in `errors`.
128        for (index, (field, message)) in self.errors.iter().enumerate() {
129            let separator = if index == 0 { "; " } else { ", " };
130            write!(formatter, "{separator}{field}: {message}")?;
131        }
132        Ok(())
133    }
134}
135
136impl std::error::Error for EzError {}
137
138/// Errors produced by request construction, HTTP transport, API responses, and polling.
139///
140/// This enum is `#[non_exhaustive]`: new variants may be added in future minor
141/// releases, so downstream matches must include a wildcard arm.
142#[derive(Debug, thiserror::Error)]
143#[non_exhaustive]
144pub enum Error {
145    /// A failure whose only useful detail is its description.
146    ///
147    /// An invalid client configuration arrives here today. Anything a caller
148    /// would react to programmatically gets a variant of its own instead, so
149    /// this one is meant to be printed rather than matched on.
150    #[error("{0}")]
151    Message(String),
152
153    /// The HTTP request failed.
154    ///
155    /// Wraps the underlying [`reqwest::Error`], whose `is_timeout`,
156    /// `is_connect`, and `status` methods classify the failure.
157    #[cfg(any(feature = "async", feature = "blocking"))]
158    #[error("HTTP request failed: {0}")]
159    Request(#[from] reqwest::Error),
160
161    /// The response could not be used, either because it did not decode or
162    /// because it violated the API contract.
163    ///
164    /// A ready result carrying no solution field lands here too: the task was
165    /// billed and the answer is gone, which is a broken contract rather than a
166    /// category of its own. [`Solution::is_missing`] still tells that case
167    /// apart from a solution that was present and null.
168    ///
169    /// [`Solution::is_missing`]: crate::Solution::is_missing
170    #[error("unexpected API response: {0}")]
171    UnexpectedResponse(String),
172
173    /// The API reported an error.
174    ///
175    /// This covers both a structured business error and any non-success HTTP
176    /// status, so [`EzError::http_status`] is always available here.
177    #[error(transparent)]
178    EzError(Box<EzError>),
179
180    /// Polling ended before the task reached a terminal state.
181    #[error(
182        "task `{task_id}` did not complete after {attempts} polling attempts at {interval:?} intervals"
183    )]
184    PollingExhausted {
185        /// Task identifier.
186        task_id: String,
187        /// Number of completed result queries.
188        attempts: usize,
189        /// Delay configured between queries.
190        interval: Duration,
191    },
192
193    /// The task was created and billed, but waiting for its result failed.
194    ///
195    /// `solve` creates the task internally, so the identifier never reaches the
196    /// caller on its own. A failure that dropped it would leave a paid-for
197    /// result unreachable — hence this variant, whose whole purpose is to carry
198    /// the identifier out.
199    ///
200    /// Recover by waiting on the same task again rather than creating a second
201    /// one: the service holds a result for five minutes after creation, and a
202    /// new task is billed again. Errors that already carry the identifier
203    /// themselves are left alone, so a business error still arrives as
204    /// [`Self::EzError`] and an exhausted budget as [`Self::PollingExhausted`].
205    #[error("task `{task_id}` was created but waiting for its result failed: {source}")]
206    WaitInterrupted {
207        /// Identifier of the task that was created and billed.
208        task_id: String,
209        /// Request identifier returned by task creation.
210        request_id: Option<String>,
211        /// The failure that interrupted the wait.
212        #[source]
213        source: Box<Error>,
214    },
215
216    /// A raw solution could not be converted into the requested type.
217    ///
218    /// The raw value travels with the error: a decode failure means the worker
219    /// returned a shape this release does not model, and that shape is exactly
220    /// what is needed to diagnose it. The convenience methods consume the task
221    /// result, so this is the only place it survives.
222    #[error("failed to decode task solution: {source}; raw solution: {}", preview_solution(.raw))]
223    SolutionDecode {
224        /// JSON decoding error.
225        #[source]
226        source: serde_json::Error,
227        /// Raw solution value that failed to decode, preserved in full.
228        raw: Box<Value>,
229    },
230}
231
232/// Renders a raw solution for an error message, truncated so an oversized
233/// worker response cannot bury the rest of the message. [`Error::SolutionDecode`]
234/// still carries the untruncated value.
235fn preview_solution(value: &Value) -> String {
236    const MAX_CHARS: usize = 512;
237
238    let rendered = value.to_string();
239    let mut chars = rendered.chars();
240    let head: String = chars.by_ref().take(MAX_CHARS).collect();
241    if chars.next().is_some() {
242        format!("{head}...")
243    } else {
244        head
245    }
246}
247
248impl Error {
249    /// Returns the identifier of a task that was created and billed, when this
250    /// failure left one behind.
251    ///
252    /// Creating a task is what costs money, so a failure after that point
253    /// leaves a result worth recovering: wait on this identifier again with
254    /// `wait_for_result` instead of creating a second task. The service holds a
255    /// result for five minutes after creation.
256    ///
257    /// `None` means nothing was billed — the failure happened before or during
258    /// task creation, so there is nothing to recover.
259    ///
260    /// Which variant the identifier is stored on is an implementation detail;
261    /// this is the one place to ask.
262    ///
263    /// ```
264    /// # fn example(error: ezcapsolver::Error) {
265    /// if let Some(task_id) = error.task_id() {
266    ///     // The task is still on the service. Waiting again is free.
267    ///     eprintln!("recover with wait_for_result({task_id})");
268    /// }
269    /// # }
270    /// ```
271    #[must_use]
272    pub fn task_id(&self) -> Option<&str> {
273        match self {
274            Self::WaitInterrupted { task_id, .. } | Self::PollingExhausted { task_id, .. } => {
275                Some(task_id)
276            }
277            Self::EzError(error) => error.task_id.as_deref(),
278            _ => None,
279        }
280    }
281}
282
283impl From<EzError> for Error {
284    fn from(error: EzError) -> Self {
285        Self::EzError(Box::new(error))
286    }
287}
288
289#[cfg(any(feature = "async", feature = "blocking"))]
290impl Error {
291    /// Reports an invalid client configuration.
292    ///
293    /// The prefix is added here rather than at each call site so that every
294    /// configuration failure reads the same way. These are raised while a
295    /// client is being built, never once one is in use, so a caller holding
296    /// this error has not been billed for anything.
297    pub(crate) fn config(detail: impl fmt::Display) -> Self {
298        Self::Message(format!("invalid client configuration: {detail}"))
299    }
300
301    /// Attaches the identifiers of a created task to a failure raised while
302    /// waiting for its result.
303    ///
304    /// Every failure leaving the wait has to carry the task identifier, because
305    /// the caller of `solve` has no other way to reach a task that was already
306    /// billed. Variants that hold the identifier themselves are filled in
307    /// place; the rest are wrapped, which is the only place to put it.
308    pub(crate) fn with_task_context(self, task_id: String, request_id: Option<String>) -> Self {
309        match self {
310            Self::EzError(mut error) => {
311                error.task_id = Some(task_id);
312                error.request_id = request_id;
313                Self::EzError(error)
314            }
315            // These already carry a task_id, so wrapping them would only make a
316            // caller unwrap one more layer to read the same value.
317            already_identified @ (Self::PollingExhausted { .. } | Self::WaitInterrupted { .. }) => {
318                already_identified
319            }
320            source => Self::WaitInterrupted {
321                task_id,
322                request_id,
323                source: Box::new(source),
324            },
325        }
326    }
327}