Skip to content

Commit 38c2119

Browse files
authored
Add CL-REDIS-backed stores fixed-window rate limiting
1 parent cdc39d5 commit 38c2119

5 files changed

Lines changed: 178 additions & 1 deletion
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
(asdf:defsystem #:io.github.cl-sdk.wst.rate-limit.redis-store
2+
:description "Redis-backed storage backend for wst.rate-limit."
3+
:author "Bruno Dias"
4+
:license "Unlicense"
5+
:version "0.0.1"
6+
:depends-on (#:io.github.cl-sdk.wst.rate-limit
7+
#:cl-redis)
8+
:pathname "rate-limit"
9+
:serial t
10+
:components ((:file "redis-store")))
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
(asdf:defsystem #:io.github.cl-sdk.wst.rate-limit.redis-store.test
2+
:description "Redis-backed rate limit storage backend."
3+
:author "Bruno Dias"
4+
:license "Unlicense"
5+
:version "0.0.1"
6+
:depends-on (#:fiveam
7+
#:io.github.cl-sdk.wst.rate-limit.redis-store)
8+
:pathname "t"
9+
:serial t
10+
:components ((:file "rate-limit-redis-store-tests")))

io.github.cl-sdk.wst.test.asd

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,5 @@
1717
#:io.github.cl-sdk.wst.trace-context.routing.test
1818
#:io.github.cl-sdk.wst.feature-flag.test
1919
#:io.github.cl-sdk.wst.feature-flag.routing.test
20-
#:io.github.cl-sdk.wst.session.sqlite.test))
20+
#:io.github.cl-sdk.wst.session.sqlite.test
21+
#:io.github.cl-sdk.wst.rate-limit.redis-store.test))

rate-limit/redis-store.lisp

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
(defpackage :io.github.cl-sdk.wst.rate-limit.redis-store
2+
(:use #:cl)
3+
(:documentation "Redis-backed storage backend for wst.rate-limit.
4+
5+
This backend implements the wst.rate-limit.store protocol using Redis hash
6+
entries with fields:
7+
- \"count\" : current request count
8+
- \"start\" : window start time (universal-time)")
9+
(:export
10+
#:redis-store))
11+
12+
(in-package :io.github.cl-sdk.wst.rate-limit.redis-store)
13+
14+
(defclass redis-store ()
15+
((key-prefix :initarg :key-prefix
16+
:initform "wst:rate-limit:"
17+
:reader redis-store-key-prefix)
18+
(window-seconds :initarg :window-seconds
19+
:initform nil
20+
:reader redis-store-window-seconds)
21+
(connection :initarg :connection
22+
:initform nil
23+
:reader redis-store-connection))
24+
(:documentation "Redis-backed implementation of the rate-limit store protocol.
25+
26+
Slots:
27+
- KEY-PREFIX: string prefix used to namespace Redis keys.
28+
- WINDOW-SECONDS: optional TTL set on each saved key via EXPIRE.
29+
- CONNECTION: CL-REDIS connection object used as REDIS:*CONNECTION*."))
30+
31+
(defmacro redis-store--with-connection ((store) &body body)
32+
`(let ((redis:*connection* (redis-store-connection ,store)))
33+
,@body))
34+
35+
(defun redis-store--key (store key)
36+
(format nil "~a~a"
37+
(redis-store-key-prefix store)
38+
(write-to-string key :readably t)))
39+
40+
(defun %integer-or-nil (value)
41+
(cond
42+
((null value) nil)
43+
((integerp value) value)
44+
((stringp value)
45+
(handler-case (parse-integer value :junk-allowed nil)
46+
(error () nil)))
47+
(t nil)))
48+
49+
(defmethod io.github.cl-sdk.wst.rate-limit.store:fetch-window ((store redis-store) key)
50+
(let* ((redis-key (redis-store--key store key))
51+
(reply (redis-store--with-connection (store)
52+
(redis:red-hmget redis-key "count" "start")))
53+
(count-raw (and (listp reply) (first reply)))
54+
(start-raw (and (listp reply) (second reply)))
55+
(count (%integer-or-nil count-raw))
56+
(start (%integer-or-nil start-raw)))
57+
(if (and count start)
58+
(values count start)
59+
(values nil nil))))
60+
61+
(defmethod io.github.cl-sdk.wst.rate-limit.store:save-window ((store redis-store) key count start-time)
62+
(let* ((redis-key (redis-store--key store key))
63+
(ttl (redis-store-window-seconds store)))
64+
(redis-store--with-connection (store)
65+
(redis:red-hmset redis-key
66+
"count" (write-to-string count)
67+
"start" (write-to-string start-time)))
68+
(when (and ttl (plusp ttl))
69+
(redis-store--with-connection (store)
70+
(redis:red-expire redis-key ttl)))
71+
t))
72+
73+
(defmethod io.github.cl-sdk.wst.rate-limit.store:delete-window ((store redis-store) key)
74+
(redis-store--with-connection (store)
75+
(redis:red-del (redis-store--key store key))))
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
(defpackage :io.github.cl-sdk.wst.rate-limit.redis-store.test
2+
(:use #:cl))
3+
4+
(in-package :io.github.cl-sdk.wst.rate-limit.redis-store.test)
5+
6+
(5am:def-suite wst.rate-limit.redis-store.suite
7+
:description "Tests for the wst.rate-limit.redis-store package.")
8+
9+
(5am:in-suite wst.rate-limit.redis-store.suite)
10+
11+
(defun ensure-rate-limit-entry (records key)
12+
(or (gethash key records)
13+
(setf (gethash key records) (make-hash-table :test #'equal))))
14+
15+
(defmacro with-mocked-rate-limit-redis ((records expiries) &body body)
16+
`(let* ((hmget-original (symbol-function 'redis:red-hmget))
17+
(hmset-original (symbol-function 'redis:red-hmset))
18+
(expire-original (symbol-function 'redis:red-expire))
19+
(del-original (symbol-function 'redis:red-del)))
20+
(unwind-protect
21+
(progn
22+
(setf (symbol-function 'redis:red-hmget)
23+
(lambda (key field &rest fields)
24+
(let* ((entry (gethash key ,records))
25+
(wanted-fields (cons field fields)))
26+
27+
(mapcar (lambda (field-name)
28+
(and entry (gethash field-name entry)))
29+
wanted-fields)))
30+
(symbol-function 'redis:red-hmset)
31+
(lambda (key &rest fields-and-values)
32+
(let ((entry (ensure-rate-limit-entry ,records key)))
33+
(loop for (field value) on fields-and-values by #'cddr
34+
do (setf (gethash field entry) value)))
35+
"OK")
36+
(symbol-function 'redis:red-expire)
37+
(lambda (key ttl)
38+
(setf (gethash key ,expiries) ttl)
39+
t)
40+
(symbol-function 'redis:red-del)
41+
(lambda (key &rest keys)
42+
(let ((removed 0))
43+
(dolist (k (cons key keys) removed)
44+
(when (gethash k ,records)
45+
(incf removed))
46+
(remhash k ,records)
47+
(remhash k ,expiries)))))
48+
,@body)
49+
(setf (symbol-function 'redis:red-hmget) hmget-original
50+
(symbol-function 'redis:red-hmset) hmset-original
51+
(symbol-function 'redis:red-expire) expire-original
52+
(symbol-function 'redis:red-del) del-original))))
53+
54+
(5am:def-test redis-rate-limit-store-roundtrip ()
55+
(let ((records (make-hash-table :test #'equal))
56+
(expiries (make-hash-table :test #'equal)))
57+
(with-mocked-rate-limit-redis (records expiries)
58+
(let ((store (make-instance 'io.github.cl-sdk.wst.rate-limit.redis-store:redis-store)))
59+
(multiple-value-bind (count start)
60+
(io.github.cl-sdk.wst.rate-limit.store:fetch-window store :client-a)
61+
(5am:is-false count)
62+
(5am:is-false start))
63+
(io.github.cl-sdk.wst.rate-limit.store:save-window store :client-a 3 1000)
64+
(multiple-value-bind (count start)
65+
(io.github.cl-sdk.wst.rate-limit.store:fetch-window store :client-a)
66+
(5am:is (= 3 count))
67+
(5am:is (= 1000 start)))
68+
(io.github.cl-sdk.wst.rate-limit.store:delete-window store :client-a)
69+
(multiple-value-bind (count start)
70+
(io.github.cl-sdk.wst.rate-limit.store:fetch-window store :client-a)
71+
(5am:is-false count)
72+
(5am:is-false start))))))
73+
74+
(5am:def-test redis-rate-limit-store-applies-expiry-when-configured ()
75+
(let ((records (make-hash-table :test #'equal))
76+
(expiries (make-hash-table :test #'equal)))
77+
(with-mocked-rate-limit-redis (records expiries)
78+
(let ((store (make-instance 'io.github.cl-sdk.wst.rate-limit.redis-store:redis-store
79+
:window-seconds 42)))
80+
(io.github.cl-sdk.wst.rate-limit.store:save-window store :client-b 1 2000)
81+
(5am:is (= 42 (gethash "wst:rate-limit::CLIENT-B" expiries)))))))

0 commit comments

Comments
 (0)