RateLimit header fields for HTTP

This commit is contained in:
Maurus Decimus
2026-05-23 11:41:43 +02:00
parent 7aa4865a10
commit 777cf1252b
71 changed files with 825 additions and 274 deletions

View File

@@ -4,7 +4,9 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::utils::{account::Account, jmap::JmapUtils, server::TestServer, smtp::SmtpConnection};
use crate::utils::{
account::Account, http::HttpRequest, jmap::JmapUtils, server::TestServer, smtp::SmtpConnection,
};
use email::{cache::MessageCacheFetch, mailbox::INBOX_ID};
use jmap::blob::upload::DISABLE_UPLOAD_QUOTA;
use jmap_client::{
@@ -102,6 +104,12 @@ pub async fn test(test: &mut TestServer) {
// Test temporary blob quota (3 files)
DISABLE_UPLOAD_QUOTA.store(false, std::sync::atomic::Ordering::Relaxed);
let client = account.jmap_client().await;
let raw_http = HttpRequest::with_credentials(
8899,
"user1@example.org",
"this is a very strong password1",
);
let upload_url = format!("/jmap/upload/{account_id}");
for i in 0..3 {
assert_eq!(
client
@@ -112,14 +120,45 @@ pub async fn test(test: &mut TestServer) {
1024
);
}
match client
.upload(None, vec![b'Z'; 1024], None)
.await
.unwrap_err()
{
jmap_client::Error::Problem(err) if err.detail().unwrap().contains("quota") => (),
other => panic!("Unexpected error: {:?}", other),
}
let resp = raw_http
.send_full(
hyper::Method::POST,
&upload_url,
Some(vec![b'Z'; 1024]),
Some("application/octet-stream"),
)
.await;
assert_eq!(
resp.status.as_u16(),
429,
"blob-files-quota body: {}",
resp.body
);
let policy = resp
.rate_limit_policy()
.unwrap_or_else(|| panic!("missing RateLimit-Policy on {:?}", resp.headers));
assert!(
policy.contains("\"blob-upload-files\";q=3"),
"RateLimit-Policy = {policy}"
);
assert!(
policy.contains("\"blob-upload-bytes\";q=50000")
&& policy.contains(r#"qu="content-bytes""#),
"RateLimit-Policy = {policy}"
);
let state = resp
.rate_limit()
.unwrap_or_else(|| panic!("missing RateLimit on {:?}", resp.headers));
assert!(
state.contains("\"blob-upload-files\";r=0") && state.contains("t="),
"RateLimit = {state}"
);
assert!(
resp.retry_after().is_some(),
"missing Retry-After on {:?}",
resp.headers
);
assert!(resp.body.contains("quota"), "body = {}", resp.body);
test.blob_expire_all().await;
// Test temporary blob quota (50000 bytes)
@@ -134,14 +173,40 @@ pub async fn test(test: &mut TestServer) {
25000
);
}
match client
.upload(None, vec![b'z'; 1024], None)
.await
.unwrap_err()
{
jmap_client::Error::Problem(err) if err.detail().unwrap().contains("quota") => (),
other => panic!("Unexpected error: {:?}", other),
}
let resp = raw_http
.send_full(
hyper::Method::POST,
&upload_url,
Some(vec![b'z'; 1024]),
Some("application/octet-stream"),
)
.await;
assert_eq!(
resp.status.as_u16(),
429,
"blob-bytes-quota body: {}",
resp.body
);
let policy = resp
.rate_limit_policy()
.unwrap_or_else(|| panic!("missing RateLimit-Policy on {:?}", resp.headers));
assert!(
policy.contains("\"blob-upload-bytes\";q=50000")
&& policy.contains(r#"qu="content-bytes""#),
"RateLimit-Policy = {policy}"
);
let state = resp
.rate_limit()
.unwrap_or_else(|| panic!("missing RateLimit on {:?}", resp.headers));
assert!(
state.contains("\"blob-upload-bytes\";r=0") && state.contains("t="),
"RateLimit = {state}"
);
assert!(
resp.retry_after().is_some(),
"missing Retry-After on {:?}",
resp.headers
);
test.blob_expire_all().await;
tokio::time::sleep(std::time::Duration::from_millis(1100)).await;

View File

@@ -7,6 +7,7 @@
use crate::{
system::authentication::validate_password_with_ip,
utils::{
http::HttpRequest,
imap::{ImapConnection, Type},
registry::UnwrapRegistryId,
server::TestServer,
@@ -271,6 +272,11 @@ pub async fn test(test: &mut TestServer) {
// Concurrent requests check
let client = Arc::new(client);
let raw_http = HttpRequest::with_credentials(
8899,
"user@example.org",
"this is a very strong password",
);
for _ in 0..8 {
let client_ = client.clone();
tokio::spawn(async move {
@@ -283,14 +289,48 @@ pub async fn test(test: &mut TestServer) {
});
}
tokio::time::sleep(Duration::from_millis(500)).await;
assert!(matches!(
client
.mailbox_query(
mailbox::query::Filter::name("__sleep").into(),
[mailbox::query::Comparator::name()].into(),
)
.await,
Err(jmap_client::Error::Problem(err)) if err.status() == Some(400)));
let body = serde_json::to_vec(&json!({
"using": ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"],
"methodCalls": [
["Mailbox/query", {
"accountId": user_id.to_string(),
"filter": { "name": "__sleep" }
}, "c1"]
]
}))
.unwrap();
let resp = raw_http
.send_full(
hyper::Method::POST,
"/jmap/",
Some(body),
Some("application/json"),
)
.await;
assert_eq!(
resp.status.as_u16(),
400,
"concurrent-requests body: {}",
resp.body
);
let policy = resp
.rate_limit_policy()
.unwrap_or_else(|| panic!("missing RateLimit-Policy header on {:?}", resp.headers));
assert!(
policy.contains("\"concurrent-requests\"") && policy.contains("q=8"),
"RateLimit-Policy = {policy}"
);
assert!(
policy.contains(r#"qu="concurrent-requests""#),
"RateLimit-Policy = {policy}"
);
let state = resp
.rate_limit()
.unwrap_or_else(|| panic!("missing RateLimit header on {:?}", resp.headers));
assert!(
state.contains("\"concurrent-requests\"") && state.contains("r=0"),
"RateLimit = {state}"
);
// Wait for sleep to be done
tokio::time::sleep(Duration::from_millis(1000)).await;
@@ -303,9 +343,37 @@ pub async fn test(test: &mut TestServer) {
});
}
tokio::time::sleep(Duration::from_millis(500)).await;
assert!(matches!(
client.upload(None, b"sleep".to_vec(), None).await,
Err(jmap_client::Error::Problem(err)) if err.status() == Some(400)));
let resp = raw_http
.send_full(
hyper::Method::POST,
&format!("/jmap/upload/{user_id}"),
Some(b"sleep".to_vec()),
Some("application/octet-stream"),
)
.await;
assert_eq!(
resp.status.as_u16(),
400,
"concurrent-uploads body: {}",
resp.body
);
let policy = resp
.rate_limit_policy()
.unwrap_or_else(|| panic!("missing RateLimit-Policy header on {:?}", resp.headers));
assert!(
policy.contains("\"concurrent-uploads\"") && policy.contains("q=4"),
"RateLimit-Policy = {policy}"
);
let state = resp
.rate_limit()
.unwrap_or_else(|| panic!("missing RateLimit header on {:?}", resp.headers));
assert!(
state.contains("\"concurrent-uploads\"") && state.contains("r=0"),
"RateLimit = {state}"
);
// Wait for sleep to be done before continuing
tokio::time::sleep(Duration::from_millis(1000)).await;
// Disable X-Forwarded-For processing
admin

View File

@@ -158,7 +158,12 @@ async fn pagination_test(test: &mut TestServer) {
.await
.object_ids()
.collect();
assert_eq!(asc_order.len(), 12, "expected 12 tasks, got {}", asc_order.len());
assert_eq!(
asc_order.len(),
12,
"expected 12 tasks, got {}",
asc_order.len()
);
let desc_order: Vec<Id> = asc_order.iter().rev().copied().collect();