← 中文资源

简体中文版本

API 速率限制响应头

速率限制响应头告知客户端剩余配额和重置时间,让客户端在收到 429 之前主动退避,而不是盲目重试。

阅读英文原文

为什么需要响应头

只有配额没有反馈,客户端只能猜测:要么一直打到收到 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;
  }
}