Files
buzz/desktop/src/shared/api/relayRateLimitGate.ts
Will Pfleger 258cc92064 fix(desktop): survive degraded networks with rate-limit-aware relay client (#2197)
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1fgdl5qqnh3k3f2xkqrvt7cujalhm623x4s7fdjdj5yrtp5fzjl9qrjpucw <4a1bfa0013bc6d14a8d600d8bf6392efefbd2a26ac3c96c9b2a106b0d12297ca@sprout-oss.stage.blox.sqprod.co>
2026-07-21 12:18:20 -04:00

138 lines
4.5 KiB
TypeScript

/**
* Module-level rate-limit gate for the relay WebSocket and HTTP bridge.
*
* When the relay signals back-pressure via a CLOSED `rate-limited:` message or
* an HTTP 429 response, callers activate the gate. Operations that must not run
* while rate-limited call `isRateLimited()` or await `waitForRateLimit()`.
*
* The gate is a singleton: one shared expiry covers all concurrent callers so
* overlapping hints (multiple CLOSED frames) extend to the latest expiry without
* stacking. This module must be reset on community switch via
* `resetRateLimitGate()`.
*/
/** Minimum gate duration when the relay provides no `retry in Ns` hint. */
const DEFAULT_RATE_LIMIT_SECONDS = 10;
/**
* Maximum hint the TS gate will honour from a relay 429 response.
*
* Mirrors `MAX_HINT_SECONDS` in `relay_admission.rs` (Rust). The Rust relay
* layer clamps the hint before embedding it in the error string, so in practice
* this TS cap is a defence-in-depth guard against any future Rust path that
* forgets to clamp, keeping both gates on the same documented bound.
*/
export const MAX_HINT_SECONDS = 300;
let expiresAt: number | null = null;
let gateTimer: number | null = null;
let gateResolve: (() => void) | null = null;
let gatePromise: Promise<void> | null = null;
/**
* Parse `retry in Ns` from a relay message string.
*
* Matches the relay's canonical hint format embedded in both CLOSED messages
* (`rate-limited: quota exceeded; retry in 4s`) and HTTP 429 bodies
* (`relay rate-limited: retry in 4s`).
*/
export function parseRateLimitHint(msg: string): number | null {
const match = /retry in (\d+)s/i.exec(msg);
return match ? parseInt(match[1], 10) : null;
}
/**
* Activate (or extend) the rate-limit gate.
*
* If the gate is already active, the expiry is pushed forward to the maximum of
* the existing expiry and the new hint — overlapping hints never shrink the
* window. Non-positive or absent hints use the 10-second default; a 0s gate
* would resolve immediately and swallow the signal.
*
* Note: buzz-acp uses a 5s no-hint default; desktop deliberately uses 10s here
* for a wider back-off window on degraded connections.
*/
export function activateRateLimit(retryInSeconds: number | null): void {
const durationMs =
(retryInSeconds != null && retryInSeconds > 0
? Math.min(retryInSeconds, MAX_HINT_SECONDS)
: DEFAULT_RATE_LIMIT_SECONDS) * 1_000;
const newExpiry = Date.now() + durationMs;
if (expiresAt !== null && newExpiry <= expiresAt) {
// Existing window already covers this hint — nothing to do.
return;
}
expiresAt = newExpiry;
if (gateTimer !== null) {
window.clearTimeout(gateTimer);
}
if (gatePromise === null) {
// First activation: create the shared promise that waiters will await.
gatePromise = new Promise<void>((resolve) => {
gateResolve = resolve;
});
}
gateTimer = window.setTimeout(() => {
gateTimer = null;
expiresAt = null;
const resolve = gateResolve;
gateResolve = null;
gatePromise = null;
resolve?.();
}, durationMs);
}
/** Returns `true` when the relay has signalled back-pressure and the gate is active. */
export function isRateLimited(): boolean {
return expiresAt !== null && Date.now() < expiresAt;
}
/**
* Resolves when the rate-limit gate expires.
*
* Resolves immediately if the gate is not active. Callers that need to wait
* before issuing a new relay request should await this before proceeding.
*/
export function waitForRateLimit(): Promise<void> {
if (!isRateLimited() || gatePromise === null) {
return Promise.resolve();
}
return gatePromise;
}
/**
* Returns the milliseconds remaining on the active gate, or 0 when inactive.
*
* Use this instead of re-deriving the hint from the message so that a shorter
* relay hint arriving under a longer active gate never schedules a premature
* retry.
*/
export function rateLimitRemainingMs(): number {
if (expiresAt === null) return 0;
return Math.max(0, expiresAt - Date.now());
}
/**
* Reset all gate state. Must be called on community switch so a rate-limit hint
* from the old relay does not bleed into the new community's session.
*
* Any in-flight `waitForRateLimit()` awaiters are resolved immediately so they
* do not leak into the new session.
*/
export function resetRateLimitGate(): void {
if (gateTimer !== null) {
window.clearTimeout(gateTimer);
gateTimer = null;
}
expiresAt = null;
const resolve = gateResolve;
gateResolve = null;
gatePromise = null;
resolve?.();
}