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#[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 pub fn new() -> Result<Self> {
45 Self::with_config(ClientConfig::default())
46 }
47
48 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 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 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 #[must_use]
90 pub const fn config(&self) -> &ClientConfig {
91 &self.config
92 }
93
94 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 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 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 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 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 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 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 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 request_id: result.meta.request_id.or(request_id),
240 solution: result.solution.clone(),
243 raw: result.solution,
244 })
245 }
246
247 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 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 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 .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;