feat: add limit extension macros for define-page and define-api

This commit is contained in:
Luis Pereira 2025-12-26 11:18:10 +00:00 committed by Brian O'Reilly
parent 1a39e0c6d2
commit 8ae905a2c1
3 changed files with 115 additions and 0 deletions

View File

@ -36,6 +36,7 @@
:r-data-model :r-data-model
(:interface :auth) (:interface :auth)
(:interface :database) (:interface :database)
(:interface :rate)
(:interface :user)) (:interface :user))
:pathname "./" :pathname "./"
:components ((:file "app-utils") :components ((:file "app-utils")
@ -43,6 +44,7 @@
(:module :config (:module :config
:components ((:file radiance-postgres))) :components ((:file radiance-postgres)))
(:file "conditions") (:file "conditions")
(:file "limiter")
(:file "database") (:file "database")
(:file "template-utils") (:file "template-utils")
(:file "parenscript-utils") (:file "parenscript-utils")

56
limiter.lisp Normal file
View File

@ -0,0 +1,56 @@
;;;; limiter.lisp - Rate limiter definitions for the application
(in-package :asteroid)
(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."
(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-sym (intern limit-name))
(limit (or limit 30))
(timeout (or timeout 60)))
`(eval-when (:compile-toplevel :load-toplevel :execute)
(rate:define-limit ,limit-sym (time-left :limit ,limit :timeout ,timeout)
;; (format t "Route limit '~a' hit. Wait ~a seconds and retry.~%" ,(string name) time-left)
(render-rate-limit-error-page))
(define-page ,name ,uri ,rest
(rate:with-limitation (,limit-sym)
,@body))))))
(defmacro define-api-with-limit (name args options &body body)
"Rate limit for api routes. Defaults to 60 requests per minute."
(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-sym (intern limit-name))
(limit (or limit 60))
(timeout (or timeout 60)))
`(eval-when (:compile-toplevel :load-toplevel :execute)
(rate:define-limit ,limit-sym (time-left :limit ,limit :timeout ,timeout)
;; (format t "API Rate limit '~a' hit. Wait ~a seconds and retry.~%" ,(string name) time-left)
(api-limit-error-output))
(define-api ,name ,args ,rest
(rate:with-limitation (,limit-sym)
,@body))))))

57
template/error.ctml Normal file
View File

@ -0,0 +1,57 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Error - Asteroid Radio</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="/asteroid/static/favicon.ico">
<link rel="icon" type="image/png" sizes="32x32" href="/asteroid/static/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/asteroid/static/favicon-16x16.png">
<link rel="stylesheet" type="text/css" href="/asteroid/static/asteroid.css">
</head>
<body>
<div class="container">
<header>
<h1 style="display: flex; align-items: center; justify-content: center; gap: 15px;">
<img src="/asteroid/static/asteroid.png" alt="Asteroid" style="height: 50px; width: auto;">
<span>ASTEROID RADIO</span>
<img src="/asteroid/static/asteroid.png" alt="Asteroid" style="height: 50px; width: auto;">
</h1>
<nav class="nav">
<a href="/asteroid/">Home</a>
<a href="/asteroid/player">Player</a>
<a href="/asteroid/about">About</a>
</nav>
</header>
<main style="max-width: 800px; margin: 0 auto; padding: 20px;">
<section style="margin-bottom: 30px;">
<h2 style="color: #00ff00; border-bottom: 2px solid #00ff00; padding-bottom: 10px;">
<c:if test="error-title">
<c:then>
<c:splice lquery="(text error-title)"></c:splice>
</c:then>
<c:else>
⚠️ Something went wrong with your request!
</c:else>
</c:if>
</h2>
<p style="line-height: 1.6; font-size: 1.2rem;">
<c:if test="error-message">
<c:then>
<c:splice lquery="(text error-message)"></c:splice>
</c:then>
<c:else>
We seem to be unable to process your request right now.
</c:else>
</c:if>
</p>
<c:if test="error-action">
<c:then>
<p style="line-height: 1.6; font-size: 1.2rem;" lquery="(text error-action)"></p>
</c:then>
</c:if>
</section>
</main>
</div>
</body>
</html>