Skip to content

Commit a9f3b74

Browse files
authored
test(server): Add tests for throughput rate limits (#254)
1 parent e6f6745 commit a9f3b74

4 files changed

Lines changed: 290 additions & 6 deletions

File tree

objectstore-server/src/config.rs

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -933,6 +933,7 @@ mod tests {
933933
use secrecy::ExposeSecret;
934934

935935
use crate::killswitches::Killswitch;
936+
use crate::rate_limits::{BandwidthLimits, RateLimits, ThroughputLimits, ThroughputRule};
936937

937938
use super::*;
938939

@@ -1187,4 +1188,64 @@ mod tests {
11871188
Ok(())
11881189
});
11891190
}
1191+
1192+
#[test]
1193+
fn configure_rate_limits_with_yaml() {
1194+
let mut tempfile = tempfile::NamedTempFile::new().unwrap();
1195+
tempfile
1196+
.write_all(
1197+
br#"
1198+
rate_limits:
1199+
throughput:
1200+
global_rps: 1000
1201+
burst: 100
1202+
usecase_pct: 50
1203+
scope_pct: 25
1204+
rules:
1205+
- usecase: "high_priority"
1206+
scopes:
1207+
- ["org", "123"]
1208+
rps: 500
1209+
- scopes:
1210+
- ["org", "456"]
1211+
- ["project", "789"]
1212+
pct: 10
1213+
"#,
1214+
)
1215+
.unwrap();
1216+
1217+
figment::Jail::expect_with(|_jail| {
1218+
let expected = RateLimits {
1219+
throughput: ThroughputLimits {
1220+
global_rps: Some(1000),
1221+
burst: 100,
1222+
usecase_pct: Some(50),
1223+
scope_pct: Some(25),
1224+
rules: vec![
1225+
ThroughputRule {
1226+
usecase: Some("high_priority".to_string()),
1227+
scopes: vec![("org".to_string(), "123".to_string())],
1228+
rps: Some(500),
1229+
pct: None,
1230+
},
1231+
ThroughputRule {
1232+
usecase: None,
1233+
scopes: vec![
1234+
("org".to_string(), "456".to_string()),
1235+
("project".to_string(), "789".to_string()),
1236+
],
1237+
rps: None,
1238+
pct: Some(10),
1239+
},
1240+
],
1241+
},
1242+
bandwidth: BandwidthLimits::default(),
1243+
};
1244+
1245+
let config = Config::load(Some(tempfile.path())).unwrap();
1246+
assert_eq!(config.rate_limits, expected);
1247+
1248+
Ok(())
1249+
});
1250+
}
11901251
}

objectstore-server/src/extractors/id.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,12 @@ impl FromRequestParts<ServiceState> for Xt<ObjectId> {
5656
sentry::configure_scope(|s| s.set_extra("key", id.key().into()));
5757

5858
if state.config.killswitches.matches(id.context()) {
59+
tracing::debug!("Request rejected due to killswitches");
5960
return Err(ObjectRejection::Killswitched);
6061
}
6162

6263
if !state.rate_limiter.check(id.context()) {
64+
tracing::debug!("Request rejected due to rate limits");
6365
return Err(ObjectRejection::RateLimited);
6466
}
6567

@@ -121,10 +123,12 @@ impl FromRequestParts<ServiceState> for Xt<ObjectContext> {
121123
populate_sentry_context(&context);
122124

123125
if state.config.killswitches.matches(&context) {
126+
tracing::debug!("Request rejected due to killswitches");
124127
return Err(ObjectRejection::Killswitched);
125128
}
126129

127130
if !state.rate_limiter.check(&context) {
131+
tracing::debug!("Request rejected due to rate limits");
128132
return Err(ObjectRejection::RateLimited);
129133
}
130134

objectstore-server/src/rate_limits.rs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,15 @@ use objectstore_types::scope::Scopes;
66
use serde::{Deserialize, Serialize};
77

88
/// Rate limits for objectstore.
9-
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
9+
#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq)]
1010
pub struct RateLimits {
1111
/// Limits the number of requests per second per service instance.
1212
pub throughput: ThroughputLimits,
1313
/// Limits the concurrent bandwidth per service instance.
1414
pub bandwidth: BandwidthLimits,
1515
}
1616

17-
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
17+
#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq)]
1818
pub struct ThroughputLimits {
1919
/// The overall maximum number of requests per second per service instance.
2020
///
@@ -45,7 +45,7 @@ pub struct ThroughputLimits {
4545
pub rules: Vec<ThroughputRule>,
4646
}
4747

48-
#[derive(Clone, Debug, Deserialize, Serialize)]
48+
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
4949
pub struct ThroughputRule {
5050
/// Optional usecase to match.
5151
///
@@ -91,7 +91,7 @@ impl ThroughputRule {
9191
}
9292
}
9393

94-
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
94+
#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq)]
9595
pub struct BandwidthLimits {
9696
/// The overall maximum bandwidth (in bytes per second) per service instance.
9797
///
@@ -237,15 +237,15 @@ struct TokenBucket {
237237
}
238238

239239
impl TokenBucket {
240-
/// Creates a new token bucket with the specified rate limit and burst capacity.
240+
/// Creates a new, full token bucket with the specified rate limit and burst capacity.
241241
///
242242
/// - `rps`: tokens refilled per second (sustained rate limit)
243243
/// - `burst`: initial tokens and burst allowance above sustained rate
244244
pub fn new(rps: u32, burst: u32) -> Self {
245245
Self {
246246
refill_rate: rps as f64,
247247
capacity: (rps + burst) as f64,
248-
tokens: burst as f64,
248+
tokens: (rps + burst) as f64,
249249
last_update: Instant::now(),
250250
}
251251
}

objectstore-server/tests/limits.rs

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ use std::collections::BTreeMap;
88
use anyhow::Result;
99
use objectstore_server::config::{AuthZ, Config};
1010
use objectstore_server::killswitches::{Killswitch, Killswitches};
11+
use objectstore_server::rate_limits::{RateLimits, ThroughputLimits, ThroughputRule};
1112
use objectstore_test::server::TestServer;
1213

1314
#[tokio::test]
@@ -51,3 +52,221 @@ async fn test_killswitches() -> Result<()> {
5152

5253
Ok(())
5354
}
55+
56+
#[tokio::test]
57+
async fn test_throughput_global_rps_limit() -> Result<()> {
58+
let server = TestServer::with_config(Config {
59+
rate_limits: RateLimits {
60+
throughput: ThroughputLimits {
61+
global_rps: Some(2),
62+
burst: 1,
63+
..Default::default()
64+
},
65+
..Default::default()
66+
},
67+
..Default::default()
68+
})
69+
.await;
70+
71+
let client = reqwest::Client::new();
72+
73+
// First three requests without waiting should succeed, using up both the regular and burst budget
74+
for _ in 0..3 {
75+
let response = client
76+
.get(server.url("/v1/objects/test/org=1/nonexistent"))
77+
.send()
78+
.await?;
79+
assert_eq!(response.status(), reqwest::StatusCode::NOT_FOUND);
80+
}
81+
82+
// Fourth request without waiting should be rate limited
83+
let response = client
84+
.get(server.url("/v1/objects/test/org=1/nonexistent"))
85+
.send()
86+
.await?;
87+
assert_eq!(response.status(), reqwest::StatusCode::TOO_MANY_REQUESTS);
88+
89+
// Refill bucket
90+
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
91+
92+
// After waiting, request succeeds
93+
let response = client
94+
.get(server.url("/v1/objects/test/org=1/nonexistent"))
95+
.send()
96+
.await?;
97+
assert_eq!(response.status(), reqwest::StatusCode::NOT_FOUND);
98+
99+
Ok(())
100+
}
101+
102+
#[tokio::test]
103+
async fn test_throughput_usecase_pct_limit() -> Result<()> {
104+
let server = TestServer::with_config(Config {
105+
rate_limits: RateLimits {
106+
throughput: ThroughputLimits {
107+
global_rps: Some(100),
108+
burst: 0,
109+
usecase_pct: Some(2),
110+
..Default::default()
111+
},
112+
..Default::default()
113+
},
114+
..Default::default()
115+
})
116+
.await;
117+
118+
let client = reqwest::Client::new();
119+
120+
// First two requests to the same usecase without waiting should succeed
121+
for _ in 0..2 {
122+
let response = client
123+
.get(server.url("/v1/objects/test/org=1/nonexistent"))
124+
.send()
125+
.await?;
126+
assert_eq!(response.status(), reqwest::StatusCode::NOT_FOUND);
127+
}
128+
129+
// Third request to the same usecase without waiting should fail
130+
let response = client
131+
.get(server.url("/v1/objects/test/org=1/nonexistent"))
132+
.send()
133+
.await?;
134+
assert_eq!(response.status(), reqwest::StatusCode::TOO_MANY_REQUESTS);
135+
136+
// Request to a different usecase should succeed
137+
let response = client
138+
.get(server.url("/v1/objects/other/org=1/nonexistent"))
139+
.send()
140+
.await?;
141+
assert_eq!(response.status(), reqwest::StatusCode::NOT_FOUND);
142+
143+
// Refill bucket
144+
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
145+
146+
// After waiting, request to first usecase should succeed again
147+
let response = client
148+
.get(server.url("/v1/objects/test/org=1/nonexistent"))
149+
.send()
150+
.await?;
151+
assert_eq!(response.status(), reqwest::StatusCode::NOT_FOUND);
152+
153+
Ok(())
154+
}
155+
156+
#[tokio::test]
157+
async fn test_throughput_scope_pct_limit() -> Result<()> {
158+
let server = TestServer::with_config(Config {
159+
rate_limits: RateLimits {
160+
throughput: ThroughputLimits {
161+
global_rps: Some(100),
162+
burst: 0,
163+
scope_pct: Some(2),
164+
..Default::default()
165+
},
166+
..Default::default()
167+
},
168+
..Default::default()
169+
})
170+
.await;
171+
172+
let client = reqwest::Client::new();
173+
174+
// First two requests to the same scope without waiting should succeed
175+
for _ in 0..2 {
176+
let response = client
177+
.get(server.url("/v1/objects/test/org=1/nonexistent"))
178+
.send()
179+
.await?;
180+
assert_eq!(response.status(), reqwest::StatusCode::NOT_FOUND);
181+
}
182+
183+
// Third request to the same scope without waiting should fail
184+
let response = client
185+
.get(server.url("/v1/objects/test/org=1/nonexistent"))
186+
.send()
187+
.await?;
188+
assert_eq!(response.status(), reqwest::StatusCode::TOO_MANY_REQUESTS);
189+
190+
// Request to a different scope should succeed
191+
let response = client
192+
.get(server.url("/v1/objects/test/org=2/nonexistent"))
193+
.send()
194+
.await?;
195+
assert_eq!(response.status(), reqwest::StatusCode::NOT_FOUND);
196+
197+
// Refill bucket
198+
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
199+
200+
// After waiting, request to first scope should succeed again
201+
let response = client
202+
.get(server.url("/v1/objects/test/org=1/nonexistent"))
203+
.send()
204+
.await?;
205+
assert_eq!(response.status(), reqwest::StatusCode::NOT_FOUND);
206+
207+
Ok(())
208+
}
209+
210+
#[tokio::test]
211+
async fn test_throughput_rule() -> Result<()> {
212+
let server = TestServer::with_config(Config {
213+
rate_limits: RateLimits {
214+
throughput: ThroughputLimits {
215+
global_rps: None,
216+
burst: 0,
217+
rules: vec![ThroughputRule {
218+
usecase: Some("restricted".to_string()),
219+
scopes: vec![("org".to_string(), "42".to_string())],
220+
rps: Some(1),
221+
pct: None,
222+
}],
223+
..Default::default()
224+
},
225+
..Default::default()
226+
},
227+
..Default::default()
228+
})
229+
.await;
230+
231+
let client = reqwest::Client::new();
232+
233+
// First request matching rule should succeed
234+
let response = client
235+
.get(server.url("/v1/objects/restricted/org=42/nonexistent"))
236+
.send()
237+
.await?;
238+
assert_eq!(response.status(), reqwest::StatusCode::NOT_FOUND);
239+
240+
// Second request matching rule should fail
241+
let response = client
242+
.get(server.url("/v1/objects/restricted/org=42/nonexistent"))
243+
.send()
244+
.await?;
245+
assert_eq!(response.status(), reqwest::StatusCode::TOO_MANY_REQUESTS);
246+
247+
// Different usecase should not be affected by rule
248+
let response = client
249+
.get(server.url("/v1/objects/other/org=42/nonexistent"))
250+
.send()
251+
.await?;
252+
assert_eq!(response.status(), reqwest::StatusCode::NOT_FOUND);
253+
254+
// Same usecase but different scope should not be affected by rule
255+
let response = client
256+
.get(server.url("/v1/objects/restricted/org=43/nonexistent"))
257+
.send()
258+
.await?;
259+
assert_eq!(response.status(), reqwest::StatusCode::NOT_FOUND);
260+
261+
// Refill bucket
262+
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
263+
264+
// After waiting, request matching rule should succeed again
265+
let response = client
266+
.get(server.url("/v1/objects/restricted/org=42/nonexistent"))
267+
.send()
268+
.await?;
269+
assert_eq!(response.status(), reqwest::StatusCode::NOT_FOUND);
270+
271+
Ok(())
272+
}

0 commit comments

Comments
 (0)