Skip to main content

ezcapsolver/
async_client.rs

1use std::{
2    fmt,
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/// Non-blocking client for asynchronous and synchronous EzCaptchaSolver API operations.
22#[derive(Clone)]
23pub struct AsyncEzCapSolverClient {
24    client_key: String,
25    client_key_header: HeaderValue,
26    config: ClientConfig,
27    http: reqwest::Client,
28}
29
30impl fmt::Debug for AsyncEzCapSolverClient {
31    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
32        formatter
33            .debug_struct("AsyncEzCapSolverClient")
34            .field("client_key", &"[REDACTED]")
35            .field("config", &self.config)
36            .finish_non_exhaustive()
37    }
38}
39
40impl AsyncEzCapSolverClient {
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::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 async 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
116            .post(
117                &endpoint(&self.config.async_base_url, "/createTask"),
118                &request,
119                self.config.timeout,
120            )
121            .await?;
122        if response.task_id.trim().is_empty() {
123            return Err(Error::UnexpectedResponse(
124                "createTask returned an empty taskId".to_owned(),
125            ));
126        }
127        info!(task_id = %response.task_id, task_type, "task created");
128        Ok(response)
129    }
130
131    /// Queries the current result of an asynchronous API task.
132    ///
133    /// This is called once per polling attempt, so it does not log on its own;
134    /// the caller records the attempt and `TRACE` records the exchange.
135    pub async fn get_task_result(&self, task_id: &str) -> Result<TaskResult> {
136        let request = QueryTaskResultRequest {
137            client_key: &self.client_key,
138            task_id,
139        };
140        self.post(
141            &endpoint(&self.config.async_base_url, "/getTaskResult"),
142            &request,
143            self.config.timeout,
144        )
145        .await
146    }
147
148    /// Waits for an existing asynchronous task using client polling settings.
149    pub async fn wait_for_result(&self, task_id: &str) -> Result<TaskResult> {
150        self.wait_for_result_with(task_id, self.config.polling)
151            .await
152    }
153
154    /// Waits for an existing asynchronous task using per-call polling settings.
155    pub async fn wait_for_result_with(
156        &self,
157        task_id: &str,
158        polling: PollingConfig,
159    ) -> Result<TaskResult> {
160        polling.validate()?;
161        for attempt in 1..=polling.max_attempts {
162            // Wait before every query, including the first: a task that was
163            // just created is still queued and would report `processing`.
164            tokio::time::sleep(polling.interval).await;
165            debug!(task_id = %task_id, attempt, "polling task result");
166            let result = match self.get_task_result(task_id).await {
167                Ok(result) => result,
168                // Throttling says nothing about the task, which is still
169                // queued. Spend the attempt and poll again rather than failing
170                // a task that has already been billed. The attempt is spent on
171                // purpose: interval x attempts is what keeps the whole wait
172                // inside the five-minute window the result is held for.
173                Err(Error::EzError(error)) if error.is_rate_limited() => {
174                    warn!(
175                        task_id = %task_id,
176                        attempt,
177                        error_code = error.error_code.as_deref().unwrap_or_default(),
178                        "polling throttled, retrying"
179                    );
180                    continue;
181                }
182                Err(error) => return Err(error),
183            };
184            match &result.status {
185                TaskStatus::Ready => {
186                    info!(
187                        task_id = %task_id,
188                        attempts = attempt,
189                        "task completed"
190                    );
191                    return Ok(result);
192                }
193                TaskStatus::Processing => {}
194                TaskStatus::Error => {
195                    return Err(Error::UnexpectedResponse(format!(
196                        "task `{task_id}` returned status `error` without an API error"
197                    )));
198                }
199            }
200        }
201        Err(Error::PollingExhausted {
202            task_id: task_id.to_owned(),
203            attempts: polling.max_attempts,
204            interval: polling.interval,
205        })
206    }
207
208    /// Creates an asynchronous task and waits for its terminal result.
209    ///
210    /// The supplied task type is injected into the serialized parameter object.
211    ///
212    /// This is the recommended high-level method for asynchronous tasks.
213    pub async fn solve<N, T>(&self, task_type: N, task: &T) -> Result<Solved<Solution>>
214    where
215        N: TaskTypeName,
216        T: Serialize + ?Sized,
217    {
218        self.solve_with(task_type, task, self.config.polling).await
219    }
220
221    /// Creates an asynchronous task and uses per-call polling settings.
222    pub async fn solve_with<N, T>(
223        &self,
224        task_type: N,
225        task: &T,
226        polling: PollingConfig,
227    ) -> Result<Solved<Solution>>
228    where
229        N: TaskTypeName,
230        T: Serialize + ?Sized,
231    {
232        let created = self.create_task(task_type, task).await?;
233        let task_id = created.task_id;
234        let request_id = created.meta.request_id;
235        let result = self
236            .wait_for_result_with(&task_id, polling)
237            .await
238            .map_err(|error| error.with_task_context(task_id.clone(), request_id.clone()))?;
239        Ok(Solved {
240            task_id: Some(task_id),
241            // The service assigns every HTTP request its own correlation id. The one from the
242            // result query describes the response the caller is holding, so it wins;
243            // creation's id is the fallback that keeps this field populated.
244            request_id: result.meta.request_id.or(request_id),
245            // On the general entry point the solution is the raw value already, so
246            // both fields come from the same place.
247            solution: result.solution.clone(),
248            raw: result.solution,
249        })
250    }
251
252    /// Runs a synchronous task and returns its raw solution without polling.
253    ///
254    /// Accepts a known task type or a custom type name, with parameters that
255    /// serialize to a JSON object. Uses [`ClientConfig::sync_timeout`].
256    ///
257    /// Like [`Self::solve`], returns [`Solved<Solution>`], preserving the raw
258    /// solution, including a missing field or JSON null. Both [`Solved::task_id`]
259    /// and [`Solved::request_id`] come from the synchronous response.
260    pub async fn sync_solve<N, T>(&self, task_type: N, task: &T) -> Result<Solved<Solution>>
261    where
262        N: TaskTypeName,
263        T: Serialize + ?Sized,
264    {
265        let result = self.create_sync_task(task_type, task).await?;
266        if result.status == TaskStatus::Error {
267            return Err(Error::UnexpectedResponse(
268                "task result returned status `error` without an API error".to_owned(),
269            ));
270        }
271        Ok(Solved {
272            task_id: result.task_id,
273            request_id: result.meta.request_id,
274            solution: result.solution.clone(),
275            raw: result.solution,
276        })
277    }
278
279    /// Creates a synchronous API task and returns its result.
280    ///
281    /// The supplied task type is injected into the serialized parameter object.
282    /// Use [`Self::sync_solve`] to return the same aggregate as [`Self::solve`].
283    pub async fn create_sync_task<N, T>(&self, task_type: N, task: &T) -> Result<TaskResult>
284    where
285        N: TaskTypeName,
286        T: Serialize + ?Sized,
287    {
288        let task_type = task_type.task_type_name();
289        debug!(task_type, "creating sync task");
290        let request = CreateTaskRequest {
291            client_key: &self.client_key,
292            app_id: self.config.app_id,
293            task: TaskPayload {
294                task_type,
295                params: task,
296            },
297        };
298        let result: TaskResult = self
299            .post(
300                &endpoint(&self.config.sync_base_url, "/createSyncTask"),
301                &request,
302                self.config.sync_timeout,
303            )
304            .await?;
305        if result.status == TaskStatus::Processing {
306            return Err(Error::UnexpectedResponse(
307                "createSyncTask returned status `processing`".to_owned(),
308            ));
309        }
310        info!(task_type, "sync task completed");
311        Ok(result)
312    }
313
314    /// Queries the current account balance.
315    pub async fn balance(&self) -> Result<f64> {
316        debug!("querying account balance");
317        let request = QueryBalanceRequest {
318            client_key: &self.client_key,
319        };
320        let response: BalanceResponse = self
321            .post(
322                &endpoint(&self.config.async_base_url, "/getBalance"),
323                &request,
324                self.config.timeout,
325            )
326            .await?;
327        Ok(response.balance)
328    }
329
330    async fn post<B, T>(&self, url: &str, body: &B, timeout: Duration) -> Result<T>
331    where
332        B: Serialize + ?Sized,
333        T: DeserializeOwned,
334    {
335        log_request(url, body);
336        let started = Instant::now();
337        let response = self
338            .http
339            .post(url)
340            .timeout(timeout)
341            .header(CLIENT_KEY_HEADER, self.client_key_header.clone())
342            .json(body)
343            .send()
344            .await?;
345        let status = response.status().as_u16();
346        let bytes = response.bytes().await?;
347        log_response(url, status, started.elapsed(), &bytes);
348        parse_api_response(status, &bytes)
349    }
350}
351
352fn build_http_client(config: &ClientConfig) -> Result<reqwest::Client> {
353    let mut builder = reqwest::Client::builder()
354        // No timeout here: the two endpoints have different timing characteristics,
355        // so each request applies its own budget over this one shared pool.
356        .user_agent(config.user_agent.clone());
357    if let Some(proxy) = &config.proxy {
358        let proxy = reqwest::Proxy::all(proxy)
359            .map_err(|error| Error::config(format!("invalid SDK proxy URL: {error}")))?;
360        builder = builder.proxy(proxy);
361    }
362    Ok(builder.build()?)
363}
364
365mod solvers;
366
367#[cfg(test)]
368mod tests;