调用 reCAPTCHA、Turnstile 这类验证码 API 时,接口偶尔变慢或连续报错,代码还在不停重试,额度白白消耗,用户端等待也跟着变长——这是很多团队都遇到过的问题。
断路器模式(Circuit Breaker,也叫熔断机制):连续失败超过阈值后先暂停调用,给下游一点恢复时间,再用一次试探请求判断能不能恢复,避免级联故障拖垮整条链路。
reCAPTCHA 依赖 Google 托管资源,出海团队从国内网络访问境外接口本身就不太稳定,断路器几乎是这类链路的标配组件。
断路器的三种状态:关闭、打开、半开
断路器本质上是一个简单的状态机,一共三种状态:
| 状态 | 触发条件 | 行为 |
|---|---|---|
| 关闭(Closed) | 默认状态 | 请求照常发出,失败计数 |
| 打开(Open) | 失败达阈值 | 请求直接拒绝,不再打到 API |
| 半开(Half-Open) | 冷却已过 | 放行一次试探;成功回关闭,失败回打开 |
Python 实现:给验证码请求加上熔断保护
下面的 CircuitBreaker 类用线程锁保护状态切换,可直接套在现有请求函数外层:
import time
import threading
import requests
SUBMIT_URL = "https://ocr.captchaai.com/in.php"
RESULT_URL = "https://ocr.captchaai.com/res.php"
API_KEY = "YOUR_API_KEY"
class CircuitBreaker:
def __init__(self, failure_threshold=5, recovery_timeout=60):
self.failure_threshold = failure_threshold
self.recovery_timeout = recovery_timeout
self.failure_count = 0
self.last_failure_time = 0
self.state = "closed" # closed, open, half-open
self._lock = threading.Lock()
def call(self, func, *args, **kwargs):
with self._lock:
if self.state == "open":
if time.time() - self.last_failure_time > self.recovery_timeout:
self.state = "half-open"
print("[circuit] State: half-open — testing one request")
else:
remaining = self.recovery_timeout - (
time.time() - self.last_failure_time
)
raise CircuitOpenError(
f"Circuit open — retry in {remaining:.0f}s"
)
try:
result = func(*args, **kwargs)
with self._lock:
self.failure_count = 0
if self.state == "half-open":
print("[circuit] State: closed — API recovered")
self.state = "closed"
return result
except Exception as e:
with self._lock:
self.failure_count += 1
self.last_failure_time = time.time()
if self.failure_count >= self.failure_threshold:
self.state = "open"
print(
f"[circuit] State: open — "
f"{self.failure_count} failures"
)
raise
class CircuitOpenError(Exception):
pass
def solve_captcha(sitekey, page_url):
resp = requests.post(SUBMIT_URL, data={
"key": API_KEY,
"method": "userrecaptcha",
"googlekey": sitekey,
"pageurl": page_url,
"json": "1",
}, timeout=15)
data = resp.json()
if data["status"] != 1:
raise Exception(f"Submit error: {data['request']}")
task_id = data["request"]
for _ in range(24):
time.sleep(5)
poll = requests.get(RESULT_URL, params={
"key": API_KEY,
"action": "get",
"id": task_id,
"json": "1",
}, timeout=15).json()
if poll["status"] == 1:
return poll["request"]
if poll["request"] != "CAPCHA_NOT_READY":
raise Exception(f"Poll error: {poll['request']}")
raise TimeoutError(f"Task {task_id} timed out")
# Usage
breaker = CircuitBreaker(failure_threshold=3, recovery_timeout=30)
for i in range(10):
try:
token = breaker.call(
solve_captcha, "6Le-SITEKEY", "https://example.com"
)
print(f"[task-{i}] Solved: {token[:40]}...")
except CircuitOpenError as e:
print(f"[task-{i}] Skipped: {e}")
except Exception as e:
print(f"[task-{i}] Failed: {e}")
预期输出:
[task-0] Solved: 03AGdBq26ZfPxL...
[task-1] Solved: 03AGdBq27AbCdE...
[task-2] Failed: Submit error: ERROR_NO_SLOT_AVAILABLE
[task-3] Failed: Submit error: ERROR_NO_SLOT_AVAILABLE
[task-4] Failed: Submit error: ERROR_NO_SLOT_AVAILABLE
[circuit] State: open — 3 failures
[task-5] Skipped: Circuit open — retry in 28s
[task-6] Skipped: Circuit open — retry in 25s
...
[circuit] State: half-open — testing one request
[task-8] Solved: 03AGdBq28FgHiJ...
[circuit] State: closed — API recovered
JavaScript 实现
Node.js 服务端思路一样,只是把线程锁换成了单线程事件循环的顺序执行:
class CircuitBreaker {
constructor(options = {}) {
this.failureThreshold = options.failureThreshold || 5;
this.recoveryTimeout = options.recoveryTimeout || 60000;
this.failureCount = 0;
this.lastFailureTime = 0;
this.state = 'closed';
}
async call(fn, ...args) {
if (this.state === 'open') {
if (Date.now() - this.lastFailureTime > this.recoveryTimeout) {
this.state = 'half-open';
console.log('[circuit] State: half-open');
} else {
const remaining = this.recoveryTimeout - (Date.now() - this.lastFailureTime);
throw new Error(`Circuit open — retry in ${Math.ceil(remaining / 1000)}s`);
}
}
try {
const result = await fn(...args);
this.failureCount = 0;
if (this.state === 'half-open') {
console.log('[circuit] State: closed — recovered');
}
this.state = 'closed';
return result;
} catch (error) {
this.failureCount++;
this.lastFailureTime = Date.now();
if (this.failureCount >= this.failureThreshold) {
this.state = 'open';
console.log(`[circuit] State: open — ${this.failureCount} failures`);
}
throw error;
}
}
}
// Usage
const axios = require('axios');
const API_KEY = 'YOUR_API_KEY';
const breaker = new CircuitBreaker({ failureThreshold: 3, recoveryTimeout: 30000 });
async function solveCaptcha(sitekey, pageurl) {
const submit = await axios.post('https://ocr.captchaai.com/in.php', null, {
params: { key: API_KEY, method: 'userrecaptcha', googlekey: sitekey, pageurl, json: 1 }
});
if (submit.data.status !== 1) throw new Error(submit.data.request);
const taskId = submit.data.request;
for (let i = 0; i < 24; i++) {
await new Promise(r => setTimeout(r, 5000));
const poll = await axios.get('https://ocr.captchaai.com/res.php', {
params: { key: API_KEY, action: 'get', id: taskId, json: 1 }
});
if (poll.data.status === 1) return poll.data.request;
if (poll.data.request !== 'CAPCHA_NOT_READY') throw new Error(poll.data.request);
}
throw new Error('Timeout');
}
(async () => {
for (let i = 0; i < 10; i++) {
try {
const token = await breaker.call(solveCaptcha, '6Le-SITEKEY', 'https://example.com');
console.log(`[task-${i}] Solved: ${token.substring(0, 40)}...`);
} catch (err) {
console.log(`[task-${i}] ${err.message}`);
}
}
})();
一个真实场景:跨境电商结账链路
某跨境电商结账页脚本每天触发数千次 reCAPTCHA 验证,走境外线路访问 Google 托管资源,高峰期连续超时会让重试和等待一起失控:
- 连续 3 次失败即打开电路,暂停 30 秒;
- 冷却期间把任务放入队列,不再硬打 API;
- 半开状态试探一次判断是否恢复。
只需在 solve_captcha 外层套一层 CircuitBreaker,业务逻辑不用动。
断路器阈值怎么选:低流量 vs 高流量
| 参数 | 低流量(< 10 次/分钟) | 高流量(> 100 次/分钟) |
|---|---|---|
failure_threshold |
3 | 10 |
recovery_timeout |
30 秒 | 60 秒 |
阈值太低会误判正常抖动为故障,太高又会让电路在 API 真正故障时继续硬撞。经验做法:低流量用更低阈值(3 次)快速止损,高流量放宽到 10 次左右。
和重试逻辑搭配使用
重试逻辑应该放在断路器内部,让断路器只统计重试全部失败后的最终失败次数:
def solve_with_retry(sitekey, page_url, max_retries=2):
for attempt in range(max_retries + 1):
try:
return solve_captcha(sitekey, page_url)
except Exception:
if attempt == max_retries:
raise
time.sleep(2 ** attempt)
# Circuit breaker wraps the retry function
token = breaker.call(solve_with_retry, "6Le-SITEKEY", "https://example.com")
短暂的单次超时会被重试悄悄吸收,只有连续多次真正失败才会触发熔断。
常见故障排查
| 问题 | 原因 | 处理方式 |
|---|---|---|
| 电路跳闸太快 | 阈值设置过低 | 调高 failure_threshold |
| 电路一直恢复不了 | recovery_timeout 设置过长 |
缩短到 30–60 秒 |
| 多线程环境下出现竞态 | 状态没有加锁 | Python 用 threading.Lock,其他语言用原子操作 |
| 部分故障期间所有请求都被拦截 | 提交和轮询共用同一个断路器 | 给提交接口和轮询接口分别配置独立的断路器 |
常见问题
提交和轮询要不要用两个独立的断路器?
大规模系统建议分开。提交接口出问题时轮询接口可能仍正常,独立断路器能做更细粒度的控制。
电路处于“打开”状态时,验证码任务该怎么处理?
常见做法是排队稍后重试、展示降级 UI,或直接跳过。参考解决失败时的优雅降级。
断路器和普通的重试机制有什么区别?
重试只是失败了再试一次,不记忆历史失败次数;断路器持续统计失败次数,超过阈值就直接停止调用,两者是配合关系。
半开状态下的试探请求又失败了会怎样?
电路立刻回到打开状态,重新计时冷却周期,避免反复试探打断恢复过程。
多进程或多实例部署时,断路器状态需要共享吗?
各实例独立处理队列时,本地状态通常够用;多个实例共同面对同一下游、流量又大时,放进 Redis 等共享存储会更准确。
用 CaptchaAI 构建更稳定的验证码调用链路
把断路器包在现有请求函数外层,几行代码就能让整条流程在 API 抖动时更稳。前往 CaptchaAI 官网 获取你的 API Key。