feat: unify API rate limiting and enhance request budgets

This commit is contained in:
shuaiplus
2026-03-01 19:55:07 +08:00
committed by Shuai
parent aef0c2f688
commit 4390251c1e
4 changed files with 56 additions and 150 deletions
+6 -18
View File
@@ -32,18 +32,12 @@
// Login lock duration in minutes.
// 登录锁定时长(分钟)。
loginLockoutMinutes: 2,
// Write API request budget per minute.
// 写操作 API 每分钟请求配额。
apiWriteRequestsPerMinute: 120,
// /api/sync read request budget per minute.
// /api/sync 读请求每分钟配额。
syncReadRequestsPerMinute: 1000,
// /api/devices/knowndevice probe budget per IP per minute.
// /api/devices/knowndevice 每 IP 每分钟探测配额。
knownDeviceRequestsPerMinute: 10,
// Public Send access budget per IP per minute.
// 公共 Send 访问接口每 IP 每分钟配额。
publicSendRequestsPerMinute: 60,
// Authenticated API request budget per user per minute (all reads & writes combined).
// 认证 API 每用户每分钟请求配额(读写合计)
apiRequestsPerMinute: 200,
// Public (unauthenticated) request budget per IP per minute.
// 公开(未认证)接口每 IP 每分钟请求配额。
publicRequestsPerMinute: 60,
// Fixed window size for API rate limiting in seconds.
// API 限流固定窗口大小(秒)。
apiWindowSeconds: 60,
@@ -53,15 +47,9 @@
// Minimum interval between login-attempt cleanup runs.
// 登录尝试表清理的最小间隔。
loginIpCleanupIntervalMs: 10 * 60 * 1000,
// Minimum interval between API-window cleanup runs.
// API 窗口计数清理的最小间隔。
apiWindowCleanupIntervalMs: 5 * 60 * 1000,
// Retention window for login IP records.
// 登录 IP 记录保留时长。
loginIpRetentionMs: 30 * 24 * 60 * 60 * 1000,
// Number of historical API windows to keep.
// 保留的历史 API 窗口数量。
apiWindowRetentionWindows: 120,
},
cleanup: {
// Minimum interval between refresh-token cleanup runs.
+2 -2
View File
@@ -267,10 +267,10 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
return jsonResponse(response);
} else if (grantType === 'send_access') {
const sendAccessLimit = await rateLimit.consumePublicSendAccessBudget(`${clientIdentifier}:public-send-oauth`);
const sendAccessLimit = await rateLimit.consumeBudget(`${clientIdentifier}:public`, LIMITS.rateLimit.publicRequestsPerMinute);
if (!sendAccessLimit.allowed) {
return identityErrorResponse(
`Too many public Send requests. Try again in ${sendAccessLimit.retryAfterSeconds} seconds.`,
`Rate limit exceeded. Try again in ${sendAccessLimit.retryAfterSeconds} seconds.`,
'TooManyRequests',
429
);
+15 -37
View File
@@ -215,13 +215,13 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
const method = request.method;
const clientId = getClientIdentifier(request);
async function enforcePublicSendRateLimit(): Promise<Response | null> {
async function enforcePublicRateLimit(): Promise<Response | null> {
const rateLimit = new RateLimitService(env.DB);
const check = await rateLimit.consumePublicSendAccessBudget(`${clientId}:public-send`);
const check = await rateLimit.consumeBudget(`${clientId}:public`, LIMITS.rateLimit.publicRequestsPerMinute);
if (check.allowed) return null;
return new Response(JSON.stringify({
error: 'Too many requests',
error_description: `Too many public Send requests. Try again in ${check.retryAfterSeconds} seconds.`,
error_description: `Rate limit exceeded. Try again in ${check.retryAfterSeconds} seconds.`,
}), {
status: 429,
headers: {
@@ -289,7 +289,7 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
// Public Send access endpoints
const sendAccessMatch = path.match(/^\/api\/sends\/access\/([^/]+)$/i);
if (sendAccessMatch && method === 'POST') {
const blocked = await enforcePublicSendRateLimit();
const blocked = await enforcePublicRateLimit();
if (blocked) return blocked;
const accessId = sendAccessMatch[1];
return handleAccessSend(request, env, accessId);
@@ -297,14 +297,14 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
const sendAccessV2Match = path === '/api/sends/access';
if (sendAccessV2Match && method === 'POST') {
const blocked = await enforcePublicSendRateLimit();
const blocked = await enforcePublicRateLimit();
if (blocked) return blocked;
return handleAccessSendV2(request, env);
}
const sendAccessFileV2Match = path.match(/^\/api\/sends\/access\/file\/([a-f0-9-]+)$/i);
if (sendAccessFileV2Match && method === 'POST') {
const blocked = await enforcePublicSendRateLimit();
const blocked = await enforcePublicRateLimit();
if (blocked) return blocked;
const fileId = sendAccessFileV2Match[1];
return handleAccessSendFileV2(request, env, fileId);
@@ -312,7 +312,7 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
const sendAccessFileMatch = path.match(/^\/api\/sends\/([^/]+)\/access\/file\/([a-f0-9-]+)$/i);
if (sendAccessFileMatch && method === 'POST') {
const blocked = await enforcePublicSendRateLimit();
const blocked = await enforcePublicRateLimit();
if (blocked) return blocked;
const idOrAccessId = sendAccessFileMatch[1];
const fileId = sendAccessFileMatch[2];
@@ -333,12 +333,8 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
// Known device check (no auth required)
if (path === '/api/devices/knowndevice' && method === 'GET') {
const rateLimit = new RateLimitService(env.DB);
const probeLimit = await rateLimit.consumeKnownDeviceProbeBudget(clientId + ':known-device');
if (!probeLimit.allowed) {
// Keep compatibility simple: do not error, just answer "unknown device".
return jsonResponse(false);
}
const blocked = await enforcePublicRateLimit();
if (blocked) return jsonResponse(false);
return handleKnownDevice(request, env);
}
@@ -441,31 +437,13 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
if (currentUser.status !== 'active') {
return errorResponse('Account is disabled', 403);
}
// Dedicated read rate limiting for heavy sync endpoint.
if (path === '/api/sync' && method === 'GET') {
// Unified rate limiting for all authenticated API requests.
{
const rateLimit = new RateLimitService(env.DB);
const rateLimitCheck = await rateLimit.consumeSyncReadBudget(userId + ':' + clientId + ':sync');
if (!rateLimitCheck.allowed) {
return new Response(JSON.stringify({
error: 'Too many requests',
error_description: `Sync rate limit exceeded. Try again in ${rateLimitCheck.retryAfterSeconds} seconds.`,
}), {
status: 429,
headers: {
'Content-Type': 'application/json',
'Retry-After': rateLimitCheck.retryAfterSeconds!.toString(),
'X-RateLimit-Remaining': '0',
},
});
}
}
// API rate limiting only for write operations (keep reads frictionless)
const isWriteMethod = method === 'POST' || method === 'PUT' || method === 'DELETE' || method === 'PATCH';
if (isWriteMethod) {
const rateLimit = new RateLimitService(env.DB);
const rateLimitCheck = await rateLimit.consumeApiWriteBudget(userId + ':' + clientId + ':write');
const rateLimitCheck = await rateLimit.consumeBudget(
userId + ':api',
LIMITS.rateLimit.apiRequestsPerMinute
);
if (!rateLimitCheck.allowed) {
return new Response(JSON.stringify({
+32 -92
View File
@@ -1,37 +1,22 @@
import { LIMITS } from '../config/limits';
// D1-backed rate limiting.
// Notes:
// - Login attempts are tracked per client IP.
// - API rate is tracked per identifier per fixed window.
// Rate limiting service.
// - Login attempts: D1-backed (low volume, security-critical, needs cross-colo persistence).
// - API budgets: Cloudflare Cache API (high volume, auto-expires, zero D1 writes).
// Rate limit configuration
const CONFIG = {
// Friendly default: short cooldown instead of long lockouts.
LOGIN_MAX_ATTEMPTS: LIMITS.rateLimit.loginMaxAttempts,
LOGIN_LOCKOUT_MINUTES: LIMITS.rateLimit.loginLockoutMinutes,
// Write operations only (POST/PUT/DELETE/PATCH) should use this budget.
API_WRITE_REQUESTS_PER_MINUTE: LIMITS.rateLimit.apiWriteRequestsPerMinute,
// Dedicated budget for GET /api/sync reads.
SYNC_READ_REQUESTS_PER_MINUTE: LIMITS.rateLimit.syncReadRequestsPerMinute,
// Dedicated budget for GET /api/devices/knowndevice probes.
KNOWN_DEVICE_REQUESTS_PER_MINUTE: LIMITS.rateLimit.knownDeviceRequestsPerMinute,
// Dedicated budget for unauthenticated public Send access endpoints.
PUBLIC_SEND_REQUESTS_PER_MINUTE: LIMITS.rateLimit.publicSendRequestsPerMinute,
API_WINDOW_SECONDS: LIMITS.rateLimit.apiWindowSeconds,
};
export class RateLimitService {
private static loginIpTableReady = false;
private static lastLoginIpCleanupAt = 0;
private static lastApiWindowCleanupAt = 0;
private static readonly PERIODIC_CLEANUP_PROBABILITY = LIMITS.rateLimit.cleanupProbability;
private static readonly LOGIN_IP_CLEANUP_INTERVAL_MS = LIMITS.rateLimit.loginIpCleanupIntervalMs;
private static readonly API_WINDOW_CLEANUP_INTERVAL_MS = LIMITS.rateLimit.apiWindowCleanupIntervalMs;
private static readonly LOGIN_IP_RETENTION_MS = LIMITS.rateLimit.loginIpRetentionMs;
private static readonly API_WINDOW_RETENTION_WINDOWS = LIMITS.rateLimit.apiWindowRetentionWindows;
constructor(private db: D1Database) {}
@@ -56,16 +41,6 @@ export class RateLimitService {
RateLimitService.lastLoginIpCleanupAt = nowMs;
}
private async maybeCleanupApiWindows(windowStart: number, windowSeconds: number): Promise<void> {
if (!this.shouldRunCleanup(RateLimitService.lastApiWindowCleanupAt, RateLimitService.API_WINDOW_CLEANUP_INTERVAL_MS)) {
return;
}
const cutoff = windowStart - (windowSeconds * RateLimitService.API_WINDOW_RETENTION_WINDOWS);
await this.db.prepare('DELETE FROM api_rate_limits WHERE window_start < ?').bind(cutoff).run();
RateLimitService.lastApiWindowCleanupAt = Date.now();
}
private async ensureLoginIpTable(): Promise<void> {
if (RateLimitService.loginIpTableReady) return;
@@ -162,8 +137,9 @@ export class RateLimitService {
await this.db.prepare('DELETE FROM login_attempts_ip WHERE ip = ?').bind(key).run();
}
// Atomically consume one budget unit for the current fixed window.
// Uses SQLite UPSERT-with-WHERE so requests at/over limit do not increment.
// Cache API-backed fixed-window rate limiter.
// Uses Cloudflare edge cache instead of D1 — zero database writes, auto-expires via TTL.
// Per-colo isolation is acceptable (matches Cloudflare's own rate limiting behaviour).
private async consumeFixedWindowBudget(
identifier: string,
maxRequests: number,
@@ -172,77 +148,41 @@ export class RateLimitService {
const nowSec = Math.floor(Date.now() / 1000);
const windowStart = nowSec - (nowSec % windowSeconds);
const windowEnd = windowStart + windowSeconds;
await this.maybeCleanupApiWindows(windowStart, windowSeconds);
const ttl = Math.max(1, windowEnd - nowSec);
const writeResult = await this.db
.prepare(
'INSERT INTO api_rate_limits(identifier, window_start, count) VALUES(?, ?, 1) ' +
'ON CONFLICT(identifier, window_start) DO UPDATE SET count = count + 1 ' +
'WHERE api_rate_limits.count < ?'
)
.bind(identifier, windowStart, maxRequests)
.run();
const cache = await caches.open('rate-limit');
const cacheKey = new Request(`https://rl/${identifier}/${windowStart}`);
// No changed row means conflict happened and WHERE prevented increment:
// current count is already at/above configured limit.
if ((writeResult.meta.changes ?? 0) === 0) {
return {
allowed: false,
remaining: 0,
retryAfterSeconds: windowEnd - nowSec,
};
const cached = await cache.match(cacheKey);
let count = 0;
if (cached) {
count = parseInt(await cached.text(), 10) || 0;
}
const row = await this.db
.prepare('SELECT count FROM api_rate_limits WHERE identifier = ? AND window_start = ?')
.bind(identifier, windowStart)
.first<{ count: number }>();
if (!row) {
return {
allowed: true,
remaining: 0,
};
if (count >= maxRequests) {
return { allowed: false, remaining: 0, retryAfterSeconds: ttl };
}
const remaining = Math.max(0, maxRequests - row.count);
return { allowed: true, remaining };
}
// Write budget for POST/PUT/DELETE/PATCH requests.
async consumeApiWriteBudget(identifier: string): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> {
return this.consumeFixedWindowBudget(
identifier,
CONFIG.API_WRITE_REQUESTS_PER_MINUTE,
CONFIG.API_WINDOW_SECONDS
count++;
await cache.put(
cacheKey,
new Response(String(count), {
headers: { 'Cache-Control': `public, max-age=${ttl}` },
})
);
return { allowed: true, remaining: Math.max(0, maxRequests - count) };
}
// Read budget for GET /api/sync.
async consumeSyncReadBudget(identifier: string): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> {
return this.consumeFixedWindowBudget(
identifier,
CONFIG.SYNC_READ_REQUESTS_PER_MINUTE,
CONFIG.API_WINDOW_SECONDS
);
}
// Probe budget for GET /api/devices/knowndevice.
async consumeKnownDeviceProbeBudget(identifier: string): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> {
return this.consumeFixedWindowBudget(
identifier,
CONFIG.KNOWN_DEVICE_REQUESTS_PER_MINUTE,
CONFIG.API_WINDOW_SECONDS
);
}
// Budget for unauthenticated public Send access endpoints.
async consumePublicSendAccessBudget(identifier: string): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> {
return this.consumeFixedWindowBudget(
identifier,
CONFIG.PUBLIC_SEND_REQUESTS_PER_MINUTE,
CONFIG.API_WINDOW_SECONDS
);
// General-purpose fixed-window budget.
// Callers supply an identifier (must be unique per rate-limit category) and the
// per-window maximum. This single method replaces all previous specialised
// budget helpers (write / sync / knownDevice / publicSend).
async consumeBudget(
identifier: string,
maxRequests: number
): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> {
return this.consumeFixedWindowBudget(identifier, maxRequests, CONFIG.API_WINDOW_SECONDS);
}
}