Liking cljdoc? Tell your friends :D

sturdy-throttle

Clojars Project

sturdy-throttle is a two-tiered rate limiting library for Clojure ring apps. It is designed to provide both DoS protection using a fast in-memory atom, and structured API quota enforcement using a SQLite table.

Overview

  1. Pre-Authentication (IP Limiting): A fast, in-memory IP limiter using a fixed time-window. Automatically sweeps and creates zero-garbage, returning empty 429 Too Many Requests responses to save bandwidth against DoS attacks.
  2. Post-Authentication (Quota Enforcement): A batched SQLite writer that manages approximate rolling quotas based on organization_id and an optional rate_key. The quota window is configurable and defaults to one hour. It automatically migrates the required schema on startup and prunes old buckets in the background.

SQLite Quota Window Semantics

The SQLite limiter deliberately approximates a rolling window with wall-clock-aligned, one-minute buckets. It rounds :window-ms up to a whole number of minute buckets and sums the current bucket plus the preceding live buckets. Consequently, enforcement boundaries can differ from an exact per-request rolling window by less than one minute. Sub-minute windows use one calendar-minute bucket, and traffic can burst when an old bucket leaves the window at a minute boundary.

This is an intentional performance tradeoff. Aggregating requests into one row per organization, rate key, and minute keeps the table and write volume small, and allows quota checking and incrementing to remain a single atomic SQL statement under load. Use this limiter when minute-level approximation is acceptable rather than when the quota contract requires exact elapsed-time semantics.

Pruning is scoped to the rate key that triggers it, so different rate keys may safely use different windows in the same database. The same rate_key must not simultaneously be used by two limiters with different windows in one database; their quota buckets intentionally share the same key space. When changing a rate key to a longer window, buckets already removed under the shorter policy cannot be recovered, so the longer window has a warm-up period while new history accumulates.

Installation

Add to deps.edn:

{:deps com.sturdystats/sturdy-throttle {:mvn/version "VERSION"}}

Reitit / Ring Application Integration

1. Server Startup

Initialize rate limiters during your application's startup sequence and store them in your system state.

(ns my-app.server
  (:require [sturdy.throttle.memory :as memory]
            [sturdy.throttle.sqlite :as sqlite]))

(defn start-limiters [_config]
  ;; 1. The in-memory IP limiter
  ;; E.g., Max 50 requests per second per IP
  (let [ip-limiter (memory/make-ip-limiter {:limit-per-second 50
                                            :window-ms 1000})]

    ;; 2. The SQLite-backed quota limiter
    ;; E.g., Max 25,000 requests per hour per org
    (let [quota-limiter (sqlite/make-quota-limiter {:db-name "rate-limits"
                                                    :db-dir "/var/lib/my-app/limits"
                                                    :limit 25000
                                                    :window-ms 3600000
                                                    :prune-every 1000})]
      {:ip-limiter ip-limiter
       :quota-limiter quota-limiter})))

2. Adding Middleware to Reitit

You can wrap your routes using the provided middleware functions in sturdy.throttle. wrap-ip-rate-limit should be applied to the entire router (pre-auth), while wrap-quota-rate-limit should apply to to specific protected routes (post-auth).

(ns my-app.routes
  (:require [reitit.ring :as ring]
            [sturdy.throttle :as throttle]))

(defn app-router [{:keys [ip-limiter quota-limiter]}]
  (ring/ring-handler
   (ring/router
    ["/api"
     ;; --- POST-AUTH QUOTA LIMITER ---
     ;; Apply quota limiting to all authenticated /api routes
     {:middleware [[throttle/wrap-quota-rate-limit
                    quota-limiter
                    (fn [req]
                      ;; Extract your org-id from the session/token.
                      ;; You can also include a :rate-key to target specific endpoints.
                      {:org-id (get-in req [:identity :org_id])
                       :rate-key "api-general"})
                    ;; Optional custom error response
                    {:status 429
                     :headers {"Content-Type" "application/json"}
                     :body "{\"error\": \"Hourly quota exceeded.\"}"}]]}

     ["/v1/resource" {:get handler}]]

    {:data
     ;; --- PRE-AUTH IP LIMITER ---
     ;; Apply IP rate limiting globally across the entire router
     {:middleware [[throttle/wrap-ip-rate-limit
                    ip-limiter
                    (fn [req]
                      ;; Only trust an IP header set or sanitized by your own
                      ;; reverse proxy (for example, Nginx's X-Real-IP).
                      ;; Never trust a client-controlled forwarding header.
                      (or (get-in req [:headers "x-real-ip"])
                          (:remote-addr req)))]]}})))

3. Server Shutdown

Close the SQLite limiter in your server shutdown sequence. This closes the SQLite connection cleanly.

(defn stop-limiters [{:keys [quota-limiter]}]
  (when quota-limiter
    (sqlite/close-limiter quota-limiter)))

Can you improve this documentation?Edit on GitHub

cljdoc builds & hosts documentation for Clojure/Script libraries

Keyboard shortcuts
Ctrl+kJump to recent docs
Move to previous article
Move to next article
Ctrl+/Jump to the search field
× close