Skip to content

Commit 68993df

Browse files
committed
Rate limiting: token buckets with zero or negative fill rate fail
Zero or negative rate limits can cause issues in the behaviour of rate limiting. In particular, zero fill rate leads to a division by zero in time calculations. Rather than account for this, we forbid the creation of token buckets with a bad fill rate by returning None. Signed-off-by: Christian Pardillo Laursen <christian.pardillolaursen@citrix.com>
1 parent 0047b24 commit 68993df

File tree

6 files changed

+107
-66
lines changed

6 files changed

+107
-66
lines changed

ocaml/libs/rate-limit/bucket_table.ml

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,13 @@ type t = (string, Token_bucket.t) Hashtbl.t
1717
let create () = Hashtbl.create 16
1818

1919
let add_bucket table ~user_agent ~burst_size ~fill_rate =
20-
let bucket = Token_bucket.create ~burst_size ~fill_rate in
21-
Hashtbl.add table user_agent bucket
20+
let bucket_option = Token_bucket.create ~burst_size ~fill_rate in
21+
match bucket_option with
22+
| Some bucket ->
23+
Hashtbl.add table user_agent bucket ;
24+
true
25+
| None ->
26+
false
2227

2328
let delete_bucket table ~user_agent = Hashtbl.remove table user_agent
2429

ocaml/libs/rate-limit/bucket_table.mli

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,29 @@
1212
* GNU Lesser General Public License for more details.
1313
*)
1414

15+
(** Hash table mapping client identifiers to their token buckets for rate limiting. *)
1516
type t = (string, Token_bucket.t) Hashtbl.t
1617

1718
val create : unit -> t
19+
(** [create ()] creates a new empty bucket table. *)
1820

1921
val add_bucket :
20-
t -> user_agent:string -> burst_size:float -> fill_rate:float -> unit
22+
t -> user_agent:string -> burst_size:float -> fill_rate:float -> bool
23+
(** [add_bucket table ~user_agent ~burst_size ~fill_rate] adds a token bucket
24+
for the given user agent. Returns [false] if a bucket already exists, or if
25+
the bucket configuration is invalid, e.g. negative fill rate. *)
2126

2227
val peek : t -> user_agent:string -> float option
28+
(** [peek table ~user_agent] returns the current token count for the user agent,
29+
or [None] if no bucket exists. *)
2330

2431
val delete_bucket : t -> user_agent:string -> unit
32+
(** [delete_bucket table ~user_agent] removes the bucket for the user agent. *)
2533

2634
val try_consume : t -> user_agent:string -> float -> bool
35+
(** [try_consume table ~user_agent amount] attempts to consume tokens.
36+
Returns [true] on success, [false] if insufficient tokens. *)
2737

2838
val consume_and_block : t -> user_agent:string -> float -> unit
39+
(** [consume_and_block table ~user_agent amount] consumes tokens, blocking
40+
until sufficient tokens are available. *)

ocaml/libs/rate-limit/test/test_token_bucket.ml

Lines changed: 65 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,22 @@ open Thread
22
open Rate_limit
33
open QCheck
44

5+
let test_bad_fill_rate () =
6+
let tb_zero = Token_bucket.create ~burst_size:1.0 ~fill_rate:0.0 in
7+
Alcotest.(check bool)
8+
"Creating a token bucket with 0 fill rate should fail" true (tb_zero = None) ;
9+
let tb_negative = Token_bucket.create ~burst_size:1.0 ~fill_rate:~-.1.0 in
10+
Alcotest.(check bool)
11+
"Creating a token bucket with negative fill rate should fail" true
12+
(tb_negative = None)
13+
514
let test_consume_removes_correct_amount () =
615
let initial_time = Mtime.Span.of_uint64_ns 0L in
716
let tb =
8-
Token_bucket.create_with_timestamp initial_time ~burst_size:10.0
9-
~fill_rate:2.0
17+
Option.get
18+
(Token_bucket.create_with_timestamp initial_time ~burst_size:10.0
19+
~fill_rate:2.0
20+
)
1021
in
1122

1223
Alcotest.(check (float 0.0))
@@ -25,8 +36,10 @@ let test_consume_removes_correct_amount () =
2536
let test_consume_more_than_available () =
2637
let initial_time = Mtime.Span.of_uint64_ns 0L in
2738
let tb =
28-
Token_bucket.create_with_timestamp initial_time ~burst_size:5.0
29-
~fill_rate:1.0
39+
Option.get
40+
(Token_bucket.create_with_timestamp initial_time ~burst_size:5.0
41+
~fill_rate:1.0
42+
)
3043
in
3144

3245
let _ = Token_bucket.consume_with_timestamp (fun () -> initial_time) tb 4.0 in
@@ -43,8 +56,10 @@ let test_consume_more_than_available () =
4356
let test_consume_refills_before_removing () =
4457
let initial_time = Mtime.Span.of_uint64_ns 0L in
4558
let tb =
46-
Token_bucket.create_with_timestamp initial_time ~burst_size:10.0
47-
~fill_rate:2.0
59+
Option.get
60+
(Token_bucket.create_with_timestamp initial_time ~burst_size:10.0
61+
~fill_rate:2.0
62+
)
4863
in
4964

5065
let first_consume =
@@ -67,8 +82,10 @@ let test_consume_refills_before_removing () =
6782
let test_peek_respects_burst_size () =
6883
let initial_time = Mtime.Span.of_uint64_ns 0L in
6984
let tb =
70-
Token_bucket.create_with_timestamp initial_time ~burst_size:10.0
71-
~fill_rate:5.0
85+
Option.get
86+
(Token_bucket.create_with_timestamp initial_time ~burst_size:10.0
87+
~fill_rate:5.0
88+
)
7289
in
7390

7491
let _ = Token_bucket.consume_with_timestamp (fun () -> initial_time) tb 8.0 in
@@ -80,8 +97,10 @@ let test_peek_respects_burst_size () =
8097

8198
let test_concurrent_access () =
8299
let tb =
83-
Token_bucket.create_with_timestamp Mtime.Span.zero ~burst_size:15.0
84-
~fill_rate:0.0
100+
Option.get
101+
(Token_bucket.create_with_timestamp Mtime.Span.zero ~burst_size:15.0
102+
~fill_rate:0.01
103+
)
85104
in
86105
let threads =
87106
Array.init 10 (fun _ ->
@@ -101,14 +120,14 @@ let test_concurrent_access () =
101120
5.0
102121

103122
let test_sleep () =
104-
let tb = Token_bucket.create ~burst_size:20.0 ~fill_rate:5.0 in
123+
let tb = Option.get (Token_bucket.create ~burst_size:20.0 ~fill_rate:5.0) in
105124
let _ = Token_bucket.consume tb 10.0 in
106125
Thread.delay 1.0 ;
107126
Alcotest.(check (float 0.2))
108127
"Sleep 1 should refill token bucket by fill_rate" 15.0 (Token_bucket.peek tb)
109128

110129
let test_system_time_versions () =
111-
let tb = Token_bucket.create ~burst_size:10.0 ~fill_rate:2.0 in
130+
let tb = Option.get (Token_bucket.create ~burst_size:10.0 ~fill_rate:2.0) in
112131

113132
let initial_peek = Token_bucket.peek tb in
114133
Alcotest.(check (float 0.01))
@@ -122,7 +141,7 @@ let test_system_time_versions () =
122141
"After consume, should have 7 tokens" 7.0 after_consume_peek
123142

124143
let test_concurrent_system_time () =
125-
let tb = Token_bucket.create ~burst_size:100.0 ~fill_rate:10.0 in
144+
let tb = Option.get (Token_bucket.create ~burst_size:100.0 ~fill_rate:10.0) in
126145
let num_threads = 20 in
127146
let consume_per_thread = 3 in
128147

@@ -149,8 +168,10 @@ let test_concurrent_system_time () =
149168

150169
let test_consume_more_than_available_concurrent () =
151170
let tb =
152-
Token_bucket.create_with_timestamp Mtime.Span.zero ~burst_size:5.0
153-
~fill_rate:0.0
171+
Option.get
172+
(Token_bucket.create_with_timestamp Mtime.Span.zero ~burst_size:5.0
173+
~fill_rate:0.1
174+
)
154175
in
155176
let num_threads = 10 in
156177
let consume_per_thread = 1 in
@@ -180,15 +201,17 @@ let test_consume_more_than_available_concurrent () =
180201

181202
Alcotest.(check int)
182203
"Only 5 consumptions should succeed" 5 !successful_consumes ;
183-
Alcotest.(check (float 0.0))
204+
Alcotest.(check (float 0.1))
184205
"Bucket should be empty after consumptions" 0.0
185206
(Token_bucket.peek_with_timestamp Mtime.Span.zero tb)
186207

187208
let test_delay_until_available () =
188209
let initial_time = Mtime.Span.of_uint64_ns 0L in
189210
let tb =
190-
Token_bucket.create_with_timestamp initial_time ~burst_size:10.0
191-
~fill_rate:2.0
211+
Option.get
212+
(Token_bucket.create_with_timestamp initial_time ~burst_size:10.0
213+
~fill_rate:2.0
214+
)
192215
in
193216

194217
let _ =
@@ -201,57 +224,40 @@ let test_delay_until_available () =
201224
Alcotest.(check (float 0.01))
202225
"Delay for 4 tokens at 2 tokens/sec should be 2 seconds" 2.0 delay ;
203226

204-
let tb_fresh = Token_bucket.create ~burst_size:10.0 ~fill_rate:2.0 in
227+
let tb_fresh =
228+
Option.get (Token_bucket.create ~burst_size:10.0 ~fill_rate:2.0)
229+
in
205230
let _ = Token_bucket.consume tb_fresh 10.0 in
206231
let delay_system = Token_bucket.delay_until_available tb_fresh 4.0 in
207232

208233
Alcotest.(check (float 0.1))
209234
"System time delay should be approximately 2 seconds" 2.0 delay_system
210235

211236
let test_edge_cases () =
212-
let tb_zero_rate =
213-
Token_bucket.create_with_timestamp Mtime.Span.zero ~burst_size:5.0
214-
~fill_rate:0.0
215-
in
216-
let _ =
217-
Token_bucket.consume_with_timestamp
218-
(fun () -> Mtime.Span.zero)
219-
tb_zero_rate 2.0
220-
in
221-
let later_time = Mtime.Span.of_uint64_ns 1_000_000_000_000L in
222-
let available = Token_bucket.peek_with_timestamp later_time tb_zero_rate in
223-
Alcotest.(check (float 0.0))
224-
"Zero fill rate should not add tokens" 3.0 available ;
225-
226-
let tb_zero_amount =
227-
Token_bucket.create_with_timestamp Mtime.Span.zero ~burst_size:5.0
228-
~fill_rate:1.0
237+
let tb =
238+
Option.get
239+
(Token_bucket.create_with_timestamp Mtime.Span.zero ~burst_size:5.0
240+
~fill_rate:1.0
241+
)
229242
in
230243
let success =
231-
Token_bucket.consume_with_timestamp
232-
(fun () -> Mtime.Span.zero)
233-
tb_zero_amount 0.0
244+
Token_bucket.consume_with_timestamp (fun () -> Mtime.Span.zero) tb 0.0
234245
in
235246
Alcotest.(check bool) "Consuming zero tokens should succeed" true success ;
236247

237248
let tb_small =
238-
Token_bucket.create_with_timestamp Mtime.Span.zero ~burst_size:1.0
239-
~fill_rate:0.1
249+
Option.get
250+
(Token_bucket.create_with_timestamp Mtime.Span.zero ~burst_size:1.0
251+
~fill_rate:0.1
252+
)
240253
in
241254
let success_small =
242255
Token_bucket.consume_with_timestamp
243256
(fun () -> Mtime.Span.zero)
244257
tb_small 0.001
245258
in
246259
Alcotest.(check bool)
247-
"Consuming very small amount should succeed" true success_small ;
248-
249-
let tb_zero = Token_bucket.create ~burst_size:0.0 ~fill_rate:0.0 in
250-
let success_zero = Token_bucket.consume tb_zero 0.0 in
251-
let success_small = Token_bucket.consume tb_zero 0.001 in
252-
Alcotest.(check bool) "Consuming zero tokens should succeed" true success_zero ;
253-
Alcotest.(check bool)
254-
"Consuming very small amount should fail" false success_small
260+
"Consuming very small amount should succeed" true success_small
255261

256262
let test_consume_quickcheck =
257263
let open QCheck.Gen in
@@ -289,7 +295,8 @@ let test_consume_quickcheck =
289295
let property (burst_size, fill_rate, operations) =
290296
let initial_time = Mtime.Span.of_uint64_ns 0L in
291297
let tb =
292-
Token_bucket.create_with_timestamp initial_time ~burst_size ~fill_rate
298+
Option.get
299+
(Token_bucket.create_with_timestamp initial_time ~burst_size ~fill_rate)
293300
in
294301

295302
let rec check_operations op_num time_ns last_refill_ns current_tokens ops =
@@ -345,7 +352,9 @@ let test_consume_quickcheck =
345352
in
346353

347354
let gen_all =
348-
map3 (fun burst fill ops -> (burst, fill, ops)) pfloat pfloat gen_operations
355+
map3
356+
(fun burst fill ops -> (burst, fill, ops))
357+
pfloat (float_range 1e-9 1e9) gen_operations
349358
in
350359

351360
let arb_all =
@@ -371,7 +380,11 @@ let test_consume_quickcheck =
371380

372381
let test =
373382
[
374-
( "Consume removes correct amount"
383+
( "A bucket with zero or negative fill rate cannot be created"
384+
, `Quick
385+
, test_bad_fill_rate
386+
)
387+
; ( "Consume removes correct amount"
375388
, `Quick
376389
, test_consume_removes_correct_amount
377390
)

ocaml/libs/rate-limit/token_bucket.ml

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,17 @@ type t = {
2323
}
2424

2525
let create_with_timestamp timestamp ~burst_size ~fill_rate =
26-
{
27-
burst_size
28-
; fill_rate
29-
; tokens= burst_size
30-
; last_refill= timestamp
31-
; mutex= Mutex.create ()
32-
}
26+
if fill_rate <= 0. then
27+
None
28+
else
29+
Some
30+
{
31+
burst_size
32+
; fill_rate
33+
; tokens= burst_size
34+
; last_refill= timestamp
35+
; mutex= Mutex.create ()
36+
}
3337

3438
let create = create_with_timestamp (Mtime_clock.elapsed ())
3539

ocaml/libs/rate-limit/token_bucket.mli

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434

3535
type t
3636

37-
val create : burst_size:float -> fill_rate:float -> t
37+
val create : burst_size:float -> fill_rate:float -> t option
3838
(** Create token bucket with given parameters.
3939
@param burst_size Maximum number of tokens that can fit in the bucket
4040
@param fill_rate Number of tokens added to the bucket per second
@@ -66,7 +66,7 @@ val delay_until_available : t -> float -> float
6666
(* Fuctions accepting a timestamp are meant for testing only *)
6767

6868
val create_with_timestamp :
69-
Mtime.span -> burst_size:float -> fill_rate:float -> t
69+
Mtime.span -> burst_size:float -> fill_rate:float -> t option
7070
(** Create token bucket with given parameters and supplied inital timestamp
7171
@param timestamp Initial timestamp
7272
@param burst_size Maximum number of tokens that can fit in the bucket

ocaml/xapi/xapi_rate_limit.ml

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,15 @@ let register_xapi_globs () =
2727
"Adding user agent %s to bucket table with burst size %f and \
2828
fill rate %f"
2929
user_agent burst_size fill_rate ;
30-
Rate_limit.Bucket_table.add_bucket bucket_table ~user_agent
31-
~burst_size ~fill_rate
30+
if
31+
not
32+
(Rate_limit.Bucket_table.add_bucket bucket_table ~user_agent
33+
~burst_size ~fill_rate
34+
)
35+
then
36+
D.error
37+
"Bucket creation failed for user agent %s: invalid fill rate %f"
38+
user_agent fill_rate
3239
| _ ->
3340
D.debug "Skipping invalid numeric values in: %s\n" s
3441
)

0 commit comments

Comments
 (0)