feat(serve): split request read budget into head_timeout + body_timeout
The single request_timeout covered head + body under one wall clock, so a slow-but-legit body upload (e.g. a trickling cellular IoT client) was judged by the short head deadline and killed mid-body. Split into: - head_timeout (default 30s): first byte -> full head parse; the classic slowloris surface, kept short. - body_timeout (default 300s): head parse -> full body; an absolute cap sized for slow links, anchored independently once the head has parsed. read_head no longer returns a shared deadline; run_connection anchors the body deadline itself. ReadHeadErr::RequestTimeout -> HeadTimeout. Config and ConnLimits gain body_timeout; request_timeout renamed to head_timeout (breaking, but this axis is unreleased). Tests: slow_body_outlives_head_timeout (positive: body survives past the head clock), fixed_/chunked_body_stall_killed_at_body_timeout (body cap still bites), slowloris_partial_head_killed_at_head_timeout (head clock unchanged). 79 lib + 43 integration green; clippy --lib clean.
This commit is contained in:
+49
-39
@@ -46,14 +46,21 @@ pub struct ConnLimits {
|
||||
/// connection). Expiry closes the connection silently — nothing is
|
||||
/// owed to a client that isn't talking.
|
||||
pub keep_alive_timeout: Duration,
|
||||
/// Per-request wall-clock budget, measured from the first byte of a
|
||||
/// request until the request (head + body) is fully read. Expiry
|
||||
/// mid-head gets a best-effort 408; expiry mid-body just closes.
|
||||
/// Pipeline run time is NOT covered — that's the handler's business.
|
||||
/// Covers the READ phase only; the write phase has its own
|
||||
/// per-write budget (`write_timeout`) so a streaming response can
|
||||
/// legitimately outlive any whole-request clock.
|
||||
pub request_timeout: Duration,
|
||||
/// Wall-clock budget for reading the request HEAD, measured from the
|
||||
/// first byte of a request until the head is fully parsed. Expiry
|
||||
/// mid-head gets a best-effort 408. Kept short: an incomplete head is
|
||||
/// the classic slowloris, and a legitimate client sends its head in a
|
||||
/// single burst. The BODY has its own, larger budget (`body_timeout`)
|
||||
/// so a slow-but-legit upload is not judged by the head clock.
|
||||
pub head_timeout: Duration,
|
||||
/// Absolute wall-clock cap on reading the request BODY, measured from
|
||||
/// the moment the head finished parsing until the body is fully read.
|
||||
/// Sized for slow links (e.g. a trickling cellular IoT client), so it
|
||||
/// is much larger than `head_timeout`. Expiry mid-body just closes —
|
||||
/// nothing is owed to a client this far gone. Pipeline run time is NOT
|
||||
/// covered (that's the handler's business); the write phase has its own
|
||||
/// per-write budget (`write_timeout`).
|
||||
pub body_timeout: Duration,
|
||||
/// Per-write budget for response bytes: every `write_all` (the fixed
|
||||
/// head+body, and each streamed chunk) must complete within this.
|
||||
/// A client that stops reading mid-response is dropped when its
|
||||
@@ -76,7 +83,8 @@ impl Default for ConnLimits {
|
||||
max_head_bytes: 64 * 1024,
|
||||
max_body_bytes: 16 * 1024 * 1024,
|
||||
keep_alive_timeout: Duration::from_secs(60),
|
||||
request_timeout: Duration::from_secs(30),
|
||||
head_timeout: Duration::from_secs(30),
|
||||
body_timeout: Duration::from_secs(300),
|
||||
write_timeout: Duration::from_secs(30),
|
||||
max_frame_payload: 1024 * 1024,
|
||||
max_message_bytes: 4 * 1024 * 1024,
|
||||
@@ -111,7 +119,7 @@ pub fn run_connection(
|
||||
// ----- 1. Read until we have a full request head. -----
|
||||
// We are idle until a head parses: stoppable by a draining
|
||||
// registry while parked here.
|
||||
let (parsed, request_deadline) = match read_head(raw, &mut buf, &limits) {
|
||||
let parsed = match read_head(raw, &mut buf, &limits) {
|
||||
Ok(p) => p,
|
||||
Err(ReadHeadErr::ClientClosed) => {
|
||||
// Clean EOF between requests (or before any request). Normal.
|
||||
@@ -122,8 +130,8 @@ pub fn run_connection(
|
||||
// a request. Nothing is owed; close silently.
|
||||
return;
|
||||
}
|
||||
Err(ReadHeadErr::RequestTimeout) => {
|
||||
// request_timeout expired mid-head (slowloris and friends).
|
||||
Err(ReadHeadErr::HeadTimeout) => {
|
||||
// head_timeout expired mid-head (slowloris and friends).
|
||||
// Best-effort 408 WITHOUT parking on writability — a client
|
||||
// that stalls reads must not defeat the timeout by making
|
||||
// the 408 write park forever.
|
||||
@@ -142,6 +150,11 @@ pub fn run_connection(
|
||||
let _ = registry.cast(Cast::ConnBusy(me));
|
||||
|
||||
// ----- 2. Read body. -----
|
||||
// The body has its OWN absolute budget, anchored here (head just
|
||||
// parsed) and independent of the head clock — a slow-but-legit
|
||||
// upload must not be judged by the short head deadline. Expiry
|
||||
// closes the connection (nothing owed mid-body).
|
||||
let body_deadline = Instant::now() + limits.body_timeout;
|
||||
// Content-Length pre-check only applies to fixed bodies; a chunked
|
||||
// body is bounded incrementally by the decoder.
|
||||
let body_len = parsed.content_length.unwrap_or(0);
|
||||
@@ -169,7 +182,7 @@ pub fn run_connection(
|
||||
// bottom of the loop must drop exactly this much to land on the
|
||||
// next pipelined request.
|
||||
let (body, consumed_past_head) = if parsed.chunked {
|
||||
match read_chunked_body(raw, &mut buf, parsed.head_len, &limits, request_deadline) {
|
||||
match read_chunked_body(raw, &mut buf, parsed.head_len, &limits, body_deadline) {
|
||||
Ok(ok) => ok,
|
||||
Err(ChunkedBodyErr::TooLarge) => {
|
||||
let _ = write_all(
|
||||
@@ -191,7 +204,7 @@ pub fn run_connection(
|
||||
Err(ChunkedBodyErr::Io(_)) => return,
|
||||
}
|
||||
} else {
|
||||
match read_body(raw, &mut buf, parsed.head_len, body_len, request_deadline) {
|
||||
match read_body(raw, &mut buf, parsed.head_len, body_len, body_deadline) {
|
||||
Ok(b) => (b, body_len),
|
||||
// Timeout mid-body (and any other body io error) -> just
|
||||
// close; there's no point talking HTTP to a client this far
|
||||
@@ -330,9 +343,9 @@ enum ReadHeadErr {
|
||||
/// keep_alive_timeout expired while waiting for the first byte of a
|
||||
/// request. Close silently.
|
||||
IdleTimeout,
|
||||
/// request_timeout expired after the request had started arriving.
|
||||
/// Best-effort 408.
|
||||
RequestTimeout,
|
||||
/// head_timeout expired after the request had started arriving but
|
||||
/// before the head finished parsing. Best-effort 408.
|
||||
HeadTimeout,
|
||||
Io(io::Error),
|
||||
Parse(ParseError),
|
||||
}
|
||||
@@ -347,22 +360,22 @@ enum ReadHeadErr {
|
||||
/// - while `buf` is empty and nothing has arrived, we are *idle* and the
|
||||
/// wait is bounded by `keep_alive_timeout`;
|
||||
/// - the instant the request has started (first byte read, or pipelined
|
||||
/// bytes already in `buf` at entry), the *request* clock starts: an
|
||||
/// `Instant` deadline of `request_timeout` from that moment, which also
|
||||
/// covers body reads — it is returned alongside the parsed head so the
|
||||
/// caller can thread it into `read_body`.
|
||||
/// bytes already in `buf` at entry), the *head* clock starts: an
|
||||
/// `Instant` deadline of `head_timeout` from that moment. This budget
|
||||
/// covers the HEAD only; the body has its own budget (`body_timeout`),
|
||||
/// which the caller anchors once the head has parsed.
|
||||
fn read_head(
|
||||
fd: RawFd,
|
||||
buf: &mut Vec<u8>,
|
||||
limits: &ConnLimits,
|
||||
) -> Result<(parser::ParsedHead, Instant), ReadHeadErr> {
|
||||
) -> Result<parser::ParsedHead, ReadHeadErr> {
|
||||
let entry = Instant::now();
|
||||
let idle_deadline = entry + limits.keep_alive_timeout;
|
||||
// Pipelined leftovers count as a started request.
|
||||
let mut request_deadline: Option<Instant> = if buf.is_empty() {
|
||||
let mut head_deadline: Option<Instant> = if buf.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(entry + limits.request_timeout)
|
||||
Some(entry + limits.head_timeout)
|
||||
};
|
||||
|
||||
loop {
|
||||
@@ -375,11 +388,7 @@ fn read_head(
|
||||
parser::parse_head(buf, limits.max_headers)
|
||||
};
|
||||
match head {
|
||||
Ok(h) => {
|
||||
let deadline = request_deadline
|
||||
.unwrap_or_else(|| Instant::now() + limits.request_timeout);
|
||||
return Ok((h, deadline));
|
||||
}
|
||||
Ok(h) => return Ok(h),
|
||||
Err(ParseError::Incomplete) => {} // need more bytes
|
||||
Err(e) => return Err(ReadHeadErr::Parse(e)),
|
||||
}
|
||||
@@ -390,19 +399,19 @@ fn read_head(
|
||||
}
|
||||
|
||||
// Read more, bounded by whichever budget is active.
|
||||
let deadline = request_deadline.unwrap_or(idle_deadline);
|
||||
let deadline = head_deadline.unwrap_or(idle_deadline);
|
||||
match read_some(fd, buf, limits.initial_read_buf, deadline) {
|
||||
Ok(0) => return Err(ReadHeadErr::ClientClosed),
|
||||
Ok(_) => {
|
||||
if request_deadline.is_none() {
|
||||
// First byte(s) of this request: the request clock
|
||||
if head_deadline.is_none() {
|
||||
// First byte(s) of this request: the head clock
|
||||
// starts now.
|
||||
request_deadline = Some(Instant::now() + limits.request_timeout);
|
||||
head_deadline = Some(Instant::now() + limits.head_timeout);
|
||||
}
|
||||
}
|
||||
Err(e) if e.kind() == ErrorKind::TimedOut => {
|
||||
return Err(if request_deadline.is_some() {
|
||||
ReadHeadErr::RequestTimeout
|
||||
return Err(if head_deadline.is_some() {
|
||||
ReadHeadErr::HeadTimeout
|
||||
} else {
|
||||
ReadHeadErr::IdleTimeout
|
||||
});
|
||||
@@ -433,8 +442,8 @@ fn read_body(
|
||||
return Ok(buf[head_len..head_len + body_len].to_vec());
|
||||
}
|
||||
|
||||
// Read until we have the rest, on the same request budget that the
|
||||
// head was read under.
|
||||
// Read until we have the rest, bounded by the body budget (anchored
|
||||
// by the caller when the head finished parsing).
|
||||
let mut total_read = already;
|
||||
while total_read < body_len {
|
||||
match read_some(fd, buf, 8 * 1024, deadline) {
|
||||
@@ -451,8 +460,9 @@ fn read_body(
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Decodes `Transfer-Encoding: chunked` from `buf[head_len..]`, reading more
|
||||
// from the socket as needed on the SAME request deadline the head was read
|
||||
// under. Returns (decoded_body, raw_bytes_consumed_past_head) — the raw
|
||||
// from the socket as needed on the body deadline (anchored by the caller
|
||||
// when the head finished parsing, independent of the head clock).
|
||||
// Returns (decoded_body, raw_bytes_consumed_past_head) — the raw
|
||||
// count includes all framing and the trailer section, so the caller's
|
||||
// keep-alive drain lands exactly on the next pipelined request.
|
||||
//
|
||||
|
||||
Reference in New Issue
Block a user