Per-IP sliding window rate limiter
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Squashed 'ocaml-rate-limit/' content from commit c387f820 git-subtree-split: c387f82059d490dc29632368f2f03ec247fa4f08

+532
+17
.gitignore
··· 1 + # OCaml build artifacts 2 + _build/ 3 + *.install 4 + *.merlin 5 + 6 + # Dune package management 7 + dune.lock/ 8 + 9 + # Editor and OS files 10 + .DS_Store 11 + *.swp 12 + *~ 13 + .vscode/ 14 + .idea/ 15 + 16 + # Opam local switch 17 + _opam/
+1
.ocamlformat
··· 1 + version = 0.28.1
+21
LICENSE.md
··· 1 + MIT License 2 + 3 + Copyright (c) 2025 Thomas Gazagnaire 4 + 5 + Permission is hereby granted, free of charge, to any person obtaining a copy 6 + of this software and associated documentation files (the "Software"), to deal 7 + in the Software without restriction, including without limitation the rights 8 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 + copies of the Software, and to permit persons to whom the Software is 10 + furnished to do so, subject to the following conditions: 11 + 12 + The above copyright notice and this permission notice shall be included in all 13 + copies or substantial portions of the Software. 14 + 15 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 + SOFTWARE.
+66
README.md
··· 1 + # rate-limit 2 + 3 + Per-IP sliding window rate limiter with Eio support. 4 + 5 + ## Overview 6 + 7 + Thread-safe per-client rate limiting using the sliding window algorithm. 8 + Multi-domain safe with `Eio.Mutex` for concurrent access from multiple 9 + OCaml 5 domains. 10 + 11 + ## Features 12 + 13 + - Sliding window rate limiting (more accurate than fixed windows) 14 + - Per-client (IP) tracking 15 + - Thread-safe with Eio.Mutex 16 + - Configurable window size and request limit 17 + - Retry-after calculation for 429 responses 18 + - Automatic cleanup of expired entries 19 + 20 + ## Installation 21 + 22 + ``` 23 + opam install rate-limit 24 + ``` 25 + 26 + ## Usage 27 + 28 + ```ocaml 29 + (* Create a rate limiter: 100 requests per 60 seconds *) 30 + let limiter = Rate_limit.create ~max_requests:100 ~window_seconds:60.0 () 31 + 32 + let handle_request ~ip = 33 + let now = Unix.gettimeofday () in 34 + match Rate_limit.check_and_record limiter ~ip ~now with 35 + | true, remaining -> 36 + (* Request allowed *) 37 + Printf.printf "Allowed, %d requests remaining\n" remaining; 38 + process_request () 39 + | false, _ -> 40 + (* Rate limited - calculate retry-after *) 41 + let retry = Rate_limit.retry_after limiter ~ip ~now in 42 + Printf.printf "Rate limited, retry after %.0f seconds\n" retry; 43 + respond_429 ~retry_after:(int_of_float retry) 44 + ``` 45 + 46 + ## API 47 + 48 + - `Rate_limit.create` - Create a new rate limiter 49 + - `Rate_limit.check_and_record` - Check if request allowed and record it 50 + - `Rate_limit.retry_after` - Calculate seconds until limit resets 51 + - `Rate_limit.current_count` - Get current request count for an IP 52 + - `Rate_limit.stats` - Get (tracked_ips, total_requests) statistics 53 + 54 + ## Standards 55 + 56 + - [RFC 6585](https://datatracker.ietf.org/doc/html/rfc6585) - Additional HTTP 57 + Status Codes (429 Too Many Requests) 58 + 59 + ## Related Work 60 + 61 + - No standalone rate limiting packages found in opam 62 + - This implementation is Eio-native with minimal dependencies 63 + 64 + ## License 65 + 66 + MIT License. See [LICENSE.md](LICENSE.md) for details.
+21
dune-project
··· 1 + (lang dune 3.0) 2 + (name rate-limit) 3 + 4 + (generate_opam_files true) 5 + 6 + (source (github samoht/ocaml-rate-limit)) 7 + (license MIT) 8 + (authors "Thomas Gazagnaire <thomas@gazagnaire.org>") 9 + (maintainers "Thomas Gazagnaire <thomas@gazagnaire.org>") 10 + 11 + (package 12 + (name rate-limit) 13 + (synopsis "Per-IP sliding window rate limiter with Eio support") 14 + (description "Thread-safe per-client rate limiting using sliding window algorithm. Implements RFC 6585 429 responses with retry-after calculation. Multi-domain safe with Eio.Mutex for concurrent access.") 15 + (depends 16 + (ocaml (>= 4.08)) 17 + (dune (>= 3.0)) 18 + (eio (>= 1.0)) 19 + (alcotest :with-test) 20 + (crowbar :with-test) 21 + (odoc :with-doc)))
+11
fuzz/dune
··· 1 + ; Fuzz test - run with: dune build @fuzz 2 + 3 + (executable 4 + (name fuzz_rate_limit) 5 + (libraries rate-limit crowbar eio_main)) 6 + 7 + (rule 8 + (alias fuzz) 9 + (deps fuzz_rate_limit.exe) 10 + (action 11 + (run %{exe:fuzz_rate_limit.exe})))
+78
fuzz/fuzz_rate_limit.ml
··· 1 + (** Fuzz tests for Rate_limit module *) 2 + 3 + open Crowbar 4 + 5 + (** Test invariant: remaining count is always non-negative and <= max_requests 6 + *) 7 + let test_remaining_bounds max_requests ip = 8 + if max_requests <= 0 then bad_test () 9 + else 10 + Eio_main.run @@ fun _env -> 11 + let limiter = Rate_limit.create ~max_requests ~window_seconds:60.0 () in 12 + let _allowed, remaining = 13 + Rate_limit.check_and_record limiter ~ip ~now:0.0 14 + in 15 + check (remaining >= 0); 16 + check (remaining < max_requests) 17 + 18 + (** Test invariant: after max_requests, next request is blocked *) 19 + let test_blocking max_requests ip = 20 + if max_requests <= 0 || max_requests > 100 then bad_test () 21 + else 22 + Eio_main.run @@ fun _env -> 23 + let limiter = Rate_limit.create ~max_requests ~window_seconds:60.0 () in 24 + (* Exhaust the limit *) 25 + for i = 0 to max_requests - 1 do 26 + let _ = Rate_limit.check_and_record limiter ~ip ~now:(Float.of_int i) in 27 + () 28 + done; 29 + (* Next should be blocked *) 30 + let allowed, _ = 31 + Rate_limit.check_and_record limiter ~ip ~now:(Float.of_int max_requests) 32 + in 33 + check (not allowed) 34 + 35 + (** Test invariant: current_count matches actual recorded requests *) 36 + let test_count_consistency ip num_requests = 37 + if num_requests < 0 || num_requests > 50 then bad_test () 38 + else 39 + Eio_main.run @@ fun _env -> 40 + let limiter = Rate_limit.create ~max_requests:100 ~window_seconds:60.0 () in 41 + for i = 0 to num_requests - 1 do 42 + let _ = Rate_limit.check_and_record limiter ~ip ~now:(Float.of_int i) in 43 + () 44 + done; 45 + let count = 46 + Rate_limit.current_count limiter ~ip ~now:(Float.of_int num_requests) 47 + in 48 + check_eq ~pp:Format.pp_print_int num_requests count 49 + 50 + (** Test invariant: retry_after is 0 when not rate limited *) 51 + let test_retry_after_zero ip = 52 + if String.length ip = 0 then bad_test () 53 + else 54 + Eio_main.run @@ fun _env -> 55 + let limiter = Rate_limit.create ~max_requests:100 ~window_seconds:60.0 () in 56 + let _ = Rate_limit.check_and_record limiter ~ip ~now:0.0 in 57 + let retry = Rate_limit.retry_after limiter ~ip ~now:1.0 in 58 + check (retry = 0.0) 59 + 60 + (** Test invariant: stats counts are non-negative *) 61 + let test_stats_non_negative ip1 ip2 = 62 + if String.length ip1 = 0 || String.length ip2 = 0 then bad_test () 63 + else 64 + Eio_main.run @@ fun _env -> 65 + let limiter = Rate_limit.create ~max_requests:10 ~window_seconds:60.0 () in 66 + let _ = Rate_limit.check_and_record limiter ~ip:ip1 ~now:0.0 in 67 + let _ = Rate_limit.check_and_record limiter ~ip:ip2 ~now:1.0 in 68 + let ips, requests = Rate_limit.stats limiter in 69 + check (ips >= 0); 70 + check (requests >= 0) 71 + 72 + let () = 73 + add_test ~name:"remaining bounds" [ range 100; bytes ] test_remaining_bounds; 74 + add_test ~name:"blocking after max" [ range 50; bytes ] test_blocking; 75 + add_test ~name:"count consistency" [ bytes; range 50 ] test_count_consistency; 76 + add_test ~name:"retry_after zero when not limited" [ bytes ] 77 + test_retry_after_zero; 78 + add_test ~name:"stats non-negative" [ bytes; bytes ] test_stats_non_negative
+4
lib/dune
··· 1 + (library 2 + (name rate_limit) 3 + (public_name rate-limit) 4 + (libraries eio))
+83
lib/rate_limit.ml
··· 1 + (** Per-client rate limiting using sliding window algorithm. 2 + 3 + Multi-domain safe: Uses Eio.Mutex to protect Hashtbl operations, allowing 4 + safe concurrent access from multiple OCaml 5 domains. *) 5 + 6 + type t = { 7 + max_requests : int; (** Maximum requests per window *) 8 + window_seconds : float; (** Time window in seconds *) 9 + requests : (string, float list) Hashtbl.t; 10 + (** IP -> list of request timestamps *) 11 + mutable last_cleanup : float; (** Last cleanup time *) 12 + mutex : Eio.Mutex.t; (** Protects all mutable state for multi-domain safety *) 13 + } 14 + 15 + let create ?(max_requests = 100) ?(window_seconds = 60.0) () = 16 + { 17 + max_requests; 18 + window_seconds; 19 + requests = Hashtbl.create 1000; 20 + last_cleanup = 0.0; 21 + mutex = Eio.Mutex.create (); 22 + } 23 + 24 + (** Remove expired timestamps from a list *) 25 + let prune_old_requests now window timestamps = 26 + let cutoff = now -. window in 27 + List.filter (fun ts -> ts > cutoff) timestamps 28 + 29 + (** Cleanup old entries periodically (every 5 minutes) *) 30 + let maybe_cleanup t now = 31 + if now -. t.last_cleanup > 300.0 then ( 32 + t.last_cleanup <- now; 33 + let cutoff = now -. t.window_seconds in 34 + Hashtbl.filter_map_inplace 35 + (fun _ip timestamps -> 36 + let remaining = List.filter (fun ts -> ts > cutoff) timestamps in 37 + if remaining = [] then None else Some remaining) 38 + t.requests) 39 + 40 + let check_and_record t ~ip ~now = 41 + Eio.Mutex.use_rw ~protect:true t.mutex (fun () -> 42 + maybe_cleanup t now; 43 + let existing = 44 + match Hashtbl.find_opt t.requests ip with 45 + | None -> [] 46 + | Some ts -> prune_old_requests now t.window_seconds ts 47 + in 48 + let count = List.length existing in 49 + if count >= t.max_requests then (false, 0) 50 + else 51 + let updated = now :: existing in 52 + Hashtbl.replace t.requests ip updated; 53 + (true, t.max_requests - count - 1)) 54 + 55 + let retry_after t ~ip ~now = 56 + Eio.Mutex.use_ro t.mutex (fun () -> 57 + match Hashtbl.find_opt t.requests ip with 58 + | None -> 0.0 59 + | Some timestamps -> 60 + let pruned = prune_old_requests now t.window_seconds timestamps in 61 + if List.length pruned < t.max_requests then 0.0 62 + else 63 + let oldest = List.fold_left (fun acc ts -> min acc ts) now pruned in 64 + let elapsed = now -. oldest in 65 + max 0.0 (t.window_seconds -. elapsed)) 66 + 67 + let current_count t ~ip ~now = 68 + Eio.Mutex.use_ro t.mutex (fun () -> 69 + match Hashtbl.find_opt t.requests ip with 70 + | None -> 0 71 + | Some timestamps -> 72 + let pruned = prune_old_requests now t.window_seconds timestamps in 73 + List.length pruned) 74 + 75 + let stats t = 76 + Eio.Mutex.use_ro t.mutex (fun () -> 77 + let total_ips = Hashtbl.length t.requests in 78 + let total_requests = 79 + Hashtbl.fold 80 + (fun _ timestamps acc -> acc + List.length timestamps) 81 + t.requests 0 82 + in 83 + (total_ips, total_requests))
+66
lib/rate_limit.mli
··· 1 + (** Per-client rate limiting using sliding window algorithm. 2 + 3 + Implements per-IP rate limiting with a sliding time window. Multi-domain 4 + safe: uses [Eio.Mutex] to protect all mutable state, allowing safe 5 + concurrent access from multiple OCaml 5 domains. 6 + 7 + {2 Standards} 8 + 9 + - {{:https://datatracker.ietf.org/doc/html/rfc6585} RFC 6585} - Additional 10 + HTTP Status Codes (429 Too Many Requests) 11 + 12 + {2 Example} 13 + 14 + {[ 15 + let limiter = Rate_limit.create ~max_requests:100 ~window_seconds:60.0 () 16 + 17 + let handle_request ~ip ~now = 18 + match Rate_limit.check_and_record limiter ~ip ~now with 19 + | true, remaining -> 20 + (* Request allowed, remaining requests in window *) 21 + process_request () 22 + | false, _ -> 23 + (* Rate limited *) 24 + let retry = Rate_limit.retry_after limiter ~ip ~now in 25 + respond_429 ~retry_after:retry 26 + ]} *) 27 + 28 + type t 29 + (** Rate limiter state. *) 30 + 31 + val create : ?max_requests:int -> ?window_seconds:float -> unit -> t 32 + (** [create ?max_requests ?window_seconds ()] creates a new rate limiter. 33 + 34 + @param max_requests Maximum requests allowed per window (default: 100) 35 + @param window_seconds Time window in seconds (default: 60.0) *) 36 + 37 + val check_and_record : t -> ip:string -> now:float -> bool * int 38 + (** [check_and_record t ~ip ~now] checks if a request is allowed and records it. 39 + 40 + Thread-safe: uses mutex to protect Hashtbl access. 41 + 42 + @param ip Client identifier (typically IP address) 43 + @param now Current timestamp (e.g., from [Unix.gettimeofday ()]) 44 + @return 45 + [(allowed, remaining)] where: 46 + - [allowed]: [true] if request is within rate limit 47 + - [remaining]: number of requests remaining in current window *) 48 + 49 + val retry_after : t -> ip:string -> now:float -> float 50 + (** [retry_after t ~ip ~now] calculates retry-after time in seconds. 51 + 52 + Thread-safe: uses mutex for read access. 53 + 54 + @return Seconds until rate limit resets, or [0.0] if not rate-limited *) 55 + 56 + val current_count : t -> ip:string -> now:float -> int 57 + (** [current_count t ~ip ~now] returns current request count for an IP. 58 + 59 + Thread-safe: uses mutex for read access. *) 60 + 61 + val stats : t -> int * int 62 + (** [stats t] returns statistics about the rate limiter. 63 + 64 + Thread-safe: uses mutex for read access. 65 + 66 + @return [(total_tracked_ips, total_requests)] *)
+33
rate-limit.opam
··· 1 + # This file is generated by dune, edit dune-project instead 2 + opam-version: "2.0" 3 + synopsis: "Per-IP sliding window rate limiter with Eio support" 4 + description: 5 + "Thread-safe per-client rate limiting using sliding window algorithm. Implements RFC 6585 429 responses with retry-after calculation. Multi-domain safe with Eio.Mutex for concurrent access." 6 + maintainer: ["Thomas Gazagnaire <thomas@gazagnaire.org>"] 7 + authors: ["Thomas Gazagnaire <thomas@gazagnaire.org>"] 8 + license: "MIT" 9 + homepage: "https://github.com/samoht/ocaml-rate-limit" 10 + bug-reports: "https://github.com/samoht/ocaml-rate-limit/issues" 11 + depends: [ 12 + "ocaml" {>= "4.08"} 13 + "dune" {>= "3.0" & >= "3.0"} 14 + "eio" {>= "1.0"} 15 + "alcotest" {with-test} 16 + "crowbar" {with-test} 17 + "odoc" {with-doc} 18 + ] 19 + build: [ 20 + ["dune" "subst"] {dev} 21 + [ 22 + "dune" 23 + "build" 24 + "-p" 25 + name 26 + "-j" 27 + jobs 28 + "@install" 29 + "@runtest" {with-test} 30 + "@doc" {with-doc} 31 + ] 32 + ] 33 + dev-repo: "git+https://github.com/samoht/ocaml-rate-limit.git"
+3
test/dune
··· 1 + (test 2 + (name test_rate_limit) 3 + (libraries rate-limit alcotest eio_main))
+128
test/test_rate_limit.ml
··· 1 + (** Tests for Rate_limit module *) 2 + 3 + let test_basic_allow () = 4 + Eio_main.run @@ fun _env -> 5 + let limiter = Rate_limit.create ~max_requests:5 ~window_seconds:60.0 () in 6 + let now = 1000.0 in 7 + let allowed, remaining = 8 + Rate_limit.check_and_record limiter ~ip:"1.2.3.4" ~now 9 + in 10 + Alcotest.(check bool) "first request allowed" true allowed; 11 + Alcotest.(check int) "4 remaining" 4 remaining 12 + 13 + let test_rate_limit_exceeded () = 14 + Eio_main.run @@ fun _env -> 15 + let limiter = Rate_limit.create ~max_requests:3 ~window_seconds:60.0 () in 16 + let now = 1000.0 in 17 + let ip = "1.2.3.4" in 18 + (* Make 3 requests *) 19 + let _ = Rate_limit.check_and_record limiter ~ip ~now in 20 + let _ = Rate_limit.check_and_record limiter ~ip ~now:(now +. 1.0) in 21 + let _ = Rate_limit.check_and_record limiter ~ip ~now:(now +. 2.0) in 22 + (* 4th should be blocked *) 23 + let allowed, remaining = 24 + Rate_limit.check_and_record limiter ~ip ~now:(now +. 3.0) 25 + in 26 + Alcotest.(check bool) "4th request blocked" false allowed; 27 + Alcotest.(check int) "0 remaining" 0 remaining 28 + 29 + let test_window_expiry () = 30 + Eio_main.run @@ fun _env -> 31 + let limiter = Rate_limit.create ~max_requests:2 ~window_seconds:10.0 () in 32 + let ip = "1.2.3.4" in 33 + (* Make 2 requests at t=0 *) 34 + let _ = Rate_limit.check_and_record limiter ~ip ~now:0.0 in 35 + let _ = Rate_limit.check_and_record limiter ~ip ~now:1.0 in 36 + (* 3rd blocked at t=5 *) 37 + let allowed1, _ = Rate_limit.check_and_record limiter ~ip ~now:5.0 in 38 + Alcotest.(check bool) "3rd blocked within window" false allowed1; 39 + (* After window expires (t=11), should be allowed again *) 40 + let allowed2, remaining = Rate_limit.check_and_record limiter ~ip ~now:11.0 in 41 + Alcotest.(check bool) "allowed after window expires" true allowed2; 42 + Alcotest.(check int) "1 remaining after expiry" 1 remaining 43 + 44 + let test_different_ips () = 45 + Eio_main.run @@ fun _env -> 46 + let limiter = Rate_limit.create ~max_requests:2 ~window_seconds:60.0 () in 47 + let now = 1000.0 in 48 + (* Exhaust limit for IP1 *) 49 + let _ = Rate_limit.check_and_record limiter ~ip:"1.1.1.1" ~now in 50 + let _ = Rate_limit.check_and_record limiter ~ip:"1.1.1.1" ~now in 51 + let blocked, _ = Rate_limit.check_and_record limiter ~ip:"1.1.1.1" ~now in 52 + Alcotest.(check bool) "IP1 blocked" false blocked; 53 + (* IP2 should still be allowed *) 54 + let allowed, remaining = 55 + Rate_limit.check_and_record limiter ~ip:"2.2.2.2" ~now 56 + in 57 + Alcotest.(check bool) "IP2 allowed" true allowed; 58 + Alcotest.(check int) "IP2 has 1 remaining" 1 remaining 59 + 60 + let test_retry_after () = 61 + Eio_main.run @@ fun _env -> 62 + let limiter = Rate_limit.create ~max_requests:2 ~window_seconds:60.0 () in 63 + let ip = "1.2.3.4" in 64 + (* Make requests at t=0 and t=10 *) 65 + let _ = Rate_limit.check_and_record limiter ~ip ~now:0.0 in 66 + let _ = Rate_limit.check_and_record limiter ~ip ~now:10.0 in 67 + (* At t=20, should need to wait ~40s for first request to expire *) 68 + let retry = Rate_limit.retry_after limiter ~ip ~now:20.0 in 69 + Alcotest.(check bool) "retry_after > 0" true (retry > 0.0); 70 + Alcotest.(check bool) "retry_after <= 60" true (retry <= 60.0) 71 + 72 + let test_retry_after_not_limited () = 73 + Eio_main.run @@ fun _env -> 74 + let limiter = Rate_limit.create ~max_requests:5 ~window_seconds:60.0 () in 75 + let ip = "1.2.3.4" in 76 + let _ = Rate_limit.check_and_record limiter ~ip ~now:0.0 in 77 + let retry = Rate_limit.retry_after limiter ~ip ~now:1.0 in 78 + Alcotest.(check (float 0.001)) "retry_after is 0 when not limited" 0.0 retry 79 + 80 + let test_current_count () = 81 + Eio_main.run @@ fun _env -> 82 + let limiter = Rate_limit.create ~max_requests:10 ~window_seconds:60.0 () in 83 + let ip = "1.2.3.4" in 84 + let count0 = Rate_limit.current_count limiter ~ip ~now:0.0 in 85 + Alcotest.(check int) "initial count is 0" 0 count0; 86 + let _ = Rate_limit.check_and_record limiter ~ip ~now:1.0 in 87 + let _ = Rate_limit.check_and_record limiter ~ip ~now:2.0 in 88 + let _ = Rate_limit.check_and_record limiter ~ip ~now:3.0 in 89 + let count3 = Rate_limit.current_count limiter ~ip ~now:4.0 in 90 + Alcotest.(check int) "count after 3 requests" 3 count3 91 + 92 + let test_stats () = 93 + Eio_main.run @@ fun _env -> 94 + let limiter = Rate_limit.create ~max_requests:10 ~window_seconds:60.0 () in 95 + let _ = Rate_limit.check_and_record limiter ~ip:"1.1.1.1" ~now:0.0 in 96 + let _ = Rate_limit.check_and_record limiter ~ip:"1.1.1.1" ~now:1.0 in 97 + let _ = Rate_limit.check_and_record limiter ~ip:"2.2.2.2" ~now:2.0 in 98 + let ips, requests = Rate_limit.stats limiter in 99 + Alcotest.(check int) "2 tracked IPs" 2 ips; 100 + Alcotest.(check int) "3 total requests" 3 requests 101 + 102 + let test_default_params () = 103 + Eio_main.run @@ fun _env -> 104 + let limiter = Rate_limit.create () in 105 + (* Default is 100 requests per 60 seconds *) 106 + let ip = "1.2.3.4" in 107 + let allowed, remaining = Rate_limit.check_and_record limiter ~ip ~now:0.0 in 108 + Alcotest.(check bool) "allowed with defaults" true allowed; 109 + Alcotest.(check int) "99 remaining with defaults" 99 remaining 110 + 111 + let () = 112 + Alcotest.run "rate-limit" 113 + [ 114 + ( "Rate_limit", 115 + [ 116 + Alcotest.test_case "basic allow" `Quick test_basic_allow; 117 + Alcotest.test_case "rate limit exceeded" `Quick 118 + test_rate_limit_exceeded; 119 + Alcotest.test_case "window expiry" `Quick test_window_expiry; 120 + Alcotest.test_case "different IPs" `Quick test_different_ips; 121 + Alcotest.test_case "retry after" `Quick test_retry_after; 122 + Alcotest.test_case "retry after not limited" `Quick 123 + test_retry_after_not_limited; 124 + Alcotest.test_case "current count" `Quick test_current_count; 125 + Alcotest.test_case "stats" `Quick test_stats; 126 + Alcotest.test_case "default params" `Quick test_default_params; 127 + ] ); 128 + ]