-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathlimiter.lisp
More file actions
123 lines (112 loc) · 5.58 KB
/
limiter.lisp
File metadata and controls
123 lines (112 loc) · 5.58 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
;;;; limiter.lisp - Rate limiter definitions for the application
;;;;
;;;; Replaces r-simple-rate's with-limitation with a fixed-window
;;;; implementation. The upstream tax-rate updates the timestamp on
;;;; EVERY request, preventing the window from ever resetting while
;;;; polling is active. Our define-*-with-limit macros bypass
;;;; rate:with-limitation entirely and call fixed-window-check instead.
(in-package :asteroid)
(defun cleanup-corrupted-rate-limits ()
"Clean up corrupted rate limit entries with negative amounts.
The r-simple-rate library has a bug where the reset condition only triggers
when amount >= 0, so negative amounts never reset. This function deletes
any corrupted entries so they can be recreated fresh."
(handler-case
(let ((deleted (db:remove 'simple-rate::tracking
(db:query (:< 'amount 0)))))
(when (and deleted (> deleted 0))
(l:info :rate-limiter "Cleaned up ~a corrupted rate limit entries" deleted)))
(error (e)
(l:warn :rate-limiter "Failed to cleanup rate limits: ~a" e))))
;;; --- Fixed-window rate limiter ---
;;;
;;; r-simple-rate has a sliding-window bug: tax-rate updates the timestamp
;;; on every request, so the window never resets while polling is active.
;;; Rather than monkey-patching (Radiance may reload the module and clobber
;;; our overrides), we implement our own fixed-window logic directly.
(defun fixed-window-check (limit-name max-requests timeout-seconds)
"Check and tax a fixed-window rate limit. Returns T if the request
is allowed, or (VALUES NIL seconds-remaining) if rate-limited.
LIMIT-NAME is a string key, MAX-REQUESTS and TIMEOUT-SECONDS are integers.
Uses the SIMPLE-RATE/TRACKING table for storage."
(let* ((ip (remote *request*))
(tracking (dm:get-one 'simple-rate::tracking
(db:query (:and (:= 'limit limit-name)
(:= 'ip ip)))))
(now (get-universal-time)))
(cond
;; Existing entry
(tracking
(let ((window-end (+ (dm:field tracking "time") timeout-seconds)))
(when (<= window-end now)
;; Window expired - reset counter and start new window
(setf (dm:field tracking "amount") max-requests)
(setf (dm:field tracking "time") now)
(setf window-end (+ now timeout-seconds)))
;; Check budget
(if (<= (dm:field tracking "amount") 0)
;; Exhausted - report time remaining
(values nil (- window-end now))
;; Allowed - decrement and save
(progn
(decf (dm:field tracking "amount"))
(dm:save tracking)
t))))
;; First request ever from this IP for this limit
(t
(db:insert 'simple-rate::tracking
`((limit . ,limit-name)
(time . ,now)
(amount . ,(1- max-requests))
(ip . ,ip)))
t))))
(define-trigger db:connected ()
"Clean up any corrupted rate limit entries on startup"
(cleanup-corrupted-rate-limits))
(defun render-rate-limit-error-page()
(clip:process-to-string
(load-template "error")
:error-message "It seems that your acceleration has elevated your orbit out of your designated path."
:error-action "Please wait a moment for it to stabilize and try your request again."))
(defun api-limit-error-output ()
(api-output `(("status" . "error")
("message" . "It seems that your acceleration has elevated your orbit out of your designated path."))
:message "It seems that your acceleration has elevated your orbit out of your designated path."
:status 429))
(defun extract-limit-options (options)
"Extracts the rate-limit options and forwards the reamaining radiance route options"
(let ((limit (getf options :limit))
(timeout (getf options :timeout))
(group (getf options :limit-group))
(rest (loop for (k v) on options by #'cddr
unless (member k '(:limit :timeout :limit-group))
append (list k v))))
(values limit timeout group rest)))
(defmacro define-page-with-limit (name uri options &body body)
"Rate limit for a page route. Defaults to 30 requests per minute.
Uses fixed-window rate limiting (not r-simple-rate's sliding window)."
(multiple-value-bind (limit timeout group rest) (extract-limit-options options)
(let* ((limit-name (string-upcase (format nil "~a-route-limit" (or group name))))
(limit (or limit 30))
(timeout (or timeout 60)))
`(define-page ,name ,uri ,rest
(multiple-value-bind (allowed time-left)
(fixed-window-check ,limit-name ,limit ,timeout)
(declare (ignorable time-left))
(if allowed
(progn ,@body)
(render-rate-limit-error-page)))))))
(defmacro define-api-with-limit (name args options &body body)
"Rate limit for api routes. Defaults to 60 requests per minute.
Uses fixed-window rate limiting (not r-simple-rate's sliding window)."
(multiple-value-bind (limit timeout group rest) (extract-limit-options options)
(let* ((limit-name (string-upcase (format nil "~a-api-limit" (or group name))))
(limit (or limit 60))
(timeout (or timeout 60)))
`(define-api ,name ,args ,rest
(multiple-value-bind (allowed time-left)
(fixed-window-check ,limit-name ,limit ,timeout)
(declare (ignorable time-left))
(if allowed
(progn ,@body)
(api-limit-error-output)))))))