Activepieces 信用额度门控设计:未知余额一律放行(Fail Open)的实现与权衡
【免费下载链接】activepiecesAI Agents & MCPs & AI Workflow Automation • (~400 MCP servers for AI agents) • AI Automation / AI Agent with MCPs • AI Workflows & AI Agents • MCPs for AI Agents项目地址: https://gitcode.com/GitHub_Trending/ac/activepieces
导读
本文基于 Activepieces 的架构决策记录brain/knowledge/decisions/000020-credit-gating-fails-open-on-an-unknown-balance.md,深入讲解计费门控(credit gate)在运行热路径(worker RPC 准入、webhook、手动触发、聊天与托管 AI 调用)上如何做到“第三方计费服务不可达也不阻塞自动化”。你将理解shouldBlockOnCredits/assertCreditsAndAppSumoNotExceeded的调用链、computeCreditState的三层放行语义、缓存只读 + 后台刷新的刷新策略,以及自托管 EE 版本临时跳过运行门控的来龙去脉。
1. 决策背景:门控必须守卫热路径,却不能依赖第三方在线
Activepieces 的信用额度门控位于多个高频热路径上,这些路径不能因为某个第三方服务不可达而整体瘫痪:
- 工作节点 RPC
submitPayloads在准入每一次生产运行前,都要调用shouldBlockOnCredits; - 聊天(chat)与托管 AI(managed-AI)调用会调用
assertCreditsAndAppSumoNotExceeded。
门控背后的余额数据来自计费服务Autumn(通过 customer 维度的密钥调用getCustomer),并缓存在 Redis 中:
- 余额缓存 TTL 为 1 小时,由
CREDITS_CACHE_TTL_SECONDS控制(见 autumn-utils.ts); billingEnforced(是否强制执行计费)单独缓存,TTL 为 1 天,即BILLING_ENFORCED_TTL_SECONDS。
因此门控在以下场景中经常拿不到任何余额:
- 平台第一次被访问(缓存冷);
- 缓存 TTL 过期之后;
- Redis 被 flush 之后;
- Autumn 或计费控制台(console)不可达时。
“没有数据”与“余额为 0”是两种完全不同的状态。把二者混为一谈,就会把一次计费提供商的故障,放大成所有客户自动化流程的故障。
这一判断正是本决策的起点:未知余额绝不能阻塞。
2. 核心决策:未知余额永不阻塞(三层 Fail Open)
决策原文明确:“An unknown balance never blocks.”(未知余额永不阻塞)。实现上由三个相互独立的层各自默认“允许”,因此没有任何单点错误能关闭门控。
2.1 第一层:isBillingEnforced缺失即视为未强制
isBillingEnforced是一次普通的 Redis 读取,用?? false兜底——key 缺失或从未同步,就意味着“未强制计费”:
isBillingEnforced: async (platformId: string) => { return (await distributedStore.get<boolean>(getBillingEnforcedKey(platformId))) ?? false },见 autumn-billing.ts。即便余额显示为 0,只要billingEnforced不是显式的true,门控也不会拦截。
2.2 第二层:computeCreditState要求非空余额才判定“耗尽”
computeCreditState只有在拿到非空余额时,才可能认为信用额度已耗尽:
export function computeCreditState({ balance, enforced }: ComputeCreditStateParams): CreditsGateState { const exhausted = !isNil(balance) && isCreditsExhausted(balance) return { blocked: enforced && exhausted, usage: balance?.usage ?? 0, limit: balance?.granted ?? 0, remaining: balance?.remaining ?? 0, unlimited: balance?.unlimited ?? false, } }其中isCreditsExhausted要求既不是 unlimited、且剩余额度 ≤ 0:
function isCreditsExhausted(credits: CreditsBalanceCache): boolean { return !credits.unlimited && credits.remaining <= 0 }代码见 autumn-billing.ts 与 autumn-billing.ts。
关键语义:balance === null时,exhausted为false,blocked必然为false。单元测试把这一不变量固定了下来(credits-gate.test.ts):
expect(computeCreditState({ balance: null, enforced: true }).blocked).toBe(false)AppSumo 维度也有同样的断言(credits-gate.test.ts):
expect(computeCreditState({ balance: null, enforced: true }).blocked).toBe(false)测试文件同时固定了这些对照用例(credits-gate.test.ts):
| 场景 | 期望blocked |
|---|---|
remaining: 0, enforced: true | true(已知耗尽且强制) |
remaining: 0, enforced: false | false(未强制) |
remaining: 100, enforced: true | false(有余额) |
remaining: 0, unlimited: true, enforced: true | false(无限额度) |
balance: null, enforced: true | false(未知余额,Fail Open) |
2.3 第三层:拉取失败返回null而非抛错
门控路径上的内联拉取被tryCatch包裹,失败时返回null并记录日志'Failed to fetch credits gate snapshot; failing open',而不是抛异常(见 autumn-billing.ts):
const { data, error } = await tryCatch(() => distributedLock(log).runExclusive({ ... })) if (!isNil(error)) { log.warn({ error, platform: { id: platformId } }, 'Failed to fetch credits gate snapshot; failing open') return null } return data2.4 第四层:缓存读取超时/抛错同样 Fail Open
连缓存读取本身超时或抛出异常,也会 Fail Open——两个特性都返回enforced: false。也就是说,Redis 退化只会导致“少拦截”,绝不会导致“误拦截”:
const { data: snapshot, error } = await tryCatch(() => withTimeout(readCreditsCaches(platformId), CREDITS_CACHE_READ_TIMEOUT_MS)) if (isNil(snapshot)) { log.warn({ error, platform: { id: platformId } }, 'Credits gate cache read timed out or failed; failing open without gating this request') return { credits: computeCreditState({ balance: null, enforced: false }), appSumo: computeCreditState({ balance: null, enforced: true }), } }见 autumn-billing.ts。
2.5 用量上报(tracking)同样非致命
信用额度的记录(tracking)也是非致命的。trackCredits会重新抛出非重复错误,但没有任何调用方会让它冒泡:
flow-run-hooks#onFinish将每次运行的信用记录和 AI 用量记录都包在tryCatch中并只打警告;- 聊天路径通过
rejectedPromiseHandler触发 tracker。
因此一个“死掉”的 Autumn既不会弄挂一次运行,也不会弄挂一次聊天轮次。同时sendTrackEvent对重复上报做了幂等保护——只有AutumnError且状态码为 409 才算重复,见isDuplicateTrack(autumn-billing.ts)。
3. 门控本身从不主动拉取:缓存只读 + 后台刷新
决策的另一半是:门控本身从不主动 fetch。computeCreditsAndAppSumoState只做三件事:
- 并发读取
billingEnforced与两个余额 key; - 用
CREDITS_CACHE_READ_TIMEOUT_MS(25 ms)作为整体超时竞速; - 返回判定结果。
async function computeCreditsAndAppSumoState(log: FastifyBaseLogger, platformId: string): Promise<CreditsAndAppSumoState> { const { data: snapshot, error } = await tryCatch(() => withTimeout(readCreditsCaches(platformId), CREDITS_CACHE_READ_TIMEOUT_MS)) // ... fail open 分支 ... const state = { credits: computeCreditState({ balance: snapshot.credits, enforced: snapshot.billingEnforced }), appSumo: computeCreditState({ balance: snapshot.appSumo, enforced: true }), } scheduleCreditsCacheMaintenance({ log, platformId, snapshot, state }) return state }常量定义见 autumn-billing.ts:
const CREDITS_REFETCH_PERIOD_MS = 180 * 1000 // 180s:陈旧阈值 const CUSTOMER_STATE_REFRESH_DEBOUNCE_SECONDS = 15 // 15s:刷新去抖 const CUSTOMER_STATE_MISS_DEBOUNCE_SECONDS = 60 // 60s:缺失标记 TTL const CUSTOMER_STATE_FETCH_LOCK_TIMEOUT_SECONDS = 15 // 15s:分布式锁超时 const CREDITS_CACHE_READ_TIMEOUT_MS = 25 // 25ms:缓存读取超时3.1 为什么必须有 25ms 超时
共享的 Redis 客户端设置了maxRetriesPerRequest: null(BullMQ 的硬性要求)且没有commandTimeout。这意味着:当 Redis 处于重连中时,一条命令可能无限期地挂在离线队列里。如果没有这场“25ms 竞速”,每一个 webhook 都会卡在门控上。
withTimeout的实现(autumn-billing.ts):
function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> { return new Promise<T>((resolve, reject) => { const timer = setTimeout(() => reject(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs) promise.then( (value) => { clearTimeout(timer); resolve(value) }, (rejection) => { clearTimeout(timer); reject(rejection) }, ) }) }3.2 后台刷新的调度规则
除门控判定外,一切“缓存冷/陈旧/缓存为 0 但可能已充值”的情况,都交给scheduleCreditsCacheMaintenance:
function scheduleCreditsCacheMaintenance({ log, platformId, snapshot, state }: CreditsCacheMaintenanceParams): void { const stale = isNil(snapshot.credits) || isCreditsStale(snapshot.credits) if (!stale && !state.credits.blocked && !state.appSumo.blocked) { return } rejectedPromiseHandler(refreshCredits(log, platformId), log) }refreshCredits通过rejectedPromiseHandler触发,并在runOnceWithin(键customer_state_refresh:<platformId>)下做15 秒去抖(autumn-billing.ts):
async function refreshCredits(log: FastifyBaseLogger, platformId: string): Promise<void> { await distributedStore.runOnceWithin(getCustomerStateRefreshKey(platformId), CUSTOMER_STATE_REFRESH_DEBOUNCE_SECONDS, () => fetchCredits(log, platformId), ) }3.3 一个刻意不调度的例外:缓存读取失败
有一种情况刻意不调度刷新:缓存读取超时或抛错。原因很直接——刷新路径需要的正是刚刚失败的同一个 Redis,此时去抖无法生效,而且每当存储退化时,每个请求都会扇出一次resolveClientForPlatform(Postgres 查询)+getCustomer(Autumn HTTP 调用)。修复它的是 Redis 自己恢复:下一次成功的读取会正常调度刷新。
4. 非门控路径:冷未命中单飞 + 缺失标记 + 显式超时
不在门控路径上的读取仍然会内联拉取,Autumn 延迟在这里不是靠超时、而是靠**单飞(single-flighting)**来约束的。
4.1 冷未命中 → 阻塞,但单飞
fetchCreditsDeduped目前只通过resolveCreditsCache到达,服务于getConsumablesUsage(即计费 UI)。它的行为:
- 获取按平台维度的分布式锁(
customer_state_fetch_<platformId>,15 秒); - 在锁内部重新读一次缓存;
- 胜出者调用一次 Autumn 并写缓存;所有等待者发现缓存已填充,直接返回而不再调用。
这样,繁忙平台上 N 个并发的冷未命中,会折叠为跨所有 API 实例的一次getCustomer,而不是每个排队运行各打一次。完整实现见 autumn-billing.ts。
4.2 确认的缺失也会被缓存(60 秒)
单飞只在拉取能产出可缓存余额时才有用。对于没有 Autumn 凭据的平台、或没有apCredits余额的客户,拉取不会写任何东西——于是每次准入都会重新抢锁、重新询问,而且正好发生在“门控本来就是空操作”的那些平台上(从未 enroll 的自托管 EE)。
因此引入缺失标记platform_plan:customer-state-miss:<platformId>(60 秒 TTL):
const fetched = await fetchCredits(log, platformId) if (isNil(fetched?.credits)) { await distributedStore.put(getCustomerStateMissKey(platformId), '1', CUSTOMER_STATE_MISS_DEBOUNCE_SECONDS) }注意关键顺序:标记在拉取确认缺失之后写入,绝不先于调用。如果抛错(Autumn 宕机、超时),不会留下标记,下一次请求会重试,而不是把一次故障“记住”成“这个平台没有客户”。
4.3getCustomer带显式 5 秒超时
check/track继承 SDK 默认值,但 customer 读取没有——一个无界调用会一直挂在上述分布式锁内部,而那里正是“挂起会阻塞其他运行(而不只是调用方自身)”的唯一位置。因此getCustomer显式携带timeoutMs: 5000(autumn-utils.ts):
getCustomer(params?: { expand?: GetCustomerParams['expand'] }) { return client.customers.get( { customerId, expand: params?.expand }, { timeoutMs: AUTUMN_GET_CUSTOMER_TIMEOUT_MS }, ) }其中AUTUMN_GET_CUSTOMER_TIMEOUT_MS = 5000。
4.4 陈旧命中(超过 180 秒)→ 立即返回
当缓存值比CREDITS_REFETCH_PERIOD_MS(180 秒)更旧时:
- 不加锁、不等待,直接返回缓存值;
- 通过
rejectedPromiseHandler+runOnceWithin去抖触发后台刷新。
已持有值的调用方永远不会为了刷新而阻塞(见resolveCreditsCache,autumn-billing.ts)。
5. 两个刻意的例外:已知耗尽时依然拦截
所有 Fail Open 都只针对未知余额。以下两种已知耗尽场景依然硬性拦截:
- AppSumo 信用:无论
billingEnforced为何,已知耗尽即拦截——终身授权没有“续期”可等。 - 聊天与托管 AI:已知耗尽时直接 402 硬拦截(flow run 只会被标记为
QUOTA_EXCEEDED)。
这也解释了为什么门控路径上 AppSumo 的enforced恒为true(见第 2.4 节的 Fail Open 分支与 autumn-billing.ts):AppSumo 授权不依赖计费开关,始终按强制语义判定,只是“未知余额”时同样放行。
6. 自托管 EE:临时跳过运行门控
6.1 一个分支覆盖全部运行门控
shouldBlockRunOnCredits在AP_EDITION=ee时先于任何 provider 调用直接返回false(billing-provider.ts):
export async function shouldBlockRunOnCredits({ platformId, environment, log }: RunCreditsGateParams): Promise<boolean> { if (system.getEdition() === ApEdition.ENTERPRISE) { return false } if (environment !== RunEnvironment.PRODUCTION) { return false } return billingProvider.get(log).shouldBlockOnCredits(platformId) }这一个分支覆盖了所有 flow-run 信用门控——worker RPC 准入(submitPayloads,见 worker-rpc-service.ts)、webhook 路径(webhook.service.ts)、startManualTrigger以及重试断言,全都汇入它。云版不受影响;CE 早已解析为 no-op 的默认 provider。
6.2 原因是延迟,不是策略
添加该分支时,冷缓存会在准入路径上拿分布式锁并等待一次 AutumngetCustomer(5 秒超时),每 60 秒对从未 enroll 的平台重复一次。缓存只读改造已经基本消除了这笔成本——门控现在只是两次 Redis 往返、25ms 封顶、无锁、无 Autumn 调用。剩下的仅是每次准入的两次往返,而在“Redis 与单个 API 进程同机、答案永远是 allow”的盒子上,这可以接受。
6.3 这是一个权宜之计
决策明确标注这是 stopgap:一旦门控能从进程内状态作答(Redis 前的内存 TTL 缓存,或平台加载时一次性解析的 enroll 标志),就移除这个 edition 分支,让未 enroll 的平台每次运行零成本。在那之前,已 enroll 的自托管 EE 平台的 flow run不做信用门控——用量仍然会被记录,只是强制关掉了。聊天与托管 AI 在所有 edition 上都保留各自的门控。
7. 影响与取舍(Consequences)
- Autumn 故障期间的泄漏被接受:强制计费的平台在余额耗尽时仍会继续跑流程、继续消耗托管 AI。兜底手段是 OpenRouter key 自带的月度上限(见决策 000016-managed-ai-metering-moves-to-centralized-worker-execution.md),且下一次成功刷新后强制立即恢复。
- Redis 才是门控的硬依赖:Redis 挂了队列本身就挂了,所以门控永远不会成为最薄弱的环节。
- 后台刷新的代价:发现“缓存为 0”的那个请求仍会被阻塞。已强制计费的平台充值后,会继续产出
QUOTA_EXCEEDED的运行,最长持续一个去抖窗口(15 秒),而不是内联调一次 Autumn 去确认。放弃的是内联复核——它曾在 webhook 路径上花费一次 RedLock 获取(竞争时每 200ms 重试、最多到完整 TTL)加一次getCustomer。 - 自托管 EE 零计费 I/O:EE 盒子启动流程既不需要 Redis 信用 key,也不需要 Autumn 可达性。
- 测试即契约:credits-gate.test.ts 固定了核心不变量——
computeCreditState({ balance: null, enforced: true }).blocked === false(及 AppSumo 等价断言)、缓存读取失败时 Fail Open、以及失败读取不调度刷新。这些用例是阻止未来“未知即欠费”类改动通过评审的唯一防线,因为除了它们,没有任何东西会去模拟一个不可达的 Autumn。
8. 被否决的方案(Rejected Alternatives)
决策记录还明确列出并否决了五条替代路线,理解它们有助于把握本决策的边界:
- 未知余额 Fail Closed(默认阻塞):会把第三方或 Redis 缓存故障放大为全体客户的自动化瘫痪,包括那些远在额度之内的客户。账单准确性事后可恢复,但一天没跑起来的流程不可恢复。
- 缓存陈旧时阻塞刷新:把 Autumn 往返放回运行准入路径,每平台每 180 秒一次,却没有收益——3 分钟前的信用数据不会改变下一次刷新就能纠正的准入决定。
- 把陈旧余额当作未知放行:表面上是去掉读超时的诱人方案,但除了“到来的请求”没有任何东西会刷新缓存——凡是流量比
CREDITS_REFETCH_PERIOD_MS(180 秒)更稀疏的平台,几乎每个请求都会遇到陈旧余额,已耗尽的平台等于每次白拿一次运行。陈旧只决定是否调度刷新,从不决定是否拦截:陈旧的耗尽余额与新鲜的耗尽余额一样拦截。 - 缓存读取失败时调度刷新:刷新需要刚刚失败的同一个 Redis,去抖无法成立,每个请求都会直击 Postgres 与 Autumn。见第 3.3 节。
- 每次请求都从 Autumn 读余额(不缓存):这违反了所有 entitlement 读取的请求路径规则,详见 ee-platform-plans-billing.md。
9. 关键源码速查
| 关注点 | 位置 |
|---|---|
| 决策原文 | 000020-credit-gating-fails-open-on-an-unknown-balance.md |
| 门控核心实现(超时、状态计算、单飞、刷新) | autumn-billing.ts |
| Autumn 客户端、缓存读写、客户状态缓存 | autumn-utils.ts |
shouldBlockRunOnCredits与 edition 分支 | billing-provider.ts |
| worker RPC 准入调用点 | worker-rpc-service.ts |
| webhook 调用点 | webhook.service.ts |
| flow-run 调用点 | flow-run-service.ts |
| 不变量单元测试 | credits-gate.test.ts |
| 相关计费规则文档 | ee-platform-plans-billing.md |
| 托管 AI 计费配套决策 | 000016-managed-ai-metering-moves-to-centralized-worker-execution.md |
10. 总结
Activepieces 的信用门控用一套“三层 Fail Open + 缓存只读 + 后台刷新”的组合,把计费服务(Autumn)从运行热路径的硬依赖中剥离出来:未知余额永不拦截、缓存读取 25ms 封顶、刷新 15 秒去抖、缺失与陈旧各自有明确的缓存策略。系统性的取舍是清晰的——宁可短暂泄漏(autumn 宕机期间继续运行),也不制造大规模自动化瘫痪;同时用单元测试把“未知 ≠ 欠费”钉死为不可回归的契约。对于自托管 EE,运行门控以“记录用量但不强制”的临时形态存在,等待进程内状态方案将其彻底移除。
【免费下载链接】activepiecesAI Agents & MCPs & AI Workflow Automation • (~400 MCP servers for AI agents) • AI Automation / AI Agent with MCPs • AI Workflows & AI Agents • MCPs for AI Agents项目地址: https://gitcode.com/GitHub_Trending/ac/activepieces
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考