From 91686cd0cc1477d94f6873011a3ce916ad3bce3c Mon Sep 17 00:00:00 2001 From: Glenn Thompson Date: Thu, 9 Apr 2026 06:59:42 +0100 Subject: [PATCH] Fix r-simple-rate sliding-window bug + normalize polling intervals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rate limiter fix (limiter.lisp): - Override simple-rate::tax-rate with fixed-window implementation The upstream version updates the timestamp on every request, which prevents the window from ever resetting while polling is active. This override only updates the timestamp when the window expires and the counter resets. - Override rate:left to correctly report expired windows as full budget, so with-limitation does not block on stale tracking entries. - These are monkey-patches on r-simple-rate; the upstream library is not modified. Polling normalization: - front-page.lisp: channel-name polling 10s → 15s (matches stream-player.lisp) Context: In frameset mode, front-page.js and stream-player.js both poll the channel-name and now-playing endpoints. The sliding-window bug meant the rate limit counter never reset as long as requests kept arriving within the 60s timeout, eventually exhausting the budget and producing 429 errors for all API endpoints. --- frontend-partials.lisp | 4 +-- limiter.lisp | 56 +++++++++++++++++++++++++++++++++++++ parenscript/front-page.lisp | 2 +- 3 files changed, 59 insertions(+), 3 deletions(-) diff --git a/frontend-partials.lisp b/frontend-partials.lisp index 203ddf8..cec0665 100644 --- a/frontend-partials.lisp +++ b/frontend-partials.lisp @@ -68,7 +68,7 @@ (shuffle-now-playing mount) (harmony-now-playing mount)))) -(define-api-with-limit asteroid/partial/now-playing (&optional mount) (:limit 30 :timeout 60) +(define-api-with-limit asteroid/partial/now-playing (&optional mount) (:limit 120 :timeout 60) "Get Partial HTML with live now-playing status. Optional MOUNT parameter specifies which stream to get metadata from. Returns partial HTML with current track info." @@ -91,7 +91,7 @@ :connection-error t :stats nil)))))) -(define-api-with-limit asteroid/partial/now-playing-inline (&optional mount) (:limit 30 :timeout 60) +(define-api-with-limit asteroid/partial/now-playing-inline (&optional mount) (:limit 120 :timeout 60) "Get inline text with now playing info (for admin dashboard and widgets). Optional MOUNT parameter specifies which stream to get metadata from." (with-error-handling diff --git a/limiter.lisp b/limiter.lisp index 305f0c8..9c65fec 100644 --- a/limiter.lisp +++ b/limiter.lisp @@ -1,4 +1,10 @@ ;;;; limiter.lisp - Rate limiter definitions for the application +;;;; +;;;; Includes monkey-patches for r-simple-rate's sliding-window bug: +;;;; upstream tax-rate updates the timestamp on EVERY request, which +;;;; prevents the window from ever resetting while polling is active. +;;;; Our overrides use a proper fixed window — the timestamp is only +;;;; updated when the window expires and the counter resets. (in-package :asteroid) @@ -15,6 +21,56 @@ (error (e) (l:warn :rate-limiter "Failed to cleanup rate limits: ~a" e)))) +;;; ——— r-simple-rate fixed-window overrides ——— + +(defun simple-rate::tax-rate (limit &key (ip (remote *request*))) + "Fixed-window version of tax-rate. + Only updates the timestamp when the window resets, not on every request. + This prevents the sliding-window bug where continuous polling starves + the counter because the reset condition never triggers." + (let* ((limit (simple-rate::limit limit)) + (tracking (dm:get-one 'simple-rate::tracking + (db:query (:and (:= 'limit (simple-rate::name limit)) + (:= 'ip ip)))))) + (cond (tracking + ;; If the window has expired, reset counter AND timestamp + (when (<= (+ (dm:field tracking "time") + (simple-rate::timeout limit)) + (get-universal-time)) + (setf (dm:field tracking "amount") (simple-rate::amount limit)) + (setf (dm:field tracking "time") (get-universal-time))) + ;; Tax it (do NOT touch timestamp here — fixed window) + (decf (dm:field tracking "amount")) + (dm:save tracking)) + (t + (db:insert 'simple-rate::tracking + `((limit . ,(simple-rate::name limit)) + (time . ,(get-universal-time)) + (amount . ,(simple-rate::amount limit)) + (ip . ,ip))))))) + +(defun rate:left (limit &key (ip (remote *request*))) + "Fixed-window version of rate:left. + Returns correct remaining amount even for expired windows, so that + with-limitation does not block on stale tracking entries." + (let* ((limit (simple-rate::limit limit)) + (tracking (dm:get-one 'simple-rate::tracking + (db:query (:and (:= 'limit (simple-rate::name limit)) + (:= 'ip ip)))))) + (if tracking + (let ((window-end (+ (dm:field tracking "time") + (simple-rate::timeout limit))) + (now (get-universal-time))) + (if (<= window-end now) + ;; Window expired — report full budget + (values (simple-rate::amount limit) 0) + ;; Window still active + (values (dm:field tracking "amount") + (- window-end now)))) + ;; No tracking entry yet — full budget + (values (simple-rate::amount limit) + (simple-rate::timeout limit))))) + (define-trigger db:connected () "Clean up any corrupted rate limit entries on startup" (cleanup-corrupted-rate-limits)) diff --git a/parenscript/front-page.lisp b/parenscript/front-page.lisp index 7a136b1..9658fc3 100644 --- a/parenscript/front-page.lisp +++ b/parenscript/front-page.lisp @@ -776,7 +776,7 @@ (setf (ps:@ curated-option text-content) (+ "🎧 " current-channel-name))))))))))) (catch (lambda (error) (ps:chain console (log "Could not fetch channel name:" error)))))) - 10000)) ;; Poll every 10 seconds + 15000)) ;; Poll every 15 seconds ;; Listen for messages from popout window (ps:chain window