captchaforge 0.2.40

Captcha detection and solving for Firefox and BiDi-driven browsers. Detection, vendor solver scaffolding, trusted cross-origin click delivery into nested OOPIFs, and stealth personas are implemented and tested; broad live-vendor solve rates are not yet benchmarked.
Documentation
//! Transient-error retry primitive for the third-party solver's HTTP calls.
//!
//! Network blips, intermittent 5xx, connection-reset etc. shouldn't
//! fail a solve outright, the service just had a moment. These
//! helpers wrap the HTTP calls in `submit_task` and `poll_result` with
//! a small exponential-backoff retry budget. Terminal errors (bad
//! API key, malformed request, banned IP) bubble up immediately so we
//! don't burn time on doomed requests.

use super::*;

/// Number of attempts (including the first) for transient-error retry
/// on `submit_task` and the per-poll HTTP call inside `poll_result`.
/// Terminal API errors (bad key, zero balance, banned IP) are NOT
/// retried (they're returned immediately. See `is_terminal_error`).
pub(crate) const TRANSIENT_RETRY_ATTEMPTS: u32 = 3;
/// Initial backoff before the first retry. Doubles each attempt.
/// 250ms → 500ms → 1s → 2s caps out around the 4-second mark for the
/// configured 3 retries (4 total attempts).
pub(crate) const TRANSIENT_RETRY_BASE_MS: u64 = 250;
pub(crate) const TRANSIENT_RETRY_MAX_MS: u64 = 4_000;

#[derive(Debug)]
pub(crate) enum RetryError {
    /// Worth retrying (network blip, 5xx, transient timeout).
    Transient(anyhow::Error),
    /// Don't retry (bad request, terminal API code, malformed JSON).
    Terminal(anyhow::Error),
}

/// Tag a `reqwest::Error` as transient. All reqwest errors are by
/// default "network had a moment", the request didn't reach the
/// server cleanly. Treat as transient.
pub(crate) fn transient(e: reqwest::Error) -> RetryError {
    RetryError::Transient(anyhow!("reqwest: {e}"))
}

/// Run `op` up to [`TRANSIENT_RETRY_ATTEMPTS`] times, doubling the
/// backoff each retry from [`TRANSIENT_RETRY_BASE_MS`] up to
/// [`TRANSIENT_RETRY_MAX_MS`]. Terminal errors short-circuit on first
/// hit. The `label` is used in the final timeout error message so
/// production logs distinguish submit-vs-poll exhaustion.
pub(crate) 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")))
}

/// 2captcha-protocol error codes that will not flip to ready by
/// continuing to poll. Polling past these wastes wall-clock time and
/// (for paid services) submits-without-results.
///
/// List sourced from the 2captcha public API docs; CapMonster +
/// CapSolver mirror most of these for their compat endpoint.
pub(crate) 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"
    )
}