Node.js 调用 CaptchaAI 时验证码识别请求失败了,该重试还是直接终止?答案看错误类型:ERROR_NO_SLOT_AVAILABLE、超时可以重试;ERROR_ZERO_BALANCE、ERROR_WRONG_USER_KEY 重试也不会成功,只会浪费额度。本文用可复用的 Node.js 代码,讲清错误分类、指数退避、断路器、token 缓存和监控指标怎么串起来。
先分类错误,再决定要不要重试
CaptchaAI 的错误码分两类:可重试(排队、还没解出来)和致命(余额耗尽、key 错误、无法识别)。下面封装成 RetriableError 和 FatalError,用 instanceof 判断,不用到处写字符串比较。
const RETRIABLE_ERRORS = new Set([
"ERROR_NO_SLOT_AVAILABLE",
"CAPCHA_NOT_READY",
]);
const FATAL_ERRORS = new Set([
"ERROR_WRONG_USER_KEY",
"ERROR_KEY_DOES_NOT_EXIST",
"ERROR_ZERO_BALANCE",
"ERROR_CAPTCHA_UNSOLVABLE",
"ERROR_BAD_DUPLICATES",
"ERROR_BAD_PARAMETERS",
"ERROR_WRONG_CAPTCHA_ID",
]);
class CaptchaError extends Error {
constructor(code, message) {
super(message || code);
this.name = "CaptchaError";
this.code = code;
}
}
class RetriableError extends CaptchaError {
constructor(code) {
super(code, `Retriable: ${code}`);
this.name = "RetriableError";
}
}
class FatalError extends CaptchaError {
constructor(code) {
super(code, `Fatal: ${code}`);
this.name = "FatalError";
}
}
function classifyError(code) {
if (FATAL_ERRORS.has(code)) throw new FatalError(code);
throw new RetriableError(code);
}
以后新增错误码,维护这两个 Set 即可。
分类做好之后,重试间隔同样关键:太短等于空转甚至触发限流,太长又拖慢任务。withRetry 用指数退避加随机抖动,失败后等待时间翻倍并叠加随机系数,避免并发任务同时重试。
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
async function withRetry(fn, options = {}) {
const {
maxRetries = 3,
baseDelay = 2000,
maxDelay = 30000,
jitter = true,
} = options;
let lastError;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
if (error instanceof FatalError) throw error;
lastError = error;
if (attempt < maxRetries) {
let delay = Math.min(baseDelay * Math.pow(2, attempt), maxDelay);
if (jitter) delay *= 0.5 + Math.random();
console.log(
`Retry ${attempt + 1}/${maxRetries} in ${(delay / 1000).toFixed(1)}s: ${error.message}`
);
await sleep(delay);
}
}
}
throw lastError;
}
捕获到 FatalError 会立刻向上抛出,不浪费重试机会——这正是前面做错误分类的意义所在。
封装可复用的求解器,用断路器兜底
把提交和轮询封装进 RobustSolver,业务代码只调用 solve(method, params)。国内机房访问海外 reCAPTCHA 站点时,出口网络抖动比同区域访问更常见,超时和连接失败更频繁——这正是前面重试逻辑要解决的问题。
const API_KEY = "YOUR_API_KEY";
class RobustSolver {
#apiKey;
#maxRetries;
#pollInterval;
#maxPollTime;
constructor(apiKey, options = {}) {
this.#apiKey = apiKey;
this.#maxRetries = options.maxRetries ?? 3;
this.#pollInterval = options.pollInterval ?? 5000;
this.#maxPollTime = options.maxPollTime ?? 150000;
}
async solve(method, params) {
return withRetry(
() => this.#doSolve(method, params),
{ maxRetries: this.#maxRetries }
);
}
async #doSolve(method, params) {
const taskId = await this.#submit(method, params);
return await this.#poll(taskId);
}
async #submit(method, params) {
for (let attempt = 0; attempt <= this.#maxRetries; attempt++) {
try {
const resp = await fetch("https://ocr.captchaai.com/in.php", {
method: "POST",
body: new URLSearchParams({
key: this.#apiKey,
method,
json: "1",
...params,
}),
signal: AbortSignal.timeout(30000),
});
if (!resp.ok) {
throw new RetriableError(`HTTP_${resp.status}`);
}
const data = await resp.json();
if (data.status === 1) return data.request;
if (data.request === "ERROR_NO_SLOT_AVAILABLE") {
if (attempt < this.#maxRetries) {
await sleep(3000 * (attempt + 1));
continue;
}
}
classifyError(data.request);
} catch (error) {
if (error instanceof FatalError) throw error;
if (error.name === "TimeoutError" || error.name === "AbortError") {
if (attempt < this.#maxRetries) {
await sleep(2000 * (attempt + 1));
continue;
}
}
throw error;
}
}
throw new RetriableError("MAX_SUBMIT_RETRIES");
}
async #poll(taskId) {
const start = Date.now();
while (Date.now() - start < this.#maxPollTime) {
await sleep(this.#pollInterval);
try {
const resp = await fetch(
`https://ocr.captchaai.com/res.php?${new URLSearchParams({
key: this.#apiKey,
action: "get",
id: taskId,
json: "1",
})}`,
{ signal: AbortSignal.timeout(30000) }
);
const data = await resp.json();
if (data.status === 1) return data.request;
if (data.request === "CAPCHA_NOT_READY") continue;
if (FATAL_ERRORS.has(data.request)) throw new FatalError(data.request);
} catch (error) {
if (error instanceof FatalError) throw error;
// Network errors during poll — keep trying
continue;
}
}
throw new CaptchaError("TIMEOUT", `Timed out after ${this.#maxPollTime}ms`);
}
}
#submit 处理排队和超时,#poll 处理未解出和致命错误,两段分开写更容易定位问题。
如果 API 本身短时间大面积异常,无脑重试只会让积压请求越堆越多,这时候需要断路器:连续失败达到阈值就短路一段时间,等恢复再放行。
class CircuitBreaker {
#state = "closed"; // closed | open | half-open
#failures = 0;
#lastFailure = 0;
#threshold;
#resetTimeout;
constructor(threshold = 5, resetTimeout = 60000) {
this.#threshold = threshold;
this.#resetTimeout = resetTimeout;
}
get state() {
return this.#state;
}
canExecute() {
if (this.#state === "closed") return true;
if (this.#state === "open") {
if (Date.now() - this.#lastFailure > this.#resetTimeout) {
this.#state = "half-open";
return true;
}
return false;
}
return true; // half-open: allow test request
}
recordSuccess() {
this.#failures = 0;
this.#state = "closed";
}
recordFailure() {
this.#failures++;
this.#lastFailure = Date.now();
if (this.#failures >= this.#threshold) {
this.#state = "open";
console.log(`Circuit OPEN — pausing for ${this.#resetTimeout / 1000}s`);
}
}
}
class ProtectedSolver {
#solver;
#breaker;
constructor(apiKey) {
this.#solver = new RobustSolver(apiKey);
this.#breaker = new CircuitBreaker(5, 60000);
}
async solve(method, params) {
if (!this.#breaker.canExecute()) {
throw new CaptchaError(
"CIRCUIT_OPEN",
"API appears down — circuit breaker is open"
);
}
try {
const result = await this.#solver.solve(method, params);
this.#breaker.recordSuccess();
return result;
} catch (error) {
if (error instanceof FatalError) throw error;
this.#breaker.recordFailure();
throw error;
}
}
get circuitState() {
return this.#breaker.state;
}
}
三种状态:closed(放行)、open(暂停)、half-open(探测恢复)。ProtectedSolver 把它和 RobustSolver 组合在一起,业务代码无感知。
token 缓存、监控指标与完整生产示例
token 有有效期(reCAPTCHA 约 2 分钟),隔太久提交会被拒绝。TokenCache 按 key 缓存并判断过期;solveWithRetryOnReject 在提交被拒时自动重新识别一次。
class TokenCache {
#cache = new Map();
#defaultTTL;
constructor(defaultTTL = 110000) {
// reCAPTCHA: ~2 min, Turnstile: ~5 min
this.#defaultTTL = defaultTTL;
}
get(key) {
const entry = this.#cache.get(key);
if (!entry) return null;
if (Date.now() - entry.timestamp > this.#defaultTTL) {
this.#cache.delete(key);
return null;
}
return entry.token;
}
set(key, token) {
this.#cache.set(key, { token, timestamp: Date.now() });
}
invalidate(key) {
this.#cache.delete(key);
}
}
class CachedSolver {
#solver;
#cache;
constructor(apiKey) {
this.#solver = new ProtectedSolver(apiKey);
this.#cache = new TokenCache(110000);
}
async getToken(cacheKey, method, params) {
const cached = this.#cache.get(cacheKey);
if (cached) return cached;
const token = await this.#solver.solve(method, params);
this.#cache.set(cacheKey, token);
return token;
}
async solveWithRetryOnReject(method, params, submitFn, maxAttempts = 2) {
for (let i = 0; i < maxAttempts; i++) {
const token = await this.#solver.solve(method, params);
const accepted = await submitFn(token);
if (accepted) return token;
console.log(`Token rejected (attempt ${i + 1}), re-solving...`);
}
throw new CaptchaError("TOKEN_REJECTED", "Token rejected after max attempts");
}
}
TTL 默认设 110 秒,比 reCAPTCHA 的 2 分钟窗口留点余量。
光靠日志排查效率低。SolverMetrics 记录提交/成功/失败/重试次数并算出成功率和吞吐量;InstrumentedSolver 把统计包在最外层,不侵入业务代码。
class SolverMetrics {
#startTime = Date.now();
#solveTimes = [];
#counts = { submitted: 0, solved: 0, failed: 0, retries: 0 };
recordSubmit() { this.#counts.submitted++; }
recordSolved(duration) { this.#counts.solved++; this.#solveTimes.push(duration); }
recordFailed() { this.#counts.failed++; }
recordRetry() { this.#counts.retries++; }
report() {
const elapsed = (Date.now() - this.#startTime) / 1000;
const total = this.#counts.solved + this.#counts.failed;
const avgTime = this.#solveTimes.length > 0
? this.#solveTimes.reduce((a, b) => a + b, 0) / this.#solveTimes.length / 1000
: 0;
return {
elapsed: `${elapsed.toFixed(0)}s`,
submitted: this.#counts.submitted,
solved: this.#counts.solved,
failed: this.#counts.failed,
retries: this.#counts.retries,
avgSolveTime: `${avgTime.toFixed(1)}s`,
successRate: total > 0 ? `${((this.#counts.solved / total) * 100).toFixed(1)}%` : "N/A",
throughput: `${(this.#counts.solved / (elapsed / 60)).toFixed(1)}/min`,
};
}
}
class InstrumentedSolver {
#solver;
#metrics;
constructor(apiKey) {
this.#solver = new ProtectedSolver(apiKey);
this.#metrics = new SolverMetrics();
}
async solve(method, params) {
this.#metrics.recordSubmit();
const start = Date.now();
try {
const token = await this.#solver.solve(method, params);
this.#metrics.recordSolved(Date.now() - start);
return token;
} catch (error) {
this.#metrics.recordFailed();
throw error;
}
}
report() {
return this.#metrics.report();
}
}
可以另起一个定时任务,把 report() 结果推到监控或告警渠道。
叠好各层后,业务代码只面对一个 InstrumentedSolver。下面用 Promise.allSettled 并发跑 10 个任务,成功失败分别统计,失败任务打印具体原因。
// Combine everything
const solver = new InstrumentedSolver("YOUR_API_KEY");
async function main() {
const tasks = Array.from({ length: 10 }, (_, i) => ({
method: "userrecaptcha",
params: { googlekey: `KEY_${i}`, pageurl: `https://example.com/${i}` },
}));
const results = await Promise.allSettled(
tasks.map((task) => solver.solve(task.method, task.params))
);
const solved = results.filter((r) => r.status === "fulfilled");
const failed = results.filter((r) => r.status === "rejected");
console.log(`Solved: ${solved.length}, Failed: ${failed.length}`);
console.log("Metrics:", solver.report());
for (const fail of failed) {
console.log(` Error: ${fail.reason.message}`);
}
}
main();
fail.reason 是 FatalError 说明任务本身有问题(比如 sitekey 填错),该单独排查而不是重跑。
常见故障排查
| 症状 | 可能原因 | 处理方式 |
|---|---|---|
| 重试全部立刻失败 | 把致命错误当成可重试错误在重试 | 检查错误分类逻辑 |
| 断路器一直打开 | API 异常或 key 配置错误 | 先确认 API 状态和 key 是否正确 |
| 提交时 token 已过期 | 识别耗时加上业务延迟太长 | 提前识别,别等到要用时才发起请求 |
fetch 抛出 AbortError |
超时时间设置太短 | 调大 AbortSignal.timeout 的值 |
| 出现未处理的 Promise 拒绝 | 异步调用漏掉了 catch | 给每个 await 调用都加上错误处理 |
常见问题
轮询间隔设多长比较合适?
pollInterval 设 3–5 秒,配合 maxPollTime(示例 150 秒)做上限,超时按失败处理。
断路器打开后要等多久才会恢复?
由 resetTimeout 决定(示例 60 秒),期间请求直接失败,超时后进入 half-open 探测一次,成功转回 closed。
token 提交后被目标网站拒绝该怎么办?
先确认 token 没过期、sitekey/pageurl 一致,都对的话用 solveWithRetryOnReject 重新识别一次再提交。
网络错误和 CaptchaAI 返回的 API 错误要一样处理吗?
不一样:网络错误(TypeError/AbortError)通常可重试,API 错误要先分类,致命的别再重试。
小结
用 Node.js 做生产级验证码识别,核心是叠好错误分类、指数退避、断路器、token 缓存和监控指标这五层,封装进 CaptchaAI 的调用层,脚本会明显更抗造。