Fix r-simple-rate sliding-window bug + normalize polling intervals

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.
This commit is contained in:
Glenn Thompson 2026-04-09 06:59:42 +01:00
parent 9042e78ae8
commit 91686cd0cc
3 changed files with 59 additions and 3 deletions

View File

@ -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

View File

@ -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))

View File

@ -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