ratelimit slop

This commit is contained in:
legop3
2026-07-12 14:46:12 -04:00
parent cc525afe20
commit 23108241f1
@@ -35,6 +35,7 @@ const SPOTLIGHT_VERIFY_DELAY_MS = 1200;
const PUBLISHER_STDERR_SYNC_MS = 10000; const PUBLISHER_STDERR_SYNC_MS = 10000;
const PUBLISHER_RTSP_TIMEOUT_US = 10000000; const PUBLISHER_RTSP_TIMEOUT_US = 10000000;
const REOLINK_API_RETRY_MS = 1000; const REOLINK_API_RETRY_MS = 1000;
const REOLINK_API_WRITE_INTERVAL_MS = 1000;
const events = new EventEmitter(); const events = new EventEmitter();
const config = loadConfig(); const config = loadConfig();
@@ -92,6 +93,8 @@ let snapshotTimer = null;
let spotlightVerifyTimer = null; let spotlightVerifyTimer = null;
let vendorStatePromise = Promise.resolve(); let vendorStatePromise = Promise.resolve();
let reolinkApiLogNextAt = 0; let reolinkApiLogNextAt = 0;
let reolinkWriteInFlight = false;
let lastReolinkWriteAcceptedAt = 0;
let lastSnapshotState = null; let lastSnapshotState = null;
const snapshotSubscribers = new Map(); const snapshotSubscribers = new Map();
const socketSnapshotSubscriptions = new Map(); const socketSnapshotSubscriptions = new Map();
@@ -815,6 +818,47 @@ function serializeVendorState(operation) {
return vendorStatePromise; return vendorStatePromise;
} }
function reserveReolinkWriteSlot(control) {
/*
Reolink light/IR writes are physical camera API writes, not high-frequency
control signals. Rejecting too-fast requests here, before they enter
serializeVendorState(), is what makes this a true rate limit instead of a
delayed queue. A request either gets the current write slot immediately or
fails immediately; the server never stores skipped toggle states to replay
later.
*/
if (reolinkWriteInFlight) {
const err = new Error('PTZ API rate limited');
err.code = 'PTZ_REOLINK_RATE_LIMITED';
err.control = control;
err.retryAfterMs = REOLINK_API_WRITE_INTERVAL_MS;
throw err;
}
const now = Date.now();
const elapsed = now - lastReolinkWriteAcceptedAt;
if (elapsed < REOLINK_API_WRITE_INTERVAL_MS) {
const err = new Error('PTZ API rate limited');
err.code = 'PTZ_REOLINK_RATE_LIMITED';
err.control = control;
err.retryAfterMs = REOLINK_API_WRITE_INTERVAL_MS - elapsed;
throw err;
}
reolinkWriteInFlight = true;
lastReolinkWriteAcceptedAt = now;
}
function releaseReolinkWriteSlot() {
/*
Keep the in-flight flag separate from the timestamp. The flag blocks
overlap while an accepted write is still talking to the camera or waiting
through API reconnect; the timestamp blocks a second accepted write from
starting immediately after the first one finishes.
*/
reolinkWriteInFlight = false;
}
async function initialize() { async function initialize() {
if (!enabled || state.initialized || state.initializing) return; if (!enabled || state.initialized || state.initializing) return;
state.initializing = true; state.initializing = true;
@@ -1173,7 +1217,9 @@ async function removePreset(socket, payload = {}) {
async function setSpotlight(socket, payload = {}) { async function setSpotlight(socket, payload = {}) {
requireOperator(socket); requireOperator(socket);
reserveReolinkWriteSlot('spotlight');
return serializeVendorState(async () => { return serializeVendorState(async () => {
try {
let current = state.light ? normalizeSpotlightState(state.light) : null; let current = state.light ? normalizeSpotlightState(state.light) : null;
if (payload.state === undefined && !current) { if (payload.state === undefined && !current) {
/* /*
@@ -1212,12 +1258,17 @@ async function setSpotlight(socket, payload = {}) {
await callReolinkApi('SetWhiteLed', { WhiteLed: cameraPayload }); await callReolinkApi('SetWhiteLed', { WhiteLed: cameraPayload });
scheduleSpotlightVerification(); scheduleSpotlightVerification();
return state.light; return state.light;
} finally {
releaseReolinkWriteSlot();
}
}); });
} }
async function setIr(socket, payload = {}) { async function setIr(socket, payload = {}) {
requireOperator(socket); requireOperator(socket);
reserveReolinkWriteSlot('ir');
return serializeVendorState(async () => { return serializeVendorState(async () => {
try {
const nextState = normalizeIrState(payload.state); const nextState = normalizeIrState(payload.state);
/* /*
The camera requires channel inside IrLights. Without it, SetIrLights The camera requires channel inside IrLights. Without it, SetIrLights
@@ -1231,6 +1282,9 @@ async function setIr(socket, payload = {}) {
await refreshVendorState(); await refreshVendorState();
emitChange('ir'); emitChange('ir');
return state.ir; return state.ir;
} finally {
releaseReolinkWriteSlot();
}
}); });
} }