The retry loop is the least-reviewed code in most automation stacks. It gets written in five minutes during integration, it works, and nobody opens it again until the month the bill triples or a target starts returning challenges to traffic that used to sail through.
Key takeaway: a retry is not free, and it is not one cost. Every retry spends a solve, occupies a concurrency slot for the duration, and spends a little of the reputation attached to whatever IP and browser identity it went out on. The first cost is visible on an invoice. The second and third are the ones that actually break pipelines, and neither shows up in a success-rate dashboard — because by the time the number is computed, the retries have already been folded in.
What follows is the seven ways this goes wrong, each with the reason it is tempting to write and the cheapest check that catches it.
What a retry actually spends
Before the mistakes, the mechanism. A CAPTCHA retry differs from an ordinary HTTP retry in three ways worth holding in your head.
- It bills. Under per-solve pricing that is direct. Under thread-based pricing it is indirect and worse, because the retry does not consume a unit of quota — it consumes a slot, and slots are the scarce resource.
- It occupies. A solve stays in flight for a duration that depends on the challenge type, and the spread across types is large. A retried solve holds a thread for that whole time, whether or not the answer will be usable when it arrives.
- It signals. The retried request goes out from an egress identity that has just made a failed impression on the target. Repeating that impression immediately is a stronger signal than making it once.
Ordinary retry advice — exponential backoff, cap the attempts — addresses the first cost and mostly ignores the other two. That gap is where the mistakes live.
Mistake 1: retrying the solve when the failure was downstream
What it looks like: the target rejected the request, so the code re-invokes the solver and tries the submission again with a fresh token.
Why it is tempting: the CAPTCHA is the exotic part of the flow, so it feels like the likely culprit. Solver SDKs also tend to ship with a convenient retry helper right at that call site, which puts the retry in the wrong layer by default.
Why it fails: a token is not a generic credential. It is minted against a specific site key and page context, and it has a short life. Re-solving in isolation produces a fresh token bound to the same stale context you already had — sometimes a page whose session, cookies or form nonce have since moved on. You pay for a second solve to submit something the target will reject for exactly the same reason as the first.
The cheap check: log two fields on every token — t_issued and t_used — and the page URL and site key it was minted for versus the one it was submitted to. Plot the p95 of t_used - t_issued. If that gap is climbing toward the token's lifetime, or if the mint and submit contexts ever disagree, your retry is at the wrong layer. The correct retry restarts the flow from page load, and re-solves as a consequence rather than as the action.
Mistake 2: retrying errors that will never succeed
What it looks like: a blanket except: retry around the solver call, or a retry decorator with no error inspection.
Why it is tempting: it ships in five minutes, it is genuinely correct for transient network failures, and during integration every error looks transient because you are still fixing them one at a time.
Why it fails: solver APIs return several classes of error that no amount of retrying will change. An invalid key stays invalid. A zero balance stays zero. A challenge the service reports as unsolvable will usually be unsolvable on the next attempt too, because the reason is the challenge, not chance. Retrying these three to five times each multiplies your failure latency and, for the ones that bill, your spend.
The fix: classify before you loop. Three buckets, and every documented code belongs to exactly one:
- Fatal — authentication, balance, permission. Do not retry. Page someone.
- Target-shaped — unsolvable, unsupported type, repeatedly rejected token. Do not retry the solve; change something about the request or back off from the target.
- Transient — timeouts, 5xx, "no available slot", queue-full responses. Retry with backoff.
The cheap check: make the classifier a table, and add a test that asserts every error code in the vendor's documentation maps to a bucket. Then make the default for an unrecognised code raise rather than retry. An unclassified error retried blindly is how a single upstream change turns into a spend anomaly overnight.
Mistake 3: retries that inflate concurrency instead of throughput
What it looks like: the retry sits inside the same worker, synchronously, so the worker holds its slot across the original attempt and every retry.
Why it is tempting: synchronous retries are far easier to reason about, and under normal conditions the extra occupancy is invisible.
Why it fails: it is invisible precisely until it matters. Retries cluster in time — a target incident or a challenge-type change makes everything fail at once — so the moment you most need capacity is the moment your effective concurrency multiplies. If your plan is sized against a measured peak, and retries add attempts on top of that peak, you hit slot exhaustion, which produces more timeouts, which produces more retries. The loop is self-reinforcing.
The cheap check: instrument in-flight solves, not solves per minute. Throughput is a lagging, averaged view; occupancy is the thing you actually rent. Emit a gauge on every submit and decrement on every terminal outcome, then alert on in_flight / threads_available rather than on error rate. Size the plan against the peak your retry policy can produce, not the peak your happy path produces — the arithmetic for that is in sizing threads for real peak concurrency.
Mistake 4: the timeout that does not cancel
What it looks like: the client gives up on a poll after N seconds and moves on. The job on the solver side keeps running to completion.
Why it is tempting: a client-side deadline feels like a complete abstraction. From the caller's perspective the request is over.
Why it fails: abandoning a poll does not abandon the work. The job may still complete, still bill, and — under thread pricing — still hold a slot until it does. Do this at scale and you accumulate a population of zombie solves you are paying for and cannot use, which suppresses your real capacity for reasons that appear nowhere in your logs.
The cheap check: count solves_initiated and solves_consumed as separate counters and diff them over a window. A persistent, growing gap is abandoned work. Fix it by setting deadlines that reflect the challenge type's realistic in-flight time rather than one global number, by propagating a single deadline through the whole flow instead of nesting independent ones, and by using the vendor's cancel or report facility where one exists.
Mistake 5: retrying on the same identity
What it looks like: the retry goes out immediately, on the same IP, the same TLS and browser fingerprint, the same cookie jar.
Why it is tempting: the identity is what your session object holds, and rotating it mid-flow means threading more state through the retry path. Also, a good proxy is not free, so reusing one feels efficient.
Why it fails: anti-bot systems weight repetition. A single failed challenge is noise; the same identity failing twice inside a few seconds is a pattern, and patterns are what scoring systems are built to find. The retry is not a fresh attempt — it is the second, worse impression from a party that has already made a bad one. The mechanism behind that weighting is covered in how anti-bot detection decides you are a bot.
The cheap check: put acceptance rate on a per-egress-pool panel and split it by attempt number. If first-attempt acceptance is healthy and retry-on-same-identity acceptance is materially worse, retrying in place is the problem and you want the retry to come from a different pool. Note the distinction that gets muddled here: rotation is not retry. Rotating identity on every request is a separate decision with its own costs; what this check tells you is only whether a failed identity should be reused immediately.
Mistake 6: synchronised retries with no jitter
What it looks like: fixed backoff, or exponential backoff with a deterministic schedule, applied uniformly across every worker.
Why it is tempting: deterministic backoff is easy to reason about and easy to test. Jitter feels like it makes behaviour non-reproducible.
Why it fails: when a shared cause fails many requests at once, deterministic backoff reschedules all of them for the same instant. You rebuild the original spike, one backoff interval later, against a target that is already unhappy — and again after that. This is the classic thundering herd, and CAPTCHA flows are unusually prone to it because the failures really are correlated: they share a target, and often a challenge type.
The cheap check: histogram your retry timestamps modulo the backoff interval. A healthy distribution is flat. Spikes mean your retries are synchronised. The fix is full jitter — sleep a random duration between zero and the current backoff ceiling rather than the ceiling itself — plus a per-target circuit breaker so that a target which is failing everything stops receiving the full fleet's attention.
Mistake 7: counting retries as success
What it looks like: the dashboard reports overall solve rate, computed after retries.
Why it is tempting: it is the number that describes user-visible outcomes, and it is the one that stays reassuringly high.
Why it fails: it hides the derivative. A target that has quietly tightened its threshold shows up first as more attempts per success, not as fewer successes — the retries absorb the regression and the headline metric barely moves until the retries stop being enough. By then the cost has been climbing for days.
The cheap check: record first-attempt acceptance as its own metric and alert on it. Then add attempts-per-success as a second series. Those two move before the headline rate does, and they are what turns an overnight drop into a ten-minute diagnosis rather than a two-day one. The full diagnostic path for a drop, once you can see it, is in why your solve rate dropped overnight.
What a sane retry policy looks like
Pulling the seven together into something you can implement:
- Retry the flow, not the solve. The solver call is a step inside the unit of work, not the unit itself.
- Classify every error code into fatal, target-shaped, or transient. Unknown codes raise.
- Budget retries as a share of traffic, not per request. A global retry budget — say, a small percentage of total attempts in a rolling window — degrades gracefully during an incident. Per-request attempt counts do the opposite: they scale the storm with the outage.
- One deadline, propagated. Not a deadline per layer, which multiplies.
- Full jitter on backoff, and a circuit breaker per target.
- Rotate identity on retry after a challenge failure, and do not reuse an identity that has just been rejected.
- Meter in-flight, first-attempt acceptance, and attempts-per-success. These three catch every mistake above.
Retries are load, and load is somebody else's problem
Worth saying plainly, because it is easy to treat this as purely an engineering-cost question. A retry storm is traffic. It arrives at a target that did not ask for it, at the moment that target is least able to serve it, and it is the exact behaviour that rate limits exist to stop.
If a site publishes a crawl delay or a rate limit, that number applies to your retries too — an attempt is an attempt. Honour robots.txt, keep concurrency inside published limits, and treat a target's error responses as instructions rather than obstacles. A backoff policy that respects the signal is both the polite implementation and the one that keeps working, since a client that backs off correctly does not accumulate the reputation damage that makes the next hour harder. The broader version of that argument is in what robots.txt, rate limits and terms actually require.
FAQ
How many times should I retry a CAPTCHA solve? Fewer than you think, and only for transient errors. One or two attempts with jittered backoff covers genuine network flakiness; beyond that, a repeated failure is almost always telling you something about the target or the request rather than about luck. Spend the budget on diagnosis instead of attempts.
Should I retry an unsolvable-type error? No. That class of response means the service could not produce an answer for the challenge as presented, and presenting it again unchanged usually gets the same outcome. Treat it as target-shaped: change the request, the identity, or the timing — or back off from that endpoint.
Do retries count against a thread-based plan? They occupy slots, which is what a thread-based plan actually sells. Solves themselves are not metered, but a retried solve holds a concurrency slot for its full in-flight time, so a retry-heavy policy raises the peak concurrency your plan has to cover.
Why did my costs rise while my success rate stayed flat? That is the signature of retries absorbing a regression. Something on the target changed, first attempts started failing more often, and the retry layer kept the headline number steady by spending more attempts per success. Add first-attempt acceptance and attempts-per-success as metrics and the gap becomes obvious.
Is exponential backoff enough on its own? Not for correlated failures. Without jitter, exponential backoff reschedules every failed request for the same moment, which rebuilds the spike that caused the failure. Add full jitter, and add a per-target circuit breaker so a failing target stops receiving the whole fleet.
Where to go from here
Open the retry path in your codebase and answer three questions: which layer does it retry, which error codes does it exclude, and what does it do to in-flight count when everything fails at once. Most stacks get one of the three right.
Once retries are honest, the remaining volume is real work — legitimate requests to a target that challenges server-side traffic no matter how clean it is, which is the part a solver answers. The documented parameters, error codes and plan structures are at getcaptchaai.com; size threads against the peak your retry policy creates, not the one your happy path shows.