···11+MIT License
22+33+Copyright (c) 2025 Thomas Gazagnaire
44+55+Permission is hereby granted, free of charge, to any person obtaining a copy
66+of this software and associated documentation files (the "Software"), to deal
77+in the Software without restriction, including without limitation the rights
88+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
99+copies of the Software, and to permit persons to whom the Software is
1010+furnished to do so, subject to the following conditions:
1111+1212+The above copyright notice and this permission notice shall be included in all
1313+copies or substantial portions of the Software.
1414+1515+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
1616+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
1717+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
1818+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
1919+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
2020+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
2121+SOFTWARE.
+66
README.md
···11+# rate-limit
22+33+Per-IP sliding window rate limiter with Eio support.
44+55+## Overview
66+77+Thread-safe per-client rate limiting using the sliding window algorithm.
88+Multi-domain safe with `Eio.Mutex` for concurrent access from multiple
99+OCaml 5 domains.
1010+1111+## Features
1212+1313+- Sliding window rate limiting (more accurate than fixed windows)
1414+- Per-client (IP) tracking
1515+- Thread-safe with Eio.Mutex
1616+- Configurable window size and request limit
1717+- Retry-after calculation for 429 responses
1818+- Automatic cleanup of expired entries
1919+2020+## Installation
2121+2222+```
2323+opam install rate-limit
2424+```
2525+2626+## Usage
2727+2828+```ocaml
2929+(* Create a rate limiter: 100 requests per 60 seconds *)
3030+let limiter = Rate_limit.create ~max_requests:100 ~window_seconds:60.0 ()
3131+3232+let handle_request ~ip =
3333+ let now = Unix.gettimeofday () in
3434+ match Rate_limit.check_and_record limiter ~ip ~now with
3535+ | true, remaining ->
3636+ (* Request allowed *)
3737+ Printf.printf "Allowed, %d requests remaining\n" remaining;
3838+ process_request ()
3939+ | false, _ ->
4040+ (* Rate limited - calculate retry-after *)
4141+ let retry = Rate_limit.retry_after limiter ~ip ~now in
4242+ Printf.printf "Rate limited, retry after %.0f seconds\n" retry;
4343+ respond_429 ~retry_after:(int_of_float retry)
4444+```
4545+4646+## API
4747+4848+- `Rate_limit.create` - Create a new rate limiter
4949+- `Rate_limit.check_and_record` - Check if request allowed and record it
5050+- `Rate_limit.retry_after` - Calculate seconds until limit resets
5151+- `Rate_limit.current_count` - Get current request count for an IP
5252+- `Rate_limit.stats` - Get (tracked_ips, total_requests) statistics
5353+5454+## Standards
5555+5656+- [RFC 6585](https://datatracker.ietf.org/doc/html/rfc6585) - Additional HTTP
5757+ Status Codes (429 Too Many Requests)
5858+5959+## Related Work
6060+6161+- No standalone rate limiting packages found in opam
6262+- This implementation is Eio-native with minimal dependencies
6363+6464+## License
6565+6666+MIT License. See [LICENSE.md](LICENSE.md) for details.
···11+(** Per-client rate limiting using sliding window algorithm.
22+33+ Multi-domain safe: Uses Eio.Mutex to protect Hashtbl operations, allowing
44+ safe concurrent access from multiple OCaml 5 domains. *)
55+66+type t = {
77+ max_requests : int; (** Maximum requests per window *)
88+ window_seconds : float; (** Time window in seconds *)
99+ requests : (string, float list) Hashtbl.t;
1010+ (** IP -> list of request timestamps *)
1111+ mutable last_cleanup : float; (** Last cleanup time *)
1212+ mutex : Eio.Mutex.t; (** Protects all mutable state for multi-domain safety *)
1313+}
1414+1515+let create ?(max_requests = 100) ?(window_seconds = 60.0) () =
1616+ {
1717+ max_requests;
1818+ window_seconds;
1919+ requests = Hashtbl.create 1000;
2020+ last_cleanup = 0.0;
2121+ mutex = Eio.Mutex.create ();
2222+ }
2323+2424+(** Remove expired timestamps from a list *)
2525+let prune_old_requests now window timestamps =
2626+ let cutoff = now -. window in
2727+ List.filter (fun ts -> ts > cutoff) timestamps
2828+2929+(** Cleanup old entries periodically (every 5 minutes) *)
3030+let maybe_cleanup t now =
3131+ if now -. t.last_cleanup > 300.0 then (
3232+ t.last_cleanup <- now;
3333+ let cutoff = now -. t.window_seconds in
3434+ Hashtbl.filter_map_inplace
3535+ (fun _ip timestamps ->
3636+ let remaining = List.filter (fun ts -> ts > cutoff) timestamps in
3737+ if remaining = [] then None else Some remaining)
3838+ t.requests)
3939+4040+let check_and_record t ~ip ~now =
4141+ Eio.Mutex.use_rw ~protect:true t.mutex (fun () ->
4242+ maybe_cleanup t now;
4343+ let existing =
4444+ match Hashtbl.find_opt t.requests ip with
4545+ | None -> []
4646+ | Some ts -> prune_old_requests now t.window_seconds ts
4747+ in
4848+ let count = List.length existing in
4949+ if count >= t.max_requests then (false, 0)
5050+ else
5151+ let updated = now :: existing in
5252+ Hashtbl.replace t.requests ip updated;
5353+ (true, t.max_requests - count - 1))
5454+5555+let retry_after t ~ip ~now =
5656+ Eio.Mutex.use_ro t.mutex (fun () ->
5757+ match Hashtbl.find_opt t.requests ip with
5858+ | None -> 0.0
5959+ | Some timestamps ->
6060+ let pruned = prune_old_requests now t.window_seconds timestamps in
6161+ if List.length pruned < t.max_requests then 0.0
6262+ else
6363+ let oldest = List.fold_left (fun acc ts -> min acc ts) now pruned in
6464+ let elapsed = now -. oldest in
6565+ max 0.0 (t.window_seconds -. elapsed))
6666+6767+let current_count t ~ip ~now =
6868+ Eio.Mutex.use_ro t.mutex (fun () ->
6969+ match Hashtbl.find_opt t.requests ip with
7070+ | None -> 0
7171+ | Some timestamps ->
7272+ let pruned = prune_old_requests now t.window_seconds timestamps in
7373+ List.length pruned)
7474+7575+let stats t =
7676+ Eio.Mutex.use_ro t.mutex (fun () ->
7777+ let total_ips = Hashtbl.length t.requests in
7878+ let total_requests =
7979+ Hashtbl.fold
8080+ (fun _ timestamps acc -> acc + List.length timestamps)
8181+ t.requests 0
8282+ in
8383+ (total_ips, total_requests))
+66
lib/rate_limit.mli
···11+(** Per-client rate limiting using sliding window algorithm.
22+33+ Implements per-IP rate limiting with a sliding time window. Multi-domain
44+ safe: uses [Eio.Mutex] to protect all mutable state, allowing safe
55+ concurrent access from multiple OCaml 5 domains.
66+77+ {2 Standards}
88+99+ - {{:https://datatracker.ietf.org/doc/html/rfc6585} RFC 6585} - Additional
1010+ HTTP Status Codes (429 Too Many Requests)
1111+1212+ {2 Example}
1313+1414+ {[
1515+ let limiter = Rate_limit.create ~max_requests:100 ~window_seconds:60.0 ()
1616+1717+ let handle_request ~ip ~now =
1818+ match Rate_limit.check_and_record limiter ~ip ~now with
1919+ | true, remaining ->
2020+ (* Request allowed, remaining requests in window *)
2121+ process_request ()
2222+ | false, _ ->
2323+ (* Rate limited *)
2424+ let retry = Rate_limit.retry_after limiter ~ip ~now in
2525+ respond_429 ~retry_after:retry
2626+ ]} *)
2727+2828+type t
2929+(** Rate limiter state. *)
3030+3131+val create : ?max_requests:int -> ?window_seconds:float -> unit -> t
3232+(** [create ?max_requests ?window_seconds ()] creates a new rate limiter.
3333+3434+ @param max_requests Maximum requests allowed per window (default: 100)
3535+ @param window_seconds Time window in seconds (default: 60.0) *)
3636+3737+val check_and_record : t -> ip:string -> now:float -> bool * int
3838+(** [check_and_record t ~ip ~now] checks if a request is allowed and records it.
3939+4040+ Thread-safe: uses mutex to protect Hashtbl access.
4141+4242+ @param ip Client identifier (typically IP address)
4343+ @param now Current timestamp (e.g., from [Unix.gettimeofday ()])
4444+ @return
4545+ [(allowed, remaining)] where:
4646+ - [allowed]: [true] if request is within rate limit
4747+ - [remaining]: number of requests remaining in current window *)
4848+4949+val retry_after : t -> ip:string -> now:float -> float
5050+(** [retry_after t ~ip ~now] calculates retry-after time in seconds.
5151+5252+ Thread-safe: uses mutex for read access.
5353+5454+ @return Seconds until rate limit resets, or [0.0] if not rate-limited *)
5555+5656+val current_count : t -> ip:string -> now:float -> int
5757+(** [current_count t ~ip ~now] returns current request count for an IP.
5858+5959+ Thread-safe: uses mutex for read access. *)
6060+6161+val stats : t -> int * int
6262+(** [stats t] returns statistics about the rate limiter.
6363+6464+ Thread-safe: uses mutex for read access.
6565+6666+ @return [(total_tracked_ips, total_requests)] *)