请求频率控制
按调用方、身份和成本执行原子的请求准入判定。
@pluxel/plugin-rates目前只供 Pluxel 工作区使用,尚不是公开安装入口。完整边界见 Package 矩阵。
@pluxel/plugin-rates 只负责一件事:根据一次请求的身份和成本,原子地判断是否放行。它不负责排队、等待、长期套餐额度或计费,也不会替 HTTP 或 RPC 层选择响应状态。
第一个 limiter
业务 Plugin 依赖抽象能力 Rates,在 init() 中用稳定业务名和策略创建 limiter,处理请求时只需传入身份和可选成本:
import { , } from '@pluxel/core'
import { , } from '@pluxel/core/better-result'
import { , type RateLimiter } from '@pluxel/plugin-rates'
export class extends ('MessageRateLimited')<{
: number
: number
}> {}
@()
export class extends {
private !: RateLimiter
private readonly : string[] = []
constructor(private readonly : ) {
super()
}
protected override (): void {
this. = this..('send-message', {
: 'sliding-window-counter',
: 100,
: 60_000,
})
}
async (: string, : string): <<void, >> {
const = await this..({ , })
if (.) {
return .(
new ({
: .,
: .,
}),
)
}
// Replace this example's in-memory delivery with the application's transport.
this..()
return .()
}
(): number {
return this..
}
}use() 会同步复制、校验、归一化并冻结 policy。相同 caller、name 和归一化 policy 会返回同一个 handle;同名但 policy 不同会同步抛出 RatesPolicyConflictError。因此 name 应是静态业务标识,不能由 request、tenant 或用户输入生成。
部署配置入口
RatesPlugin 没有运行配置,限流 policy 由 consumer 的 use() 调用声明。MemoryRatesBackendPlugin 使用根入口导出的 MemoryRatesBackendConfig:例如 envBinding(MemoryRatesBackendPlugin, { config: { schema: MemoryRatesBackendConfig, mapping: { maxIdentities: 'RATES_MAX_IDENTITIES' } } })。Redis adapter 的 schema 见 Redis 配置入口。
这些入口均无需 Workbench;env/file/saved 的优先级遵循 Host 部署绑定。运行中的额度状态不修改部署容量或业务 policy;Redis 中旧 state 与新 policy 冲突时按已有冲突契约失败,不静默重置额度。
host 选择 backend
单进程 host 可使用内存 backend:
以下 host 是 测试宿主,用于验证装配。应用入口按 添加插件 配置清单、配置记录和自动启动。
import { MemoryRatesBackendPlugin, RatesPlugin } from '@pluxel/plugin-rates'
await host.commit((change) => {
change.start(MemoryRatesBackendPlugin, {
initialConfig: { maxIdentities: 10_000 },
})
change.start(RatesPlugin)
change.start(MessagingPlugin)
})内存 backend 重启后额度会重置,也不会淘汰仍有效的 state。达到 maxIdentities 时,它会抛出 RatesUnavailableError,其中可能带 retryAfterMs。
多进程共享额度可改用 @pluxel/plugin-redis 提供的 adapter:
import { RatesPlugin } from '@pluxel/plugin-rates'
import { RedisPlugin, RedisRatesBackendPlugin } from '@pluxel/plugin-redis'
await host.start([RedisPlugin, RedisRatesBackendPlugin, RatesPlugin, MessagingPlugin])consumer 仍只依赖 Rates。Redis adapter 使用 Redis server time,并在单 key Lua 调用中校验 policy、更新状态和 TTL,避免多个实例间的读写竞态。
同一个 identity 连续消费 100 次后,第 101 次应得到 denied: true;换一个 identity 应获得独立额度。用这个结果验证业务返回值映射,再选下节的成本与算法。内存 backend 的测试不证明跨进程共享额度。
identity 与 cost
consume(identity, { cost? }) 的 cost 默认为 1,也可以表达一次操作消耗多个单位:
const decision = await limiter.consume({ tenantId, modelId }, { cost: estimatedTokens })identity 支持以下形状:
- primitive:
string | number | bigint | boolean; - 最多 16 项的 primitive tuple;
- 最多 16 个字段、字段值均为 primitive 的 plain record。
框架会做带类型的 canonical encoding,所以字符串 "1"、数字 1 和 bigint 1n 不会碰撞,record 字段顺序也不影响 identity。字符串和字段名必须是 well-formed Unicode;最终 canonical encoding 不得超过 1024 UTF-8 bytes。不要使用 access token、cookie 或其他 credential 作为 identity。
cost 必须是正的 safe integer,并且不能超过 policy 容量:token bucket 对应 burst,其他算法对应 limit。无效 identity 或 cost 会让 consume() reject RatesInvalidArgumentError。
选择算法
| 需求 | algorithm | 语义 |
|---|---|---|
| 平滑补充额度,并允许独立 burst | token-bucket | limit / windowMs 持续 refill;burst 默认等于 limit |
| 最低成本的固定周期限制 | fixed-window | 窗口与 Unix epoch 对齐,边界附近可能连续放行两个窗口的额度 |
| 固定空间的近似滚动窗口 | sliding-window-counter | 用当前与前一窗口加权估算,状态为 O(1) |
| 精确滚动窗口 | sliding-window-log | 保存窗口内事件;精确但成本更高,limit 最大为 10,000 |
所有 limit、windowMs 和 burst 都必须是正 safe integer;实现还会检查乘积和状态 TTL 是否安全。policy 只接受精确 plain object,未知字段、getter、symbol 字段或不合法组合都会以 RATES_INVALID_ARGUMENT 拒绝。
读取判定结果
成功完成 backend 判定时,结果只有 allow 或 deny 两种:
const decision = await limiter.consume(userId)
if (decision.denied) {
decision.remaining
decision.retryAfterMs
decision.resetAt
} else {
decision.remaining
decision.resetAt
}remaining:本次判定之后,当前可立即消费的完整 cost-1 单位数;retryAfterMs:被拒绝时,至少等待多久才可能允许同样的 cost;resetAt:如果没有后续 consume,恢复完整容量的 epoch milliseconds。
deny 是可信业务结果,不是异常。backend 无法给出可信结果时 Promise 会 reject;Rates 不会隐式 fail-open,也不会把异常伪装成 deny。
caller 隔离与共享额度
普通 rates.use() 默认把 quota 绑定到 caller 的结构化 Plugin node address。两个不同 Plugin 即使使用相同 name 和 identity,也不会共享额度。
只有多个 Plugin 明确实现同一个平台级 quota contract 时,才使用 global:
this.egress = this.rates.global.use('platform.tenant-egress', {
algorithm: 'token-bucket',
limit: 1_000,
windowMs: 60_000,
burst: 100,
})所有 owner 对同一个 global name 必须注册完全相同的归一化 policy,否则抛出 RatesPolicyConflictError。global 只共享 backend state;handle 的生命周期仍归创建它的 caller 所有。
caller、RatesPlugin 或 backend generation 停止或被替换后,旧 handle 的新调用抛出 RatesStoppedError。已经提交给 backend 的 in-flight 判定不会被事后撤销。
稳定错误契约
所有 Rates 自有错误都继承 RatesError,可按 code 处理:
| code | class | 含义 |
|---|---|---|
RATES_STOPPED | RatesStoppedError | handle 或 provider generation 已停止 |
RATES_UNAVAILABLE | RatesUnavailableError | backend 没有给出可信判定;可能含 retryAfterMs 与 cause |
RATES_POLICY_CONFLICT | RatesPolicyConflictError | 同一 registration 已有不同 policy;含 active 与 requested |
RATES_INVALID_ARGUMENT | RatesInvalidArgumentError | name、policy、identity 或 cost 非法;含 argument |
transport 层应显式映射这些事实。例如 deny 可以映射为 HTTP 429,而 RATES_UNAVAILABLE 应按产品选择 fail-closed、降级或返回服务错误;不要在 capability 内偷偷吞错后放行。
消费业务 Result
上面的 MessagingPlugin.send() 把正常 deny 映射为 MessageRateLimited,保留 retryAfterMs / resetAt,
只在允许时执行投递。示例以进程内数组代替实际发送;实际 transport 的未知失败仍 reject,不伪装成限流。
例如 HTTP consumer 可以这样结束调用:
const sent = await messages.send(tenantId, userId)
if (sent.isErr()) {
return Response.json(
{ code: 'message_rate_limited', retryAfterMs: sent.error.retryAfterMs },
{ status: 429, headers: { 'Retry-After': String(Math.ceil(sent.error.retryAfterMs / 1_000)) } },
)
}
return new Response(null, { status: 204 })底层 consume() 继续返回 RateDecision。RATES_UNAVAILABLE、policy conflict 和停止不是可信 deny,不能转成
MessageRateLimited。准入已经扣除的额度也不会因后续发送失败自动恢复;Result 不提供事务或自动退款。
最后更新于