讲解为何重试在免费模型长时宕机时会放大故障,演示如何构建断路器模式——追踪连续失败次数,在服务降级时快速失败而非消耗配额。
重试可以修复偶发的故障,但会让 outage 恶化。断路器可以终止这个恶性循环。本教程从零开始构建一个断路器,并用 mock 端点验证每个阶段。
免费模型端点的失败是有规律的。共享基础设施意味着共同的痛苦。速率限制和提供商 outage 的到来毫无预兆。重试阶梯可以处理第一次的偶发故障。它无法处理持续十分钟的 outage。每次重试都在消耗配额和时间。断路器则快速失败,而不是反复尝试。
这是重试阶梯之上的那一层。它追踪连续失败。当端点降级时它会打开(open)。它在下一次允许流量之前先探测(probe)。你可以在免费服务器上运行整套工具。MonkeyCode 提供免费模型访问和免费服务器。如果需要的话,可以把它们作为具体的目标来使用。下面的代码只需要一个 URL。披露:本文是 MonkeyCode 产品推广的一部分。
重试阶梯假设故障是瞬时的。它会退避、添加抖动、然后重试。这对 503 的偶发故障有效。对持续性的 outage 则无效。每次尝试都消耗配额。每次尝试都增加延迟。用户等待更长时间却得到同样的错误。断路器反转了这个逻辑。当失败看起来是系统性的时就停止尝试。只有在冷却期过后证明端点已恢复时才会重新打开。
一个小型的 Node.js 服务,包含三个部分:
零依赖。Node 20 或更高版本。总共不到五分钟的安装配置。
三种状态:Closed(关闭)、Open(打开)、Half-Open(半开)。
Closed 状态下计数失败次数。达到阈值时,断路器变为 Open。Open 状态下拒绝每一次调用且不触碰网络。冷却期过后,一次探测通过。成功则关闭电路。失败则重新打开。
三个数字至关重要:
failureThreshold:打开之前允许的连续失败次数cooldownMs:电路保持 Open 状态的时长timeoutMs:单次调用允许运行的最长时间,超过则计为失败没有通用的值。从 5 次失败、30 秒、5 秒开始。根据你自己的流量调整。免费端点需要保守的阈值。负载时它们的延迟会飙升。
Node 20 或更高版本。无需任何依赖。
mkdir breaker-demo
cd breaker-demo
npm init -y
npm pkg set type=module
node -e "console.log(process.version)"
最后一条命令验证你的运行时。你应该看到 v20 或更高版本。type=module 这行启用了 import 语法。
class CircuitBreaker {
constructor({ failureThreshold = 5, cooldownMs = 30_000, timeoutMs = 5_000 }) {
this.failureThreshold = failureThreshold;
this.cooldownMs = cooldownMs;
this.timeoutMs = timeoutMs;
this.state = 'CLOSED';
this.failures = 0;
this.openedAt = 0;
}
async call(fn) {
if (this.state === 'OPEN') {
if (Date.now() - this.openedAt >= this.cooldownMs) {
this.state = 'HALF_OPEN';
} else {
throw new Error('circuit open');
}
}
try {
const result = await Promise.race([
fn(),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('call timeout')), this.timeoutMs)
)
]);
this.onSuccess();
return result;
} catch (err) {
this.onFailure();
throw err;
}
}
onSuccess() {
this.failures = 0;
if (this.state === 'HALF_OPEN') this.state = 'CLOSED';
}
onFailure() {
this.failures += 1;
if (this.state === 'HALF_OPEN' || this.failures >= this.failureThreshold) {
this.state = 'OPEN';
this.openedAt = Date.now();
}
}
}
export { CircuitBreaker };
关键细节:超时使用 Promise.race。慢调用计为失败。Half-open 状态允许恰好一次探测。一次失败重新打开电路。
import { CircuitBreaker } from './breaker.js';
export const breaker = new CircuitBreaker({
failureThreshold: 5,
cooldownMs: 30_000,
timeoutMs: 5_000
});
export async function callModel(prompt) {
return breaker.call(async () => {
const res = await fetch(process.env.MODEL_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt })
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
});
}
将 MODEL_URL 设置为任意 JSON 模型端点。断路器包装了网络调用。非 2xx 响应计为失败。超时也一样。
创建 test.js。这是可复现的验证产物。
import { CircuitBreaker } from './breaker.js';
let failuresLeft = 0;
async function fakeModel() {
if (failuresLeft > 0) {
failuresLeft -= 1;
throw new Error('upstream 503');
}
return { ok: true };
}
const breaker = new CircuitBreaker({ failureThreshold: 3, cooldownMs: 200 });
// Stage 1: starts closed
console.assert(breaker.state === 'CLOSED', 'starts closed');
// Stage 2: three failures open the circuit
failuresLeft = 3;
for (let i = 0; i < 3; i += 1) {
try { await breaker.call(fakeModel); } catch (err) {}
}
console.assert(breaker.state === 'OPEN', 'opens after threshold');
// Stage 3: open circuit rejects without calling
failuresLeft = 0;
try { await breaker.call(fakeModel); } catch (err) {}
console.assert(breaker.state === 'OPEN', 'stays open during cooldown');
// Stage 4: cooldown passes, probe succeeds, circuit closes
await new Promise((resolve) => setTimeout(resolve, 250));
const result = await breaker.call(fakeModel);
console.assert(result.ok, 'probe succeeds');
console.assert(breaker.state === 'CLOSED', 'closes after probe');
console.log('all checks passed');
node test.js
期望输出:all checks passed。Mock 先失败三次,然后恢复。这锻炼了每一个分支。console.assert 在失败时输出到 stderr。它不会抛出异常。对于 CI,将 assert 替换为显式的 throw。
断路器应该放在你的代码运行的地方。免费服务器适用于低流量的工具。创建 health.js。
import { callModel, breaker } from './model.js';
setInterval(async () => {
try {
await callModel('ping');
console.log(new Date().toISOString(), breaker.state, 'ok');
} catch (err) {
console.log(new Date().toISOString(), breaker.state, err.message);
}
}, 60_000);
用你的端点启动它。
MODEL_URL=https://your-endpoint.example/v1/complete node health.js
用进程管理器保持运行。或者用 nohup 快速检查。
nohup node health.js > health.log 2>&1 &
tail -f health.log
观察一个完整的周期。健康调用显示 CLOSED ok。Outage 期间显示 OPEN circuit open。冷却期过后,你会看到一次探测。恢复后回到 CLOSED ok。这就是你的验证循环。免费服务器非常适合这种工作负载。一分钟一次请求。状态保存在内存中。不需要数据库。
一次只改一个值。每次修改后重新运行 mock 测试。
它不会减少 429。速率限制合规仍然需要你的重试阶梯。
它不会节省配额。Half-open 探测每次冷却期消耗一次真实请求。
阈值需要调优。错误的值会导致误开或检测缓慢。
它掩盖了提供商的健康状况。将状态转换单独记录。
每个端点一个断路器。不要在不同的模型之间共享一个。
在三种情况下跳过这个模式:
最佳场景是小型、长期运行的工具。定时检查、机器人、个人助手。这些工具运行时间足够长以至于会遇到 outage。这些工具从快速失败中受益。
这个工具链是端点无关的。将它指向任意 JSON 模型端点。从 mock 测试开始。让真实流量教你阈值。记录每一次状态转换。下个月你会需要这些数据。