use std::convert::Infallible;
use std::time::Duration;
use autumn_web::config::AutumnConfig;
use autumn_web::sse::{Event, Sse};
use autumn_web::test::TestApp;
use autumn_web::{get, post, routes};
use futures::stream::Stream;
fn with_global_timeout(ms: u64) -> AutumnConfig {
let mut config = AutumnConfig::default();
config.server.timeouts.request_timeout_ms = Some(ms);
config
}
#[get("/slow")]
async fn slow() -> &'static str {
tokio::time::sleep(Duration::from_millis(300)).await;
"done"
}
#[get("/fast")]
async fn fast() -> &'static str {
"quick"
}
#[get("/export", timeout_ms = 5000)]
async fn export() -> &'static str {
tokio::time::sleep(Duration::from_millis(200)).await;
"report"
}
#[get("/longpoll", timeout = "off")]
async fn longpoll() -> &'static str {
tokio::time::sleep(Duration::from_millis(200)).await;
"eventually"
}
#[get("/items", timeout = "off")]
async fn items_list() -> &'static str {
tokio::time::sleep(Duration::from_millis(200)).await;
"list"
}
#[post("/items")]
async fn items_create() -> &'static str {
tokio::time::sleep(Duration::from_millis(200)).await;
"created"
}
#[get("/sse")]
async fn sse() -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
let stream = futures::stream::unfold(0u8, |i| async move {
if i >= 2 {
return None;
}
if i > 0 {
tokio::time::sleep(Duration::from_millis(150)).await;
}
Some((
Ok::<_, Infallible>(Event::default().data(format!("tick-{i}"))),
i + 1,
))
});
Sse::new(stream)
}
#[tokio::test]
async fn slow_handler_returns_503_problem_json_for_api_client() {
let client = TestApp::new()
.routes(routes![slow])
.config(with_global_timeout(50))
.build();
let resp = client
.get("/slow")
.header("accept", "application/json")
.send()
.await;
resp.assert_status(503);
resp.assert_header_contains("content-type", "application/problem+json");
let body: serde_json::Value = resp.json();
assert_eq!(body["status"], 503, "Problem Details status must be 503");
}
#[tokio::test]
async fn slow_handler_renders_html_error_page_for_browser() {
let client = TestApp::new()
.routes(routes![slow])
.config(with_global_timeout(50))
.build();
let resp = client
.get("/slow")
.header("accept", "text/html")
.send()
.await;
resp.assert_status(503);
resp.assert_header_contains("content-type", "text/html");
assert!(
resp.header("x-request-id").is_some(),
"HTML timeout page must preserve the X-Request-Id header"
);
let body = resp.text();
assert!(
body.contains("<!DOCTYPE html") || body.contains("<html"),
"browser clients must receive the HTML error page, got: {body}"
);
}
#[tokio::test]
async fn timeout_override_is_method_specific() {
let client = TestApp::new()
.routes(routes![items_list, items_create])
.config(with_global_timeout(50))
.build();
client.get("/items").send().await.assert_status(200);
client.post("/items").send().await.assert_status(503);
}
#[tokio::test]
async fn per_route_timeout_ms_attribute_extends_deadline() {
let client = TestApp::new()
.routes(routes![slow, export])
.config(with_global_timeout(50))
.build();
client.get("/export").send().await.assert_status(200);
client.get("/slow").send().await.assert_status(503);
}
#[tokio::test]
async fn per_route_timeout_off_attribute_disables_deadline() {
let client = TestApp::new()
.routes(routes![longpoll])
.config(with_global_timeout(50))
.build();
client.get("/longpoll").send().await.assert_status(200);
}
#[tokio::test]
async fn sse_stream_survives_global_timeout() {
let client = TestApp::new()
.routes(routes![sse])
.config(with_global_timeout(50))
.build();
let resp = client.get("/sse").send().await;
resp.assert_status(200);
resp.assert_body_contains("tick-0");
resp.assert_body_contains("tick-1");
}
#[tokio::test]
async fn fast_route_not_timed_out_when_deadline_enabled() {
let client = TestApp::new()
.routes(routes![fast])
.config(with_global_timeout(50))
.build();
client
.get("/fast")
.send()
.await
.assert_status(200)
.assert_body_contains("quick");
}
#[get("/hang")]
async fn hang() -> &'static str {
tokio::time::sleep(Duration::from_secs(30)).await;
"never"
}
#[tokio::test(flavor = "multi_thread")]
async fn timeout_fires_cleanly_during_graceful_drain() {
use std::net::SocketAddr;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use tokio_util::sync::CancellationToken;
let shutdown = CancellationToken::new();
let shutdown_clone = shutdown.clone();
let tc = TestApp::new()
.routes(routes![hang])
.config(with_global_timeout(100))
.build();
let probes = tc.probes().clone();
let router = tc.into_router();
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
let addr: SocketAddr = listener.local_addr().expect("local_addr");
let server = tokio::spawn(async move {
axum::serve(listener, router)
.with_graceful_shutdown(shutdown.cancelled_owned())
.await
.ok();
});
tokio::time::sleep(Duration::from_millis(20)).await;
let client = tokio::spawn(async move {
let mut stream = TcpStream::connect(addr).await.expect("connect");
stream
.write_all(b"GET /hang HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
.await
.expect("send");
let mut buf = Vec::new();
stream.read_to_end(&mut buf).await.expect("read");
String::from_utf8_lossy(&buf).into_owned()
});
tokio::time::sleep(Duration::from_millis(40)).await;
probes.begin_draining();
shutdown_clone.cancel();
let response = tokio::time::timeout(Duration::from_secs(5), client)
.await
.expect("hung request must be released by the deadline during drain")
.expect("client task must not panic");
assert!(
response.starts_with("HTTP/1.1 503"),
"a request that exceeds the deadline during drain must return a clean 503; got: {response}"
);
tokio::time::timeout(Duration::from_secs(5), server)
.await
.expect("server must drain and exit cleanly")
.ok();
}