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}