为什么需要响应头
只有配额没有反馈,客户端只能猜测:要么一直打到收到 429,要么退避得远超必要。速率限制响应头把策略和剩余额度写进每个响应,包括成功响应,让客户端形成闭环控制。
字段如何工作
IETF 草案定义两个结构化字段。RateLimit-Policy 公布配额,例如 RateLimit-Policy: "default";q=100;w=60 表示每 60 秒 100 次;RateLimit 报告当前状态,例如 RateLimit: "default";r=12;t=38 表示还剩 12 次、38 秒后重置。早期草案和许多商业 API 使用 X-RateLimit-Limit、X-RateLimit-Remaining、X-RateLimit-Reset,这些只是惯例,并未标准化。
配额耗尽时返回 429 Too Many Requests,并按 RFC 9110 带上 Retry-After。两者必须一致,Retry-After 不应与公布的重置时间矛盾。
何时使用与取舍
当客户端共享配额或运行无人值守任务时应暴露这些头,SDK 可据此动态调整并发,而不是写死常量。单个固定契约的内部调用方收益不大。
精细的头会泄露策略形状,攻击者可据此把流量控制在阈值之下;大量客户端在同一秒看到重置,还会形成新的惊群,因此重置时间应加抖动。CDN 和代理可能剥离或缓存这些字段,需要在上线前验证。
示例与常见误解
批处理客户端读到 RateLimit: "default";r=0;t=17,把下次调用安排到 17 秒外加随机抖动,避免了重试风暴;无视该字段的客户端立即重试,只会再收到一个 429。
关键误解是认为响应头具有强制力。强制发生在服务端,响应头只是建议性遥测,客户端可以忽略,服务端必须在被忽略时仍然正确。相关概念:速率限制、重试预算、API 网关。
代码示例
export interface RateLimitResult {
allowed: boolean;
remaining: number;
retryAfterMs: number;
resetAt: number;
}
export class SlidingWindowRateLimiter {
private readonly hits = new Map<string, number[]>();
public constructor(
private readonly limit: number,
private readonly windowMs: number,
private readonly now: () => number = Date.now,
) {
if (!Number.isInteger(limit) || limit < 1) {
throw new RangeError("limit must be a positive integer");
}
if (!Number.isFinite(windowMs) || windowMs <= 0) {
throw new RangeError("windowMs must be a positive finite number");
}
if (typeof now !== "function") {
throw new TypeError("now must be a function");
}
}
public consume(key: string, cost = 1): RateLimitResult {
if (typeof key !== "string" || key.length === 0) {
throw new TypeError("key must be a non-empty string");
}
if (!Number.isInteger(cost) || cost < 1 || cost > this.limit) {
throw new RangeError("cost must be an integer between 1 and limit");
}
const timestamp = this.now();
const cutoff = timestamp - this.windowMs;
const timestamps = (this.hits.get(key) ?? []).filter(
(value) => value > cutoff,
);
if (timestamps.length + cost > this.limit) {
const oldest = timestamps[0] ?? timestamp;
this.hits.set(key, timestamps);
return {
allowed: false,
remaining: Math.max(0, this.limit - timestamps.length),
retryAfterMs: Math.max(0, oldest + this.windowMs - timestamp),
resetAt: oldest + this.windowMs,
};
}
for (let index = 0; index < cost; index += 1) {
timestamps.push(timestamp);
}
this.hits.set(key, timestamps);
return {
allowed: true,
remaining: this.limit - timestamps.length,
retryAfterMs: 0,
resetAt: timestamps[0] + this.windowMs,
};
}
public reset(key: string): boolean {
return this.hits.delete(key);
}
public prune(): number {
const cutoff = this.now() - this.windowMs;
let removed = 0;
for (const [key, timestamps] of this.hits) {
const active = timestamps.filter((value) => value > cutoff);
if (active.length === 0) {
this.hits.delete(key);
removed += 1;
} else {
this.hits.set(key, active);
}
}
return removed;
}
}
let now = 0;
const limiter = new SlidingWindowRateLimiter(3, 1000, () => now);
for (let index = 0; index < 3; index += 1) {
console.log(limiter.consume("user:1"));
}
console.log(limiter.consume("user:1"));
now = 600;
console.log(limiter.consume("user:1", 2));
now = 1001;
console.log(limiter.consume("user:1"));
console.log(limiter.consume("user:2", 2));
now = 3000;
console.log(limiter.prune());
export class TokenBucket {
constructor(options) {
const { capacity, refillPerSecond, now = () => Date.now() } = options;
if (!Number.isInteger(capacity) || capacity < 1) {
throw new RangeError("capacity must be a positive integer");
}
if (!Number.isFinite(refillPerSecond) || refillPerSecond <= 0) {
throw new RangeError("refillPerSecond must be a positive number");
}
this.capacity = capacity;
this.refillPerSecond = refillPerSecond;
this.now = now;
this.tokens = capacity;
this.lastRefill = now();
}
get available() {
this.refill();
return this.tokens;
}
tryConsume(cost = 1) {
if (!Number.isFinite(cost) || cost <= 0) {
throw new RangeError("cost must be a positive number");
}
if (cost > this.capacity) {
throw new RangeError(`cost ${cost} exceeds bucket capacity ${this.capacity}`);
}
this.refill();
if (this.tokens < cost) {
return { allowed: false, remaining: this.tokens };
}
this.tokens -= cost;
return { allowed: true, remaining: this.tokens };
}
refill() {
const t = this.now();
const elapsedMs = Math.max(0, t - this.lastRefill);
const gained = (elapsedMs / 1000) * this.refillPerSecond;
this.tokens = Math.min(this.capacity, this.tokens + gained);
this.lastRefill = t;
}
}