ratelimit.js 2.07 KB
/**
 * `createAiLimit` —— 给烧钱的 AI 端点(LLM 续写 / TTS 合成 / 生图)挂 per-IP / 匿名基础
 * 限流的 Fastify preHandler 工厂,超限返结构化 `429`。
 *
 * 只做「按端点类别构造 RateQuery → consume → 超限成形 429」,桶数学 / store 在 @drama/ratelimit
 * 内。subject 取匿名 device-id(`x-device-id`)优先,否则回退客户端 IP(per-IP 防脚本薅穿)。
 */

import { parse } from 'valibot'
import { ApiError } from '@drama/contracts/platform'

/**
 * @param {{ limiter: { consume: Function } }} deps  @drama/ratelimit 的 limiter 实例
 * @returns {(endpoint: 'llm'|'tts'|'image') => import('fastify').preHandlerHookHandler}
 */
export function createAiLimit({ limiter }) {
  if (!limiter || typeof limiter.consume !== 'function') {
    throw new Error('createAiLimit: 缺少注入的 limiter(需实现 consume)')
  }

  return function aiLimit(endpoint) {
    return async function preHandler(request, reply) {
      const subject = subjectOf(request)
      const decision = await limiter.consume(request.ctx, { endpoint, subject })
      if (!decision.allowed) {
        const retryAfterSec = Math.ceil(decision.retryAfterMs / 1000)
        reply
          .code(429)
          .header('retry-after', String(retryAfterSec))
          .header('x-ratelimit-remaining', String(Math.max(0, decision.remaining)))
        return reply.send(
          parse(ApiError, {
            error: {
              code: 'rate_limited',
              message: `Too many ${endpoint} requests, retry after ${retryAfterSec}s`,
              retryable: true,
              details: { endpoint, retryAfterMs: decision.retryAfterMs, ruleId: decision.ruleId },
            },
          }),
        )
      }
      reply.header('x-ratelimit-remaining', String(Math.max(0, decision.remaining)))
    }
  }
}

/**
 * @param {import('fastify').FastifyRequest} request
 * @returns {string}
 */
function subjectOf(request) {
  const dev = request.headers['x-device-id']
  if (typeof dev === 'string' && dev) return `anon:${dev}`
  return `ip:${request.ip || 'unknown'}`
}