use super::*;
use crate::captcha_detect::DetectedCaptcha;
use std::time::Duration;
const POLL_INTERVAL_MS: u64 = 5_000;
const MAX_POLLS: u32 = 30;
const ENV_API_KEY: &str = "CAPTCHAFORGE_THIRDPARTY_API_KEY";
const ENV_ENDPOINT: &str = "CAPTCHAFORGE_THIRDPARTY_ENDPOINT";
const TRANSIENT_RETRY_ATTEMPTS: u32 = 3;
const TRANSIENT_RETRY_BASE_MS: u64 = 250;
const TRANSIENT_RETRY_MAX_MS: u64 = 4_000;
#[derive(Debug, Clone)]
pub enum ThirdPartyService {
TwoCaptcha,
CapMonster,
CapSolver,
Custom { base_url: String },
}
impl ThirdPartyService {
pub fn base_url(&self) -> &str {
match self {
Self::TwoCaptcha => "https://2captcha.com",
Self::CapMonster => "https://api.capmonster.cloud",
Self::CapSolver => "https://api.capsolver.com",
Self::Custom { base_url } => base_url,
}
}
}
#[derive(Debug)]
pub(crate) struct KeyPool {
keys: Vec<String>,
cursor: std::sync::atomic::AtomicUsize,
}
impl KeyPool {
fn new(keys: Vec<String>) -> Self {
Self {
keys,
cursor: std::sync::atomic::AtomicUsize::new(0),
}
}
pub(crate) fn advance(&self) -> String {
let n = self.keys.len();
if n == 0 {
return String::new();
}
let prev = self
.cursor
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
self.keys[(prev + 1) % n].clone()
}
#[allow(dead_code)]
pub(crate) fn current(&self) -> String {
let n = self.keys.len();
if n == 0 {
return String::new();
}
let idx = self.cursor.load(std::sync::atomic::Ordering::SeqCst);
self.keys[idx % n].clone()
}
}
pub struct ThirdPartyCaptchaSolver {
pub(crate) client: reqwest::Client,
pub(crate) service: ThirdPartyService,
pub(crate) api_key: Option<String>,
pub(crate) poll_interval_ms: u64,
pub(crate) max_polls: u32,
pub(crate) key_pool: Option<std::sync::Arc<KeyPool>>,
}
impl Default for ThirdPartyCaptchaSolver {
fn default() -> Self {
Self::two_captcha()
}
}
fn validate_endpoint_url(raw: &str) -> bool {
let Ok(parsed) = url::Url::parse(raw) else {
return false;
};
if !matches!(parsed.scheme(), "http" | "https") {
return false;
}
let Some(host) = parsed.host() else {
return false;
};
match host {
url::Host::Ipv4(addr) => {
!(addr.is_loopback()
|| addr.is_private()
|| addr.is_link_local()
|| addr.is_unspecified())
}
url::Host::Ipv6(addr) => {
if let Some(v4) = addr.to_ipv4_mapped() {
if v4.is_loopback() || v4.is_private() || v4.is_link_local() {
return false;
}
}
let seg = addr.segments();
!(addr.is_loopback()
|| addr.is_unspecified()
|| (seg[0] & 0xffc0) == 0xfe80
|| (seg[0] & 0xfe00) == 0xfc00)
}
url::Host::Domain(d) => {
let lower = d.to_ascii_lowercase();
!matches!(
lower.as_str(),
"localhost"
| "ip6-localhost"
| "ip6-loopback"
| "metadata.google.internal"
| "metadata.aws.internal"
)
}
}
}
impl ThirdPartyCaptchaSolver {
pub fn two_captcha() -> Self {
Self::for_service(ThirdPartyService::TwoCaptcha)
}
pub fn cap_monster() -> Self {
Self::for_service(ThirdPartyService::CapMonster)
}
pub fn cap_solver() -> Self {
Self::for_service(ThirdPartyService::CapSolver)
}
pub fn custom_endpoint(base_url: impl Into<String>) -> Self {
Self::for_service(ThirdPartyService::Custom {
base_url: base_url.into(),
})
}
fn for_service(service: ThirdPartyService) -> Self {
let service = match std::env::var(ENV_ENDPOINT) {
Ok(custom) if !custom.is_empty() => {
if validate_endpoint_url(&custom) {
ThirdPartyService::Custom { base_url: custom }
} else {
tracing::warn!(
endpoint = %custom,
"CAPTCHAFORGE_THIRDPARTY_ENDPOINT failed SSRF policy — \
falling back to declared service"
);
service
}
}
_ => service,
};
let api_key = std::env::var(ENV_API_KEY).ok().filter(|s| !s.is_empty());
Self {
client: reqwest::Client::builder()
.timeout(Duration::from_secs(60))
.build()
.unwrap_or_else(|_| reqwest::Client::new()),
service,
api_key,
poll_interval_ms: POLL_INTERVAL_MS,
max_polls: MAX_POLLS,
key_pool: None,
}
}
pub fn with_api_key(mut self, key: impl Into<String>) -> Self {
self.api_key = Some(key.into());
self.key_pool = None;
self
}
#[must_use = "with_api_key_pool returns Self; assign or chain it"]
pub fn with_api_key_pool<I, S>(mut self, keys: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
let pool: Vec<String> = keys.into_iter().map(Into::into).filter(|k| !k.is_empty()).collect();
if pool.is_empty() {
self.key_pool = None;
return self;
}
self.api_key = pool.first().cloned();
self.key_pool = Some(std::sync::Arc::new(KeyPool::new(pool)));
self
}
pub fn rotate_api_key(&mut self) -> Option<String> {
let pool = self.key_pool.as_ref()?;
let next = pool.advance();
self.api_key = Some(next.clone());
Some(next)
}
pub fn with_poll_interval_ms(mut self, ms: u64) -> Self {
self.poll_interval_ms = ms;
self
}
pub fn with_max_polls(mut self, polls: u32) -> Self {
self.max_polls = polls;
self
}
pub fn has_api_key(&self) -> bool {
self.api_key.as_ref().is_some_and(|k| !k.is_empty())
}
pub(crate) fn build_task_params(info: &CaptchaInfo) -> Option<Vec<(&'static str, String)>> {
let pageurl = info.page_url.clone();
let sitekey = info.site_key.clone();
match &info.kind {
DetectedCaptcha::Turnstile => {
let key = sitekey?;
Some(vec![
("method", "turnstile".into()),
("sitekey", key),
("pageurl", pageurl),
("json", "1".into()),
])
}
DetectedCaptcha::RecaptchaV2 => {
let key = sitekey?;
Some(vec![
("method", "userrecaptcha".into()),
("googlekey", key),
("pageurl", pageurl),
("json", "1".into()),
])
}
DetectedCaptcha::RecaptchaV3 => {
let key = sitekey?;
Some(vec![
("method", "userrecaptcha".into()),
("googlekey", key),
("pageurl", pageurl),
("version", "v3".into()),
("min_score", "0.3".into()),
("json", "1".into()),
])
}
DetectedCaptcha::HCaptcha => {
let key = sitekey?;
Some(vec![
("method", "hcaptcha".into()),
("sitekey", key),
("pageurl", pageurl),
("json", "1".into()),
])
}
DetectedCaptcha::Custom(name) => match name.as_str() {
"datadome" => {
let captcha_url = info
.container_selector
.clone()
.unwrap_or_else(|| pageurl.clone());
Some(vec![
("method", "datadome".into()),
("captcha_url", captcha_url),
("pageurl", pageurl),
("json", "1".into()),
])
}
"arkose_funcaptcha" => {
let key = sitekey?;
Some(vec![
("method", "funcaptcha".into()),
("publickey", key),
("pageurl", pageurl),
("surl", "https://client-api.arkoselabs.com".into()),
("json", "1".into()),
])
}
"geetest_v3" => {
let gt = sitekey?;
Some(vec![
("method", "geetest".into()),
("gt", gt),
(
"challenge",
info.container_selector.clone().unwrap_or_default(),
),
("pageurl", pageurl),
("json", "1".into()),
])
}
"geetest_v4" => {
let captcha_id = sitekey?;
Some(vec![
("method", "geetest_v4".into()),
("captcha_id", captcha_id),
("pageurl", pageurl),
("json", "1".into()),
])
}
"aws_waf_captcha" => {
let _key = sitekey?;
None
}
_ => None,
},
_ => None,
}
}
async fn submit_task(&self, params: &[(&'static str, String)]) -> Result<String> {
let key = self
.api_key
.as_deref()
.ok_or_else(|| anyhow!("no API key configured for ThirdPartyCaptchaSolver"))?;
let mut form: Vec<(&str, String)> = Vec::with_capacity(params.len() + 1);
form.push(("key", key.to_string()));
for (k, v) in params {
form.push((*k, v.clone()));
}
let url = format!("{}/in.php", self.service.base_url().trim_end_matches('/'));
retry_transient("third-party submit", || async {
let resp = self
.client
.post(&url)
.form(&form)
.send()
.await
.map_err(transient)?;
let status = resp.status();
let body = resp.text().await.map_err(transient)?;
if status.is_server_error() {
return Err(RetryError::Transient(anyhow!(
"third-party submit {status}: {body}"
)));
}
if !status.is_success() {
return Err(RetryError::Terminal(anyhow!(
"third-party submit returned {status}: {body}"
)));
}
let v: serde_json::Value = serde_json::from_str(&body).map_err(|e| {
RetryError::Terminal(anyhow!("third-party submit json parse: {e}; body={body}"))
})?;
if v["status"].as_i64().unwrap_or(0) != 1 {
let err_str = v["request"].as_str().unwrap_or("<no error>").to_string();
if is_terminal_error(&err_str) {
return Err(RetryError::Terminal(anyhow!(
"third-party submit terminal error: {err_str}"
)));
}
return Err(RetryError::Transient(anyhow!(
"third-party submit error: {err_str}"
)));
}
v["request"].as_str().map(|s| s.to_string()).ok_or_else(|| {
RetryError::Terminal(anyhow!(
"third-party submit response missing 'request' field"
))
})
})
.await
}
async fn poll_result(&self, task_id: &str) -> Result<String> {
let key = self
.api_key
.as_deref()
.ok_or_else(|| anyhow!("no API key configured for ThirdPartyCaptchaSolver"))?;
let url = format!("{}/res.php", self.service.base_url().trim_end_matches('/'));
for _ in 0..self.max_polls {
tokio::time::sleep(Duration::from_millis(self.poll_interval_ms)).await;
let body = retry_transient("third-party poll", || async {
let resp = self
.client
.get(&url)
.query(&[
("key", key),
("action", "get"),
("id", task_id),
("json", "1"),
])
.send()
.await
.map_err(transient)?;
let status = resp.status();
if status.is_server_error() {
return Err(RetryError::Transient(anyhow!("third-party poll {status}")));
}
if !status.is_success() {
return Err(RetryError::Terminal(anyhow!(
"third-party poll returned {status}"
)));
}
resp.text().await.map_err(transient)
})
.await?;
let v: serde_json::Value = serde_json::from_str(&body)
.map_err(|e| anyhow!("third-party poll json parse: {e}; body={body}"))?;
let status = v["status"].as_i64().unwrap_or(0);
let request = v["request"].as_str().unwrap_or("");
if status == 1 {
return Ok(request.to_string());
}
if request == "CAPCHA_NOT_READY" {
continue;
}
if is_terminal_error(request) {
return Err(anyhow!(
"third-party terminal error (won't retry): {request}"
));
}
}
Err(anyhow!(
"third-party solver timed out after {} polls",
self.max_polls
))
}
}
#[derive(Debug)]
enum RetryError {
Transient(anyhow::Error),
Terminal(anyhow::Error),
}
fn transient(e: reqwest::Error) -> RetryError {
RetryError::Transient(anyhow!("reqwest: {e}"))
}
async fn retry_transient<F, Fut, T>(label: &'static str, mut op: F) -> Result<T>
where
F: FnMut() -> Fut,
Fut: std::future::Future<Output = std::result::Result<T, RetryError>>,
{
let mut backoff_ms = TRANSIENT_RETRY_BASE_MS;
let mut last_err: Option<anyhow::Error> = None;
for attempt in 0..TRANSIENT_RETRY_ATTEMPTS {
match op().await {
Ok(v) => return Ok(v),
Err(RetryError::Terminal(e)) => return Err(e),
Err(RetryError::Transient(e)) => {
last_err = Some(e);
if attempt + 1 < TRANSIENT_RETRY_ATTEMPTS {
tokio::time::sleep(Duration::from_millis(backoff_ms)).await;
backoff_ms = (backoff_ms * 2).min(TRANSIENT_RETRY_MAX_MS);
}
}
}
}
Err(last_err.unwrap_or_else(|| anyhow!("{label} exhausted {TRANSIENT_RETRY_ATTEMPTS} retries")))
}
fn is_terminal_error(code: &str) -> bool {
matches!(
code,
"ERROR_KEY_DOES_NOT_EXIST"
| "ERROR_WRONG_USER_KEY"
| "ERROR_ZERO_BALANCE"
| "ERROR_NO_SLOT_AVAILABLE"
| "ERROR_IP_NOT_ALLOWED"
| "IP_BANNED"
| "ERROR_BAD_TOKEN_OR_PAGEURL"
| "ERROR_BAD_DUPLICATES"
| "ERROR_PAGEURL"
| "ERROR_GOOGLEKEY"
| "ERROR_TOKEN_EXPIRED"
| "ERROR_CAPTCHA_UNSOLVABLE"
| "ERROR_BAD_PARAMETERS"
| "ERROR_WRONG_GOOGLEKEY"
| "ERROR_DOMAIN_NOT_ALLOWED"
| "ERROR_PROXY_CONNECT_REFUSED"
| "ERROR_PROXY_FORMAT"
)
}
#[async_trait]
impl CaptchaSolver for ThirdPartyCaptchaSolver {
fn name(&self) -> &'static str {
"ThirdPartyCaptchaSolver"
}
fn method(&self) -> SolveMethod {
SolveMethod::ThirdPartyService
}
fn supports(&self, kind: &DetectedCaptcha) -> bool {
if !self.has_api_key() {
return false;
}
match kind {
DetectedCaptcha::Turnstile
| DetectedCaptcha::RecaptchaV2
| DetectedCaptcha::RecaptchaV3
| DetectedCaptcha::HCaptcha => true,
DetectedCaptcha::Custom(name) => matches!(
name.as_str(),
"datadome" | "arkose_funcaptcha" | "geetest_v3" | "geetest_v4"
),
_ => false,
}
}
async fn solve(&self, page: &Page, captcha_info: &CaptchaInfo) -> Result<CaptchaSolveResult> {
let t0 = Instant::now();
if !self.has_api_key() {
return Err(anyhow!("ThirdPartyCaptchaSolver has no API key configured"));
}
let params = Self::build_task_params(captcha_info).ok_or_else(|| {
anyhow!(
"ThirdPartyCaptchaSolver cannot build task params for {:?}",
captcha_info.kind
)
})?;
let task_id = self.submit_task(¶ms).await?;
let token = self.poll_result(&task_id).await?;
let injection = match &captcha_info.kind {
DetectedCaptcha::Turnstile => Some(format!(
"(() => {{ const inp = document.querySelector('[name=\"cf-turnstile-response\"]'); if (inp) inp.value = {}; }})()",
serde_json::to_string(&token).unwrap_or_else(|_| "''".into())
)),
DetectedCaptcha::RecaptchaV2 | DetectedCaptcha::RecaptchaV3 => Some(format!(
"(() => {{ const inp = document.querySelector('[name=\"g-recaptcha-response\"]'); if (inp) inp.value = {0}; const el = document.getElementById('g-recaptcha-response'); if (el) el.innerHTML = {0}; }})()",
serde_json::to_string(&token).unwrap_or_else(|_| "''".into())
)),
DetectedCaptcha::HCaptcha => Some(format!(
"(() => {{ const inp = document.querySelector('[name=\"h-captcha-response\"]'); if (inp) inp.value = {}; }})()",
serde_json::to_string(&token).unwrap_or_else(|_| "''".into())
)),
_ => None,
};
if let Some(js) = injection {
let _ = page.evaluate(js).await;
}
let cookies = crate::cookies::capture_from_page(page)
.await
.unwrap_or_default();
Ok(CaptchaSolveResult {
solution: token,
confidence: 1.0,
method: SolveMethod::ThirdPartyService,
time_ms: t0.elapsed().as_millis() as u64,
success: true,
screenshot: None,
cookies,
verified_outcome: None,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn service_base_urls() {
assert_eq!(
ThirdPartyService::TwoCaptcha.base_url(),
"https://2captcha.com"
);
assert_eq!(
ThirdPartyService::CapMonster.base_url(),
"https://api.capmonster.cloud"
);
assert_eq!(
ThirdPartyService::CapSolver.base_url(),
"https://api.capsolver.com"
);
assert_eq!(
ThirdPartyService::Custom {
base_url: "https://my.gw".into()
}
.base_url(),
"https://my.gw"
);
}
#[test]
fn no_api_key_means_no_support() {
let s = ThirdPartyCaptchaSolver {
client: reqwest::Client::new(),
service: ThirdPartyService::TwoCaptcha,
api_key: None,
poll_interval_ms: 1,
max_polls: 1,
key_pool: None,
};
assert!(!s.has_api_key());
assert!(!s.supports(&DetectedCaptcha::Turnstile));
assert!(!s.supports(&DetectedCaptcha::RecaptchaV2));
assert!(!s.supports(&DetectedCaptcha::HCaptcha));
}
#[test]
fn with_api_key_enables_support_for_known_kinds() {
let s = ThirdPartyCaptchaSolver {
client: reqwest::Client::new(),
service: ThirdPartyService::TwoCaptcha,
api_key: Some("test-key".into()),
poll_interval_ms: 1,
max_polls: 1,
key_pool: None,
};
assert!(s.supports(&DetectedCaptcha::Turnstile));
assert!(s.supports(&DetectedCaptcha::RecaptchaV2));
assert!(s.supports(&DetectedCaptcha::RecaptchaV3));
assert!(s.supports(&DetectedCaptcha::HCaptcha));
assert!(s.supports(&DetectedCaptcha::Custom("datadome".into())));
assert!(s.supports(&DetectedCaptcha::Custom("arkose_funcaptcha".into())));
assert!(s.supports(&DetectedCaptcha::Custom("geetest_v3".into())));
assert!(s.supports(&DetectedCaptcha::Custom("geetest_v4".into())));
}
#[test]
fn with_api_key_does_not_support_unknown_custom_or_unsupported_kinds() {
let s = ThirdPartyCaptchaSolver {
client: reqwest::Client::new(),
service: ThirdPartyService::TwoCaptcha,
api_key: Some("k".into()),
poll_interval_ms: 1,
max_polls: 1,
key_pool: None,
};
assert!(!s.supports(&DetectedCaptcha::Custom("never_heard_of_it".into())));
assert!(!s.supports(&DetectedCaptcha::PowCaptcha));
assert!(!s.supports(&DetectedCaptcha::SliderCaptcha));
assert!(!s.supports(&DetectedCaptcha::CanvasCaptcha));
assert!(!s.supports(&DetectedCaptcha::None));
assert!(!s.supports(&DetectedCaptcha::Custom("aws_waf_captcha".into())));
}
fn info(kind: DetectedCaptcha, sitekey: Option<&str>) -> CaptchaInfo {
CaptchaInfo {
kind,
site_key: sitekey.map(String::from),
page_url: "https://example.com/login".into(),
container_selector: None,
}
}
#[test]
fn task_params_turnstile_uses_method_turnstile_with_sitekey() {
let p = ThirdPartyCaptchaSolver::build_task_params(&info(
DetectedCaptcha::Turnstile,
Some("0x4AAAAAAA-test"),
))
.unwrap();
assert!(p.iter().any(|(k, v)| *k == "method" && v == "turnstile"));
assert!(p
.iter()
.any(|(k, v)| *k == "sitekey" && v == "0x4AAAAAAA-test"));
assert!(p.iter().any(|(k, _)| *k == "pageurl"));
}
#[test]
fn task_params_recaptcha_v2_uses_googlekey() {
let p = ThirdPartyCaptchaSolver::build_task_params(&info(
DetectedCaptcha::RecaptchaV2,
Some("6Lc-test"),
))
.unwrap();
assert!(p
.iter()
.any(|(k, v)| *k == "method" && v == "userrecaptcha"));
assert!(p.iter().any(|(k, v)| *k == "googlekey" && v == "6Lc-test"));
}
#[test]
fn task_params_recaptcha_v3_includes_version() {
let p = ThirdPartyCaptchaSolver::build_task_params(&info(
DetectedCaptcha::RecaptchaV3,
Some("6Lc-v3-test"),
))
.unwrap();
assert!(p.iter().any(|(k, v)| *k == "version" && v == "v3"));
assert!(p.iter().any(|(k, _)| *k == "min_score"));
}
#[test]
fn task_params_hcaptcha_uses_hcaptcha_method() {
let p = ThirdPartyCaptchaSolver::build_task_params(&info(
DetectedCaptcha::HCaptcha,
Some("hsk"),
))
.unwrap();
assert!(p.iter().any(|(k, v)| *k == "method" && v == "hcaptcha"));
}
#[test]
fn task_params_datadome_works_without_sitekey() {
let mut info = info(DetectedCaptcha::Custom("datadome".into()), None);
info.container_selector = Some("https://geo.captcha-delivery.com/captcha?...".into());
let p = ThirdPartyCaptchaSolver::build_task_params(&info).unwrap();
assert!(p.iter().any(|(k, v)| *k == "method" && v == "datadome"));
assert!(p.iter().any(|(k, _)| *k == "captcha_url"));
}
#[test]
fn task_params_funcaptcha_uses_publickey() {
let p = ThirdPartyCaptchaSolver::build_task_params(&info(
DetectedCaptcha::Custom("arkose_funcaptcha".into()),
Some("AB12CD34"),
))
.unwrap();
assert!(p.iter().any(|(k, v)| *k == "method" && v == "funcaptcha"));
assert!(p.iter().any(|(k, v)| *k == "publickey" && v == "AB12CD34"));
}
#[test]
fn task_params_returns_none_for_kinds_without_a_method() {
assert!(ThirdPartyCaptchaSolver::build_task_params(&info(
DetectedCaptcha::PowCaptcha,
None
))
.is_none());
assert!(ThirdPartyCaptchaSolver::build_task_params(&info(
DetectedCaptcha::SliderCaptcha,
None
))
.is_none());
assert!(
ThirdPartyCaptchaSolver::build_task_params(&info(DetectedCaptcha::None, None))
.is_none()
);
assert!(ThirdPartyCaptchaSolver::build_task_params(&info(
DetectedCaptcha::Custom("zomg_unknown".into()),
Some("k")
))
.is_none());
}
#[test]
fn task_params_returns_none_when_required_sitekey_missing() {
assert!(ThirdPartyCaptchaSolver::build_task_params(&info(
DetectedCaptcha::Turnstile,
None
))
.is_none());
assert!(ThirdPartyCaptchaSolver::build_task_params(&info(
DetectedCaptcha::RecaptchaV2,
None
))
.is_none());
assert!(
ThirdPartyCaptchaSolver::build_task_params(&info(DetectedCaptcha::HCaptcha, None))
.is_none()
);
}
#[test]
fn builders_set_fields() {
let s = ThirdPartyCaptchaSolver::two_captcha()
.with_api_key("k123")
.with_poll_interval_ms(2000)
.with_max_polls(10);
assert_eq!(s.api_key.as_deref(), Some("k123"));
assert_eq!(s.poll_interval_ms, 2000);
assert_eq!(s.max_polls, 10);
}
#[tokio::test]
async fn retry_succeeds_first_try_on_immediate_ok() {
let attempts = std::sync::atomic::AtomicU32::new(0);
let r: Result<i32> = retry_transient("test", || async {
attempts.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Ok::<_, RetryError>(42)
})
.await;
assert_eq!(r.unwrap(), 42);
assert_eq!(attempts.load(std::sync::atomic::Ordering::SeqCst), 1);
}
#[tokio::test]
async fn retry_recovers_after_transient_failure() {
let attempts = std::sync::atomic::AtomicU32::new(0);
let r: Result<&'static str> = retry_transient("test", || async {
let n = attempts.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
if n == 0 {
Err(RetryError::Transient(anyhow!("blip")))
} else {
Ok::<_, RetryError>("recovered")
}
})
.await;
assert_eq!(r.unwrap(), "recovered");
assert_eq!(attempts.load(std::sync::atomic::Ordering::SeqCst), 2);
}
#[tokio::test]
async fn retry_exhausts_after_all_transient_failures() {
let attempts = std::sync::atomic::AtomicU32::new(0);
let r: Result<()> = retry_transient("test", || async {
attempts.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Err(RetryError::Transient(anyhow!("always fails")))
})
.await;
assert!(r.is_err());
assert_eq!(
attempts.load(std::sync::atomic::Ordering::SeqCst),
TRANSIENT_RETRY_ATTEMPTS
);
}
#[tokio::test]
async fn retry_short_circuits_on_terminal_error() {
let attempts = std::sync::atomic::AtomicU32::new(0);
let r: Result<()> = retry_transient("test", || async {
attempts.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Err(RetryError::Terminal(anyhow!("bad key")))
})
.await;
assert!(r.is_err());
assert_eq!(attempts.load(std::sync::atomic::Ordering::SeqCst), 1);
}
#[test]
fn terminal_error_codes_match_2captcha_docs() {
for code in [
"ERROR_KEY_DOES_NOT_EXIST",
"ERROR_ZERO_BALANCE",
"ERROR_WRONG_USER_KEY",
"IP_BANNED",
"ERROR_CAPTCHA_UNSOLVABLE",
"ERROR_GOOGLEKEY",
] {
assert!(is_terminal_error(code), "should be terminal: {code}");
}
}
#[test]
fn capcha_not_ready_is_not_terminal() {
assert!(!is_terminal_error("CAPCHA_NOT_READY"));
assert!(!is_terminal_error(""));
assert!(!is_terminal_error("UNKNOWN_CODE_FROM_NEW_VENDOR"));
}
#[test]
fn method_and_name_are_stable() {
let s = ThirdPartyCaptchaSolver {
client: reqwest::Client::new(),
service: ThirdPartyService::TwoCaptcha,
api_key: Some("k".into()),
poll_interval_ms: 1,
max_polls: 1,
key_pool: None,
};
assert_eq!(s.name(), "ThirdPartyCaptchaSolver");
assert_eq!(s.method(), SolveMethod::ThirdPartyService);
}
#[test]
fn key_pool_round_robins_in_order() {
let pool = KeyPool::new(vec!["a".into(), "b".into(), "c".into()]);
assert_eq!(pool.current(), "a");
assert_eq!(pool.current(), "a");
assert_eq!(pool.advance(), "b");
assert_eq!(pool.advance(), "c");
assert_eq!(pool.advance(), "a");
assert_eq!(pool.advance(), "b");
}
#[test]
fn empty_key_pool_returns_empty_string() {
let pool = KeyPool::new(vec![]);
assert_eq!(pool.current(), "");
assert_eq!(pool.advance(), "");
}
#[test]
fn with_api_key_pool_seeds_first_key_and_supports_rotation() {
let mut s = ThirdPartyCaptchaSolver::two_captcha()
.with_api_key_pool(["k1".to_string(), "k2".into(), "k3".into()]);
assert_eq!(s.api_key.as_deref(), Some("k1"));
let next = s.rotate_api_key().expect("pool exists");
assert_eq!(next, "k2");
assert_eq!(s.api_key.as_deref(), Some("k2"));
assert_eq!(s.rotate_api_key().unwrap(), "k3");
assert_eq!(s.rotate_api_key().unwrap(), "k1"); }
#[test]
fn empty_pool_disables_rotation_entirely() {
let v: Vec<String> = vec![];
let mut s = ThirdPartyCaptchaSolver::two_captcha().with_api_key_pool(v);
assert!(s.rotate_api_key().is_none());
}
#[test]
fn with_api_key_clears_any_pool() {
let mut s = ThirdPartyCaptchaSolver::two_captcha()
.with_api_key_pool(["k1".to_string(), "k2".into()])
.with_api_key("override");
assert_eq!(s.api_key.as_deref(), Some("override"));
assert!(s.rotate_api_key().is_none(), "with_api_key must clear pool");
}
}