use std::time::Duration;
use axum::body::Body;
use axum::extract::Request;
use axum::middleware::Next;
use axum::response::Response;
use axum::extract::DefaultBodyLimit;
use axum::http::header::{self, CONNECTION};
use axum::http::StatusCode;
use bytes::BytesMut;
use http_body_util::BodyExt;
use tower_http::timeout::TimeoutLayer;
const DEFAULT_BODY_BYTES: usize = 1024 * 1024;
const DEFAULT_TIMEOUT_SECS: u64 = 30;
#[must_use]
pub fn body_limit_from_env() -> usize {
std::env::var("NAUTALID_HTTP_BODY_LIMIT_BYTES")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(DEFAULT_BODY_BYTES)
.max(1024)
}
#[must_use]
pub fn request_timeout_from_env() -> Duration {
let secs = std::env::var("NAUTALID_HTTP_TIMEOUT_SECS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(DEFAULT_TIMEOUT_SECS)
.max(1);
Duration::from_secs(secs)
}
#[must_use]
pub fn response_chunk_fits(sent: usize, chunk_len: usize, max_bytes: usize) -> bool {
sent.saturating_add(chunk_len) <= max_bytes
}
#[must_use]
pub fn h3_response_chunk_fits(sent: usize, chunk_len: usize, max_bytes: usize) -> bool {
response_chunk_fits(sent, chunk_len, max_bytes)
}
pub async fn cap_response_body(request: Request, next: Next) -> Response {
let max_bytes = body_limit_from_env();
let response = next.run(request).await;
let (mut parts, body) = response.into_parts();
let (body, truncated) =
cap_body_frames(body, max_bytes, request_timeout_from_env()).await;
if truncated {
parts.status = StatusCode::SERVICE_UNAVAILABLE;
parts.headers.insert(CONNECTION, header::HeaderValue::from_static("close"));
}
Response::from_parts(parts, body)
}
pub(crate) async fn cap_body_frames(body: Body, max_bytes: usize, read_timeout: Duration) -> (Body, bool) {
let deadline = tokio::time::Instant::now() + read_timeout;
let mut body = std::pin::pin!(body);
let mut out = BytesMut::new();
let mut truncated = false;
loop {
let frame = tokio::time::timeout_at(deadline, body.frame()).await;
let Ok(maybe_frame) = frame else {
tracing::warn!(max = max_bytes, "http response body read timed out at cap");
truncated = true;
break;
};
let Some(frame_result) = maybe_frame else {
break;
};
let Ok(frame) = frame_result else {
truncated = true;
break;
};
let Some(chunk) = frame.data_ref() else {
continue;
};
if chunk.is_empty() {
continue;
}
let room = max_bytes.saturating_sub(out.len());
if chunk.len() > room {
if room > 0 {
out.extend_from_slice(&chunk[..room]);
}
tracing::warn!(
sent = out.len(),
max = max_bytes,
"http response body truncated at limit"
);
truncated = true;
break;
}
out.extend_from_slice(chunk);
}
(Body::from(out.freeze()), truncated)
}
pub fn layers() -> (DefaultBodyLimit, TimeoutLayer) {
(
DefaultBodyLimit::max(body_limit_from_env()),
TimeoutLayer::with_status_code(StatusCode::REQUEST_TIMEOUT, request_timeout_from_env()),
)
}
#[cfg(test)]
mod tests {
use super::{cap_body_frames, response_chunk_fits, Body};
#[test]
fn response_chunk_fits_respects_limit() {
assert!(response_chunk_fits(0, 100, 100));
assert!(response_chunk_fits(50, 50, 100));
assert!(!response_chunk_fits(50, 51, 100));
assert!(!response_chunk_fits(usize::MAX, 1, 100));
}
#[tokio::test]
async fn cap_body_frames_truncates_large_response() {
let body = Body::from("0123456789".repeat(10));
let (capped, truncated) = cap_body_frames(body, 25, std::time::Duration::from_secs(5)).await;
let bytes = axum::body::to_bytes(capped, 128).await.expect("read capped body");
assert_eq!(bytes.len(), 25);
assert!(truncated);
}
}