官方插件

请求频率控制

按调用方、身份和成本执行原子的请求准入判定。

@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语义
平滑补充额度,并允许独立 bursttoken-bucketlimit / 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 处理:

codeclass含义
RATES_STOPPEDRatesStoppedErrorhandle 或 provider generation 已停止
RATES_UNAVAILABLERatesUnavailableErrorbackend 没有给出可信判定;可能含 retryAfterMs 与 cause
RATES_POLICY_CONFLICTRatesPolicyConflictError同一 registration 已有不同 policy;含 active 与 requested
RATES_INVALID_ARGUMENTRatesInvalidArgumentErrorname、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 不提供事务或自动退款。

可执行示例与回归测试。

最后更新于

本页目录