openclaw/src/gateway/control-plane-rate-limit.ts

87 lines
2.3 KiB
TypeScript

import type { GatewayClient } from "./server-methods/types.js";
const CONTROL_PLANE_RATE_LIMIT_MAX_REQUESTS = 3;
const CONTROL_PLANE_RATE_LIMIT_WINDOW_MS = 60_000;
type Bucket = {
count: number;
windowStartMs: number;
};
const controlPlaneBuckets = new Map<string, Bucket>();
function normalizePart(value: unknown, fallback: string): string {
if (typeof value !== "string") {
return fallback;
}
const normalized = value.trim();
return normalized.length > 0 ? normalized : fallback;
}
export function resolveControlPlaneRateLimitKey(client: GatewayClient | null): string {
const deviceId = normalizePart(client?.connect?.device?.id, "unknown-device");
const clientIp = normalizePart(client?.clientIp, "unknown-ip");
if (deviceId === "unknown-device" && clientIp === "unknown-ip") {
// Last-resort fallback: avoid cross-client contention when upstream identity is missing.
const connId = normalizePart(client?.connId, "");
if (connId) {
return `${deviceId}|${clientIp}|conn=${connId}`;
}
}
return `${deviceId}|${clientIp}`;
}
export function consumeControlPlaneWriteBudget(params: {
client: GatewayClient | null;
nowMs?: number;
}): {
allowed: boolean;
retryAfterMs: number;
remaining: number;
key: string;
} {
const nowMs = params.nowMs ?? Date.now();
const key = resolveControlPlaneRateLimitKey(params.client);
const bucket = controlPlaneBuckets.get(key);
if (!bucket || nowMs - bucket.windowStartMs >= CONTROL_PLANE_RATE_LIMIT_WINDOW_MS) {
controlPlaneBuckets.set(key, {
count: 1,
windowStartMs: nowMs,
});
return {
allowed: true,
retryAfterMs: 0,
remaining: CONTROL_PLANE_RATE_LIMIT_MAX_REQUESTS - 1,
key,
};
}
if (bucket.count >= CONTROL_PLANE_RATE_LIMIT_MAX_REQUESTS) {
const retryAfterMs = Math.max(
0,
bucket.windowStartMs + CONTROL_PLANE_RATE_LIMIT_WINDOW_MS - nowMs,
);
return {
allowed: false,
retryAfterMs,
remaining: 0,
key,
};
}
bucket.count += 1;
return {
allowed: true,
retryAfterMs: 0,
remaining: Math.max(0, CONTROL_PLANE_RATE_LIMIT_MAX_REQUESTS - bucket.count),
key,
};
}
export const __testing = {
resetControlPlaneRateLimitState() {
controlPlaneBuckets.clear();
},
};