Skip to main content

ezcapsolver/
blocking_client.rs

1use std::{
2    fmt, thread,
3    time::{Duration, Instant},
4};
5
6use reqwest::header::HeaderValue;
7use serde::{Serialize, de::DeserializeOwned};
8use tracing::{debug, info, warn};
9
10use crate::{
11    ClientConfig, CreateTaskResponse, Error, PollingConfig, Result, Solution, Solved, TaskResult,
12    TaskStatus, TaskTypeName,
13    request::{CreateTaskRequest, QueryBalanceRequest, QueryTaskResultRequest, TaskPayload},
14    response::BalanceResponse,
15    transport::{
16        CLIENT_KEY_HEADER, client_key_header, endpoint, log_request, log_response,
17        parse_api_response,
18    },
19};
20
21/// Blocking client for asynchronous and synchronous EzCaptchaSolver API operations.
22#[derive(Clone)]
23pub struct EzCapSolverClient {
24    client_key: String,
25    client_key_header: HeaderValue,
26    config: ClientConfig,
27    http: reqwest::blocking::Client,
28}
29
30impl fmt::Debug for EzCapSolverClient {
31    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
32        formatter
33            .debug_struct("EzCapSolverClient")
34            .field("client_key", &"[REDACTED]")
35            .field("config", &self.config)
36            .finish_non_exhaustive()
37    }
38}
39
40impl EzCapSolverClient {
41    /// Creates a client with default configuration.
42    ///
43    /// The client key is loaded from `EZCAPSOLVER_API_KEY`.
44    pub fn new() -> Result<Self> {
45        Self::with_config(ClientConfig::default())
46    }
47
48    /// Creates a client with the supplied configuration.
49    ///
50    /// If the configuration does not contain an explicit client key,
51    /// `EZCAPSOLVER_API_KEY` is loaded from the environment.
52    pub fn with_config(config: ClientConfig) -> Result<Self> {
53        let client_key = config.resolve_client_key()?;
54        let client_key_header = client_key_header(&client_key)?;
55        config.validate()?;
56        let http = build_http_client(&config)?;
57        Ok(Self {
58            client_key,
59            client_key_header,
60            config,
61            http,
62        })
63    }
64
65    /// Creates a client with an explicit client key and default configuration.
66    pub fn with_client_key(client_key: impl Into<String>) -> Result<Self> {
67        Self::with_config(ClientConfig::default().with_client_key(client_key))
68    }
69
70    /// Creates a client that sends through a caller-supplied HTTP client.
71    ///
72    /// Use it to share a connection pool, or to install a middleware stack or a
73    /// custom TLS setup. [`ClientConfig::proxy`] and [`ClientConfig::user_agent`]
74    /// are **not** applied to it — the supplied client brings its own transport
75    /// and headers. Per-request timeouts still are.
76    pub fn with_http_client(config: ClientConfig, http: reqwest::blocking::Client) -> Result<Self> {
77        let client_key = config.resolve_client_key()?;
78        let client_key_header = client_key_header(&client_key)?;
79        config.validate()?;
80        Ok(Self {
81            client_key,
82            client_key_header,
83            config,
84            http,
85        })
86    }
87
88    /// Returns the active client configuration.
89    #[must_use]
90    pub const fn config(&self) -> &ClientConfig {
91        &self.config
92    }
93
94    /// Creates an asynchronous API task without waiting for completion.
95    ///
96    /// The supplied task type is injected into the serialized parameter object.
97    ///
98    /// Most callers should use [`Self::solve`] to create the task and wait for
99    /// its result in one call.
100    pub fn create_task<N, T>(&self, task_type: N, task: &T) -> Result<CreateTaskResponse>
101    where
102        N: TaskTypeName,
103        T: Serialize + ?Sized,
104    {
105        let task_type = task_type.task_type_name();
106        debug!(task_type, "creating task");
107        let request = CreateTaskRequest {
108            client_key: &self.client_key,
109            app_id: self.config.app_id,
110            task: TaskPayload {
111                task_type,
112                params: task,
113            },
114        };
115        let response: CreateTaskResponse = self.post(
116            &endpoint(&self.config.async_base_url, "/createTask"),
117            &request,
118            self.config.timeout,
119        )?;
120        if response.task_id.trim().is_empty() {
121            return Err(Error::UnexpectedResponse(
122                "createTask returned an empty taskId".to_owned(),
123            ));
124        }
125        info!(task_id = %response.task_id, task_type, "task created");
126        Ok(response)
127    }
128
129    /// Queries the current result of an asynchronous API task.
130    ///
131    /// This is called once per polling attempt, so it does not log on its own;
132    /// the caller records the attempt and `TRACE` records the exchange.
133    pub fn get_task_result(&self, task_id: &str) -> Result<TaskResult> {
134        let request = QueryTaskResultRequest {
135            client_key: &self.client_key,
136            task_id,
137        };
138        self.post(
139            &endpoint(&self.config.async_base_url, "/getTaskResult"),
140            &request,
141            self.config.timeout,
142        )
143    }
144
145    /// Waits for an existing asynchronous task using client polling settings.
146    pub fn wait_for_result(&self, task_id: &str) -> Result<TaskResult> {
147        self.wait_for_result_with(task_id, self.config.polling)
148    }
149
150    /// Waits for an existing asynchronous task using per-call polling settings.
151    pub fn wait_for_result_with(
152        &self,
153        task_id: &str,
154        polling: PollingConfig,
155    ) -> Result<TaskResult> {
156        polling.validate()?;
157        for attempt in 1..=polling.max_attempts {
158            // Wait before every query, including the first: a task that was
159            // just created is still queued and would report `processing`.
160            thread::sleep(polling.interval);
161            debug!(task_id = %task_id, attempt, "polling task result");
162            let result = match self.get_task_result(task_id) {
163                Ok(result) => result,
164                // Throttling says nothing about the task, which is still
165                // queued. Spend the attempt and poll again rather than failing
166                // a task that has already been billed. The attempt is spent on
167                // purpose: interval x attempts is what keeps the whole wait
168                // inside the five-minute window the result is held for.
169                Err(Error::EzError(error)) if error.is_rate_limited() => {
170                    warn!(
171                        task_id = %task_id,
172                        attempt,
173                        error_code = error.error_code.as_deref().unwrap_or_default(),
174                        "polling throttled, retrying"
175                    );
176                    continue;
177                }
178                Err(error) => return Err(error),
179            };
180            match &result.status {
181                TaskStatus::Ready => {
182                    info!(
183                        task_id = %task_id,
184                        attempts = attempt,
185                        "task completed"
186                    );
187                    return Ok(result);
188                }
189                TaskStatus::Processing => {}
190                TaskStatus::Error => {
191                    return Err(Error::UnexpectedResponse(format!(
192                        "task `{task_id}` returned status `error` without an API error"
193                    )));
194                }
195            }
196        }
197        Err(Error::PollingExhausted {
198            task_id: task_id.to_owned(),
199            attempts: polling.max_attempts,
200            interval: polling.interval,
201        })
202    }
203
204    /// Creates an asynchronous task and waits for its terminal result.
205    ///
206    /// The supplied task type is injected into the serialized parameter object.
207    ///
208    /// This is the recommended high-level method for asynchronous tasks.
209    pub fn solve<N, T>(&self, task_type: N, task: &T) -> Result<Solved<Solution>>
210    where
211        N: TaskTypeName,
212        T: Serialize + ?Sized,
213    {
214        self.solve_with(task_type, task, self.config.polling)
215    }
216
217    /// Creates an asynchronous task and uses per-call polling settings.
218    pub fn solve_with<N, T>(
219        &self,
220        task_type: N,
221        task: &T,
222        polling: PollingConfig,
223    ) -> Result<Solved<Solution>>
224    where
225        N: TaskTypeName,
226        T: Serialize + ?Sized,
227    {
228        let created = self.create_task(task_type, task)?;
229        let task_id = created.task_id;
230        let request_id = created.meta.request_id;
231        let result = self
232            .wait_for_result_with(&task_id, polling)
233            .map_err(|error| error.with_task_context(task_id.clone(), request_id.clone()))?;
234        Ok(Solved {
235            task_id: Some(task_id),
236            // The service assigns every HTTP request its own correlation id. The one from the
237            // result query describes the response the caller is holding, so it wins;
238            // creation's id is the fallback that keeps this field populated.
239            request_id: result.meta.request_id.or(request_id),
240            // On the general entry point the solution is the raw value already, so
241            // both fields come from the same place.
242            solution: result.solution.clone(),
243            raw: result.solution,
244        })
245    }
246
247    /// Runs a synchronous task and returns its raw solution without polling.
248    ///
249    /// Accepts a known task type or a custom type name, with parameters that
250    /// serialize to a JSON object. Uses [`ClientConfig::sync_timeout`].
251    ///
252    /// Like [`Self::solve`], returns [`Solved<Solution>`], preserving the raw
253    /// solution, including a missing field or JSON null. Both [`Solved::task_id`]
254    /// and [`Solved::request_id`] come from the synchronous response.
255    pub fn sync_solve<N, T>(&self, task_type: N, task: &T) -> Result<Solved<Solution>>
256    where
257        N: TaskTypeName,
258        T: Serialize + ?Sized,
259    {
260        let result = self.create_sync_task(task_type, task)?;
261        if result.status == TaskStatus::Error {
262            return Err(Error::UnexpectedResponse(
263                "task result returned status `error` without an API error".to_owned(),
264            ));
265        }
266        Ok(Solved {
267            task_id: result.task_id,
268            request_id: result.meta.request_id,
269            solution: result.solution.clone(),
270            raw: result.solution,
271        })
272    }
273
274    /// Creates a synchronous API task and returns its result.
275    ///
276    /// The supplied task type is injected into the serialized parameter object.
277    /// Use [`Self::sync_solve`] to return the same aggregate as [`Self::solve`].
278    pub fn create_sync_task<N, T>(&self, task_type: N, task: &T) -> Result<TaskResult>
279    where
280        N: TaskTypeName,
281        T: Serialize + ?Sized,
282    {
283        let task_type = task_type.task_type_name();
284        debug!(task_type, "creating sync task");
285        let request = CreateTaskRequest {
286            client_key: &self.client_key,
287            app_id: self.config.app_id,
288            task: TaskPayload {
289                task_type,
290                params: task,
291            },
292        };
293        let result: TaskResult = self.post(
294            &endpoint(&self.config.sync_base_url, "/createSyncTask"),
295            &request,
296            self.config.sync_timeout,
297        )?;
298        if result.status == TaskStatus::Processing {
299            return Err(Error::UnexpectedResponse(
300                "createSyncTask returned status `processing`".to_owned(),
301            ));
302        }
303        info!(task_type, "sync task completed");
304        Ok(result)
305    }
306
307    /// Queries the current account balance.
308    pub fn balance(&self) -> Result<f64> {
309        debug!("querying account balance");
310        let request = QueryBalanceRequest {
311            client_key: &self.client_key,
312        };
313        let response: BalanceResponse = self.post(
314            &endpoint(&self.config.async_base_url, "/getBalance"),
315            &request,
316            self.config.timeout,
317        )?;
318        Ok(response.balance)
319    }
320
321    fn post<B, T>(&self, url: &str, body: &B, timeout: Duration) -> Result<T>
322    where
323        B: Serialize + ?Sized,
324        T: DeserializeOwned,
325    {
326        log_request(url, body);
327        let started = Instant::now();
328        let response = self
329            .http
330            .post(url)
331            .timeout(timeout)
332            .header(CLIENT_KEY_HEADER, self.client_key_header.clone())
333            .json(body)
334            .send()?;
335        let status = response.status().as_u16();
336        let bytes = response.bytes()?;
337        log_response(url, status, started.elapsed(), &bytes);
338        parse_api_response(status, &bytes)
339    }
340}
341
342fn build_http_client(config: &ClientConfig) -> Result<reqwest::blocking::Client> {
343    let mut builder = reqwest::blocking::Client::builder()
344        // No timeout here: the two endpoints have different timing characteristics,
345        // so each request applies its own budget over this one shared pool.
346        .user_agent(config.user_agent.clone());
347    if let Some(proxy) = &config.proxy {
348        let proxy = reqwest::Proxy::all(proxy)
349            .map_err(|error| Error::config(format!("invalid SDK proxy URL: {error}")))?;
350        builder = builder.proxy(proxy);
351    }
352    Ok(builder.build()?)
353}
354
355mod solvers;
356
357#[cfg(test)]
358mod tests;