Skip to main content

ezcapsolver/
config.rs

1use std::{fmt, time::Duration};
2
3#[cfg(any(feature = "async", feature = "blocking"))]
4use crate::{Error, Result};
5
6/// Default base URL for asynchronous task operations and balance queries.
7pub const DEFAULT_ASYNC_BASE_URL: &str = "https://api.ez-captcha.com";
8
9/// Default base URL for synchronous task operations.
10pub const DEFAULT_SYNC_BASE_URL: &str = "https://sync.ez-captcha.com";
11
12/// Default environment variable used to load the client key.
13pub const DEFAULT_CLIENT_KEY_ENV: &str = "EZCAPSOLVER_API_KEY";
14
15/// Default timeout for asynchronous endpoints and balance queries.
16pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
17
18/// Default timeout for the synchronous task endpoint.
19///
20/// The service grants its slowest synchronous task types a 180-second worker
21/// deadline. Matching that exactly would leave no margin: a worker that uses
22/// its full budget would be cut off client-side, and a synchronous task that
23/// times out cannot be recovered. This adds one minute of headroom.
24pub const DEFAULT_SYNC_TIMEOUT: Duration = Duration::from_secs(240);
25
26/// Default delay between two result queries.
27pub const DEFAULT_POLL_INTERVAL: Duration = Duration::from_secs(3);
28
29/// Default number of result queries before polling gives up.
30pub const DEFAULT_MAX_POLL_ATTEMPTS: usize = 50;
31
32/// Polling settings used while waiting for an asynchronous task.
33///
34/// Construct this with [`Self::new`] or [`Self::default`]; it is
35/// `#[non_exhaustive]` so that new settings can be added without a breaking
36/// change.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38#[non_exhaustive]
39pub struct PollingConfig {
40    /// Delay applied before every result query, including the first one.
41    ///
42    /// A freshly created task is still queued for a worker, so querying
43    /// immediately after creation almost always reports `processing`. Waiting
44    /// one interval first avoids that wasted request.
45    pub interval: Duration,
46    /// Maximum number of result queries before returning a timeout error.
47    pub max_attempts: usize,
48}
49
50impl Default for PollingConfig {
51    fn default() -> Self {
52        Self {
53            interval: DEFAULT_POLL_INTERVAL,
54            max_attempts: DEFAULT_MAX_POLL_ATTEMPTS,
55        }
56    }
57}
58
59impl PollingConfig {
60    /// Creates polling settings with the supplied interval and attempt limit.
61    #[must_use]
62    pub const fn new(interval: Duration, max_attempts: usize) -> Self {
63        Self {
64            interval,
65            max_attempts,
66        }
67    }
68
69    #[cfg(any(feature = "async", feature = "blocking"))]
70    pub(crate) fn validate(self) -> Result<()> {
71        if self.interval.is_zero() {
72            return Err(Error::config("polling interval must be greater than zero"));
73        }
74        if self.max_attempts == 0 {
75            return Err(Error::config(
76                "maximum polling attempts must be greater than zero",
77            ));
78        }
79        Ok(())
80    }
81}
82
83/// Configuration shared by the asynchronous and blocking clients.
84///
85/// All builder settings are optional and use the same defaults as
86/// [`Self::default`]. The client resolves the key and validates the settings
87/// when it is created.
88///
89/// ```
90/// use std::time::Duration;
91/// use ezcapsolver::ClientConfig;
92///
93/// let config = ClientConfig::builder()
94///     .timeout(Duration::from_secs(60))
95///     .user_agent("my-app/1.0")
96///     .build();
97/// assert_eq!(config.timeout, Duration::from_secs(60));
98/// assert_eq!(config.sync_timeout, Duration::from_secs(240));
99/// ```
100#[derive(Clone, PartialEq, Eq, bon::Builder)]
101#[builder(on(String, into))]
102pub struct ClientConfig {
103    /// Optional key; when unset, the client reads `EZCAPSOLVER_API_KEY`.
104    client_key: Option<String>,
105    /// Base URL for asynchronous task operations and balance queries.
106    ///
107    /// Override it to reach a private gateway, or to point the client at a test
108    /// server. The service splits the two deployments, so they cannot share one
109    /// host — see [`Self::sync_base_url`].
110    #[builder(default = DEFAULT_ASYNC_BASE_URL.to_owned())]
111    pub async_base_url: String,
112    /// Base URL for synchronous task operations.
113    #[builder(default = DEFAULT_SYNC_BASE_URL.to_owned())]
114    pub sync_base_url: String,
115    /// Timeout applied to asynchronous endpoints and balance queries.
116    ///
117    /// These are millisecond-scale enqueue and lookup calls, so a short value
118    /// keeps a network fault distinguishable from a slow service.
119    #[builder(default = DEFAULT_TIMEOUT)]
120    pub timeout: Duration,
121    /// Timeout applied to the synchronous task endpoint.
122    ///
123    /// A synchronous call blocks until the worker answers, and the service
124    /// grants some task types a three-minute worker deadline. Sharing the
125    /// asynchronous timeout would abort a call the service is still billing
126    /// for, and a call that times out never receives the task ID it would take
127    /// to recover the result.
128    #[builder(default = DEFAULT_SYNC_TIMEOUT)]
129    pub sync_timeout: Duration,
130    /// Polling settings for asynchronous task completion.
131    #[builder(default)]
132    pub polling: PollingConfig,
133    /// Optional developer application identifier.
134    pub app_id: Option<i32>,
135    /// Optional proxy used by the SDK to reach the EzCaptchaSolver API.
136    pub proxy: Option<String>,
137    /// User agent sent with every request.
138    #[builder(default = default_user_agent())]
139    pub user_agent: String,
140}
141
142/// Default user agent identifying this SDK and its version.
143fn default_user_agent() -> String {
144    concat!("ezcapsolver-rs/", env!("CARGO_PKG_VERSION")).to_owned()
145}
146
147impl Default for ClientConfig {
148    fn default() -> Self {
149        Self {
150            client_key: None,
151            async_base_url: DEFAULT_ASYNC_BASE_URL.to_owned(),
152            sync_base_url: DEFAULT_SYNC_BASE_URL.to_owned(),
153            timeout: DEFAULT_TIMEOUT,
154            sync_timeout: DEFAULT_SYNC_TIMEOUT,
155            polling: PollingConfig::default(),
156            app_id: None,
157            proxy: None,
158            user_agent: default_user_agent(),
159        }
160    }
161}
162
163impl ClientConfig {
164    /// Sets the client key instead of loading it from the default environment variable.
165    #[must_use]
166    pub fn with_client_key(mut self, value: impl Into<String>) -> Self {
167        self.client_key = Some(value.into());
168        self
169    }
170
171    /// Points the client at different hosts, for a self-hosted deployment or a
172    /// test server. An empty string keeps the current value.
173    #[must_use]
174    pub fn with_base_urls(
175        mut self,
176        async_base_url: impl Into<String>,
177        sync_base_url: impl Into<String>,
178    ) -> Self {
179        let async_base_url = async_base_url.into();
180        if !async_base_url.is_empty() {
181            self.async_base_url = async_base_url;
182        }
183        let sync_base_url = sync_base_url.into();
184        if !sync_base_url.is_empty() {
185            self.sync_base_url = sync_base_url;
186        }
187        self
188    }
189
190    /// Sets the timeout for asynchronous endpoints and balance queries.
191    #[must_use]
192    pub const fn with_timeout(mut self, value: Duration) -> Self {
193        self.timeout = value;
194        self
195    }
196
197    /// Sets the timeout for the synchronous task endpoint.
198    #[must_use]
199    pub const fn with_sync_timeout(mut self, value: Duration) -> Self {
200        self.sync_timeout = value;
201        self
202    }
203
204    /// Sets asynchronous task polling options.
205    #[must_use]
206    pub const fn with_polling(mut self, value: PollingConfig) -> Self {
207        self.polling = value;
208        self
209    }
210
211    /// Sets the optional developer application identifier.
212    #[must_use]
213    pub const fn with_app_id(mut self, value: i32) -> Self {
214        self.app_id = Some(value);
215        self
216    }
217
218    /// Sets the proxy used by the SDK HTTP client.
219    #[must_use]
220    pub fn with_proxy(mut self, value: impl Into<String>) -> Self {
221        self.proxy = Some(value.into());
222        self
223    }
224
225    /// Overrides the user agent sent with every request.
226    #[must_use]
227    pub fn with_user_agent(mut self, value: impl Into<String>) -> Self {
228        self.user_agent = value.into();
229        self
230    }
231
232    #[cfg(any(feature = "async", feature = "blocking"))]
233    pub(crate) fn validate(&self) -> Result<()> {
234        if self.async_base_url.trim().is_empty() || self.sync_base_url.trim().is_empty() {
235            return Err(Error::config("base URLs must not be blank"));
236        }
237        if self.timeout.is_zero() {
238            return Err(Error::config("timeout must be greater than zero"));
239        }
240        if self.sync_timeout.is_zero() {
241            return Err(Error::config("sync timeout must be greater than zero"));
242        }
243        self.polling.validate()
244    }
245
246    #[cfg(any(feature = "async", feature = "blocking"))]
247    pub(crate) fn resolve_client_key(&self) -> Result<String> {
248        let client_key = match &self.client_key {
249            Some(client_key) => client_key.clone(),
250            None => std::env::var(DEFAULT_CLIENT_KEY_ENV).map_err(|source| {
251                Error::config(format!(
252                    "client key environment variable `{DEFAULT_CLIENT_KEY_ENV}` is unavailable: {source}"
253                ))
254            })?,
255        };
256        if client_key.trim().is_empty() {
257            return Err(Error::config("client key must not be blank"));
258        }
259        Ok(client_key)
260    }
261}
262
263impl fmt::Debug for ClientConfig {
264    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
265        formatter
266            .debug_struct("ClientConfig")
267            .field(
268                "client_key",
269                &self.client_key.as_ref().map(|_| "[REDACTED]"),
270            )
271            .field("async_base_url", &self.async_base_url)
272            .field("sync_base_url", &self.sync_base_url)
273            .field("timeout", &self.timeout)
274            .field("sync_timeout", &self.sync_timeout)
275            .field("polling", &self.polling)
276            .field("app_id", &self.app_id)
277            .field("proxy", &self.proxy.as_ref().map(|_| "[REDACTED]"))
278            .field("user_agent", &self.user_agent)
279            .finish()
280    }
281}
282
283impl From<String> for ClientConfig {
284    fn from(client_key: String) -> Self {
285        Self::default().with_client_key(client_key)
286    }
287}
288
289impl From<&str> for ClientConfig {
290    fn from(client_key: &str) -> Self {
291        Self::from(client_key.to_owned())
292    }
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298
299    #[test]
300    fn synchronous_and_asynchronous_timeouts_have_distinct_defaults() {
301        let config = ClientConfig::default();
302
303        // The synchronous endpoint blocks until the worker answers, which the
304        // service allows up to three minutes; the asynchronous one only enqueues
305        // and polls. One shared value would have to be wrong for one of them.
306        assert_eq!(config.timeout, Duration::from_secs(30));
307        // Strictly greater than the service's 180s worker deadline, or a worker
308        // that uses its full budget gets cut off by the client.
309        assert!(config.sync_timeout > Duration::from_secs(180));
310        assert_eq!(config.sync_timeout, Duration::from_secs(240));
311        assert_ne!(config.timeout, config.sync_timeout);
312    }
313
314    #[test]
315    fn polling_defaults_match_the_specified_budget() {
316        let polling = PollingConfig::default();
317
318        assert_eq!(polling.interval, Duration::from_secs(3));
319        assert_eq!(polling.max_attempts, 50);
320    }
321
322    #[test]
323    fn debug_output_redacts_credentials() {
324        let config = ClientConfig::default()
325            .with_client_key("secret-client-key")
326            .with_proxy("http://user:password@127.0.0.1:8080");
327        let output = format!("{config:?}");
328
329        assert!(!output.contains("secret-client-key"));
330        assert!(!output.contains("password"));
331        assert!(output.contains("[REDACTED]"));
332    }
333}