openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
73 lines (72 loc) • 2.81 kB
JavaScript
//#region src/gateway/control-plane-rate-limit.ts
const CONTROL_PLANE_RATE_LIMIT_MAX_REQUESTS = 3;
const CONTROL_PLANE_RATE_LIMIT_WINDOW_MS = 6e4;
const CONTROL_PLANE_BUCKET_MAX_STALE_MS = 5 * 6e4;
/** Hard cap to prevent memory DoS from rapid unique-key injection (CWE-400). */
const CONTROL_PLANE_BUCKET_MAX_ENTRIES = 1e4;
const controlPlaneBuckets = /* @__PURE__ */ new Map();
function normalizePart(value, fallback) {
if (typeof value !== "string") return fallback;
const normalized = value.trim();
return normalized.length > 0 ? normalized : fallback;
}
/** Builds a stable throttle key while avoiding shared fallback buckets for anonymous clients. */
function resolveControlPlaneRateLimitKey(client) {
const deviceId = normalizePart(client?.connect?.device?.id, "unknown-device");
const clientIp = normalizePart(client?.clientIp, "unknown-ip");
if (deviceId === "unknown-device" && clientIp === "unknown-ip") {
const connId = normalizePart(client?.connId, "");
if (connId) return `${deviceId}|${clientIp}|conn=${connId}`;
}
return `${deviceId}|${clientIp}`;
}
/** Consumes one write budget unit and reports retry state for gateway error responses. */
function consumeControlPlaneWriteBudget(params) {
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) {
if (!controlPlaneBuckets.has(key) && controlPlaneBuckets.size >= CONTROL_PLANE_BUCKET_MAX_ENTRIES) {
const oldest = controlPlaneBuckets.keys().next().value;
if (oldest !== void 0) controlPlaneBuckets.delete(oldest);
}
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) return {
allowed: false,
retryAfterMs: Math.max(0, bucket.windowStartMs + CONTROL_PLANE_RATE_LIMIT_WINDOW_MS - nowMs),
remaining: 0,
key
};
bucket.count += 1;
return {
allowed: true,
retryAfterMs: 0,
remaining: Math.max(0, CONTROL_PLANE_RATE_LIMIT_MAX_REQUESTS - bucket.count),
key
};
}
/**
* Remove buckets whose rate-limit window expired more than
* CONTROL_PLANE_BUCKET_MAX_STALE_MS ago. Called periodically
* by the gateway maintenance timer to prevent unbounded growth.
*/
function pruneStaleControlPlaneBuckets(nowMs = Date.now()) {
let pruned = 0;
for (const [key, bucket] of controlPlaneBuckets) if (nowMs - bucket.windowStartMs > CONTROL_PLANE_BUCKET_MAX_STALE_MS) {
controlPlaneBuckets.delete(key);
pruned += 1;
}
return pruned;
}
//#endregion
export { pruneStaleControlPlaneBuckets as n, consumeControlPlaneWriteBudget as t };