use std::time::Duration;
use bytes::Bytes;
use http::HeaderMap;
use metrics::histogram;
use pingora_core::upstreams::peer::{HttpPeer, Peer as _};
use tracing::{debug, warn};
use super::{
body::dispose_session_abnormal,
internals::{
CircuitGuard, RawExchange, SUBREQUEST_HEADER_DURATION_SECONDS, SubRequestConnector, check_clean_completion,
clamp_peer_timeouts, classify_timeout, connection_nominated_tokens, empty_body_needs_framing,
ensure_host_header, is_boundary_stripped, is_request_stripped, min_timeout, record_header_termination,
},
types::{
FrameworkHeaders, StreamLimits, StreamingSubResponse, SubRequest, SubRequestError, SubResponse, SubResponseBody,
},
};
use crate::circuit::{CircuitCheck, PeerKey};
const MAX_INTERIM_RESPONSES: u32 = 32;
const EAGER_BODY_CAPACITY: usize = 131_072;
#[derive(Clone, Debug)]
pub struct SubRequestClient {
connector: SubRequestConnector,
pub(super) max_response_bytes: usize,
}
impl SubRequestClient {
pub fn new(connector: SubRequestConnector) -> Self {
Self {
connector,
max_response_bytes: crate::config::ABSOLUTE_MAX_BODY_BYTES,
}
}
pub fn with_max_response_bytes(connector: SubRequestConnector, max_response_bytes: usize) -> Self {
Self {
connector,
max_response_bytes,
}
}
pub fn connector(&self) -> &SubRequestConnector {
&self.connector
}
pub fn evict_idle_circuits(&self, idle_threshold: Duration) -> usize {
self.connector
.circuit_breakers
.as_ref()
.map_or(0, |registry| registry.evict_idle(idle_threshold))
}
#[expect(clippy::large_stack_frames, reason = "Pingora session types are large")]
#[expect(clippy::too_many_lines, reason = "sequential HTTP exchange steps")]
async fn open_exchange<'conn>(
&'conn self,
peer: &HttpPeer,
request: &SubRequest,
timeout: Duration,
framework_headers: Option<&FrameworkHeaders>,
) -> Result<RawExchange<'conn, 'conn>, SubRequestError> {
let exchange_started = tokio::time::Instant::now();
let deadline = exchange_started
.checked_add(timeout)
.ok_or(SubRequestError::DeadlineExceeded)?;
let mut bounded_peer = peer.clone();
clamp_peer_timeouts(&mut bounded_peer, timeout);
let path = request
.uri
.path_and_query()
.map_or(b"/".as_slice(), |pq| pq.as_str().as_bytes());
let mut req_header = pingora_http::RequestHeader::build(request.method.clone(), path, None)
.map_err(|err| SubRequestError::InvalidRequest(err.to_string()))?;
let nominated = connection_nominated_tokens(&request.headers);
for (name, value) in &request.headers {
if is_request_stripped(name, &nominated) {
continue;
}
let _append = req_header.append_header(name.clone(), value.clone());
}
drop(nominated);
if let Some(fw) = framework_headers {
for name in fw.removals() {
let _remove = req_header.remove_header(name);
}
for (name, value) in fw.iter() {
let _insert = req_header.insert_header(name.clone(), value.clone());
}
}
ensure_host_header(&mut req_header, &bounded_peer)?;
if !request.body.is_empty() || empty_body_needs_framing(&request.method) {
let _cl = req_header.insert_header("Content-Length", request.body.len().to_string());
}
let peer_key: Option<PeerKey> = bounded_peer
.address()
.as_inet()
.copied()
.map(|addr| PeerKey::new(addr, bounded_peer.sni.as_str()));
if let (Some(registry), Some(key)) = (&self.connector.circuit_breakers, &peer_key)
&& !registry.precheck(key)
{
return Err(SubRequestError::CircuitOpen { peer: key.to_string() });
}
let admission_budget = deadline.saturating_duration_since(tokio::time::Instant::now());
if admission_budget.is_zero() {
return Err(SubRequestError::DeadlineExceeded);
}
let permit = self.connector.try_acquire_permit(admission_budget).await?;
let circuit_guard = match (&self.connector.circuit_breakers, peer_key) {
(Some(registry), Some(key)) => match registry.try_acquire(key.clone()) {
CircuitCheck::Rejected => {
return Err(SubRequestError::CircuitOpen { peer: key.to_string() });
},
CircuitCheck::Allowed(token) => Some(CircuitGuard::new(registry, key, token)),
},
_ => None,
};
let connect_budget = deadline.saturating_duration_since(tokio::time::Instant::now());
if connect_budget.is_zero() {
return Err(SubRequestError::DeadlineExceeded);
}
let (mut session, reused) = tokio::time::timeout(
connect_budget,
Box::pin(self.connector.connector().get_http_session(&bounded_peer)),
)
.await
.map_err(|_elapsed| SubRequestError::DeadlineExceeded)?
.map_err(|err| SubRequestError::Connect(err.to_string()))?;
debug!(
peer = %bounded_peer.address(),
reused,
method = %request.method,
uri = %request.uri,
"sub-request: connected"
);
let header_budget = deadline.saturating_duration_since(tokio::time::Instant::now());
if header_budget.is_zero() {
return Err(SubRequestError::DeadlineExceeded);
}
let header_write_timeout = min_timeout(bounded_peer.options.write_timeout, header_budget);
tokio::time::timeout(header_write_timeout, session.write_request_header(Box::new(req_header)))
.await
.map_err(|_elapsed| classify_timeout(header_budget, bounded_peer.options.write_timeout, "write"))?
.map_err(|err| SubRequestError::Io(err.to_string()))?;
if !request.body.is_empty() {
let body_budget = deadline.saturating_duration_since(tokio::time::Instant::now());
if body_budget.is_zero() {
session.shutdown().await;
return Err(SubRequestError::DeadlineExceeded);
}
let body_write_timeout = min_timeout(bounded_peer.options.write_timeout, body_budget);
tokio::time::timeout(
body_write_timeout,
session.write_request_body(request.body.clone(), true),
)
.await
.map_err(|_elapsed| classify_timeout(body_budget, bounded_peer.options.write_timeout, "write"))?
.map_err(|err| SubRequestError::Io(err.to_string()))?;
}
let finish_budget = deadline.saturating_duration_since(tokio::time::Instant::now());
if finish_budget.is_zero() {
session.shutdown().await;
return Err(SubRequestError::DeadlineExceeded);
}
let finish_write_timeout = min_timeout(bounded_peer.options.write_timeout, finish_budget);
tokio::time::timeout(finish_write_timeout, session.finish_request_body())
.await
.map_err(|_elapsed| classify_timeout(finish_budget, bounded_peer.options.write_timeout, "write"))?
.map_err(|err| SubRequestError::Io(err.to_string()))?;
let mut interim_count = 0_u32;
let status = loop {
let read_budget = deadline.saturating_duration_since(tokio::time::Instant::now());
if read_budget.is_zero() {
session.shutdown().await;
return Err(SubRequestError::DeadlineExceeded);
}
let read_timeout = min_timeout(bounded_peer.options.read_timeout, read_budget);
tokio::time::timeout(read_timeout, session.read_response_header())
.await
.map_err(|_elapsed| classify_timeout(read_budget, bounded_peer.options.read_timeout, "read"))?
.map_err(|err| SubRequestError::Io(err.to_string()))?;
let resp_header = session
.response_header()
.ok_or_else(|| SubRequestError::Io("no response header received".to_owned()))?;
let status = resp_header.status.as_u16();
if (100..=199).contains(&status) && status != 101 {
interim_count = interim_count.saturating_add(1);
if interim_count > MAX_INTERIM_RESPONSES {
session.shutdown().await;
return Err(SubRequestError::Io(
"upstream sent too many 1xx interim responses".to_owned(),
));
}
continue;
}
break status;
};
if !(100..=599).contains(&status) {
session.shutdown().await;
return Err(SubRequestError::Io(format!(
"upstream returned unsupported HTTP status {status}"
)));
}
let resp_header = session
.response_header()
.ok_or_else(|| SubRequestError::Io("no response header received".to_owned()))?;
let resp_nominated = connection_nominated_tokens(&resp_header.headers);
let mut resp_headers = HeaderMap::with_capacity(resp_header.headers.len());
for (name, value) in &resp_header.headers {
if is_boundary_stripped(name, &resp_nominated) {
continue;
}
resp_headers.append(name.clone(), value.clone());
}
histogram!(SUBREQUEST_HEADER_DURATION_SECONDS).record(exchange_started.elapsed().as_secs_f64());
Ok(RawExchange {
session,
peer: bounded_peer,
connector: &self.connector,
status,
headers: resp_headers,
circuit_guard,
permit,
deadline,
})
}
#[expect(
clippy::too_many_arguments,
reason = "framework_headers is the typed metadata injection point"
)]
#[expect(clippy::large_stack_frames, reason = "Pingora session types are large")]
#[expect(clippy::too_many_lines, reason = "sequential HTTP exchange steps")]
pub async fn send_streaming(
&self,
peer: &HttpPeer,
request: &SubRequest,
timeout: Duration,
limits: StreamLimits,
framework_headers: Option<&FrameworkHeaders>,
) -> Result<StreamingSubResponse, SubRequestError> {
let mut exchange = self.open_exchange(peer, request, timeout, framework_headers).await?;
let circuit_guard = exchange.circuit_guard.take();
if exchange.session.response_done() {
match check_clean_completion(&mut exchange.session) {
Ok(true) => {},
Ok(false) => {
let err = SubRequestError::Io(
"upstream indicated response done but stream is not cleanly terminated".to_owned(),
);
return Err(
Box::pin(fail_header_exchange(exchange, circuit_guard, "header_incomplete", err)).await,
);
},
Err(err) => return Err(Box::pin(fail_header_exchange(exchange, circuit_guard, "h2_error", err)).await),
}
if let Some(guard) = circuit_guard {
guard.finalize_success();
}
exchange
.connector
.connector()
.release_http_session(exchange.session, &exchange.peer, None)
.await;
record_header_termination("header_only");
return Ok(StreamingSubResponse {
status: exchange.status,
headers: exchange.headers,
body: SubResponseBody::new_done(),
});
}
if let Some(guard) = circuit_guard {
guard.finalize_success();
}
let read_timeout = exchange.peer.options.read_timeout;
exchange.session.set_read_timeout(None);
let handoff_now = tokio::time::Instant::now();
let stream_deadline = limits
.max_stream_duration
.map(|dur| handoff_now.checked_add(dur).ok_or(SubRequestError::DeadlineExceeded))
.transpose()?;
let body = SubResponseBody {
session: Some(exchange.session),
peer: Some(exchange.peer),
connector: Some(exchange.connector.clone()),
permit: exchange.permit,
read_timeout,
idle_timeout: limits.idle_timeout,
stream_deadline,
max_total_bytes: limits.max_total_bytes,
received_bytes: 0,
chunk_count: 0,
stream_started_at: handoff_now,
done: false,
};
debug!(
status = exchange.status,
header_count = exchange.headers.len(),
"sub-request: streaming handoff"
);
Ok(StreamingSubResponse {
status: exchange.status,
headers: exchange.headers,
body,
})
}
#[expect(
clippy::too_many_arguments,
reason = "framework_headers is the typed metadata injection point"
)]
#[expect(clippy::large_stack_frames, reason = "Pingora session types are large")]
#[expect(clippy::too_many_lines, reason = "inline body collection loop")]
pub async fn execute(
&self,
peer: &HttpPeer,
request: &SubRequest,
max_response_bytes: usize,
timeout: Duration,
framework_headers: Option<&FrameworkHeaders>,
) -> Result<SubResponse, SubRequestError> {
let exchange = self.open_exchange(peer, request, timeout, framework_headers).await;
let RawExchange {
mut session,
peer: bounded_peer,
connector,
status,
headers: resp_headers,
circuit_guard,
permit: _permit,
deadline,
} = match exchange {
Ok(ex) => ex,
Err(err) => return Err(err),
};
let effective_limit = max_response_bytes.min(self.max_response_bytes);
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
return Err(SubRequestError::DeadlineExceeded);
}
let advertised = resp_headers
.get(http::header::CONTENT_LENGTH)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse::<usize>().ok())
.map_or(0, |len| len.min(effective_limit).min(EAGER_BODY_CAPACITY));
let body_result: Result<Bytes, SubRequestError> = tokio::time::timeout(remaining, async {
let mut body_buf = Vec::with_capacity(advertised);
while !session.response_done() {
match session.read_response_body().await {
Ok(Some(chunk)) => {
if body_buf.len().saturating_add(chunk.len()) > effective_limit {
warn!(
current = body_buf.len(),
chunk = chunk.len(),
limit = effective_limit,
"sub-request response body exceeded limit"
);
session.shutdown().await;
return Err(SubRequestError::ResponseTooLarge {
actual: body_buf.len().saturating_add(chunk.len()),
limit: effective_limit,
});
}
body_buf.extend_from_slice(&chunk);
},
Ok(None) => break,
Err(err) => {
session.shutdown().await;
return Err(SubRequestError::Io(err.to_string()));
},
}
}
debug!(status, body_bytes = body_buf.len(), "sub-request: response received");
connector
.connector()
.release_http_session(session, &bounded_peer, None)
.await;
Ok(Bytes::from(body_buf))
})
.await
.unwrap_or_else(|_elapsed| Err(SubRequestError::DeadlineExceeded));
let result = body_result.map(|body| SubResponse {
status,
headers: resp_headers,
body,
});
if let Some(guard) = circuit_guard {
guard.finalize(&result);
}
result
}
}
async fn fail_header_exchange(
exchange: RawExchange<'_, '_>,
circuit_guard: Option<CircuitGuard<'_>>,
termination: &'static str,
error: SubRequestError,
) -> SubRequestError {
drop(circuit_guard);
dispose_session_abnormal(
exchange.session,
Some(&exchange.peer),
Some(exchange.connector.connector()),
)
.await;
record_header_termination(termination);
error
}
#[cfg(test)]
#[expect(clippy::allow_attributes, reason = "blanket test suppressions")]
#[allow(clippy::too_many_lines, clippy::items_after_statements, reason = "tests")]
mod tests {
use super::*;
#[test]
fn max_interim_responses_constant_is_32() {
assert_eq!(MAX_INTERIM_RESPONSES, 32, "MAX_INTERIM_RESPONSES should equal 32");
}
#[test]
fn eager_body_capacity_constant_is_128_kib() {
assert_eq!(
EAGER_BODY_CAPACITY, 131_072,
"EAGER_BODY_CAPACITY should be 128 KiB in bytes"
);
assert_eq!(EAGER_BODY_CAPACITY, 128 * 1024, "EAGER_BODY_CAPACITY should be 128 KiB");
}
#[test]
fn new_client_uses_absolute_max_body_bytes() {
let connector = test_connector();
let client = SubRequestClient::new(connector);
assert_eq!(
client.max_response_bytes,
crate::config::ABSOLUTE_MAX_BODY_BYTES,
"new() should default to ABSOLUTE_MAX_BODY_BYTES"
);
}
#[test]
fn with_max_response_bytes_sets_ceiling() {
let connector = test_connector();
let custom_ceiling = 1_048_576;
let client = SubRequestClient::with_max_response_bytes(connector, custom_ceiling);
assert_eq!(
client.max_response_bytes, custom_ceiling,
"with_max_response_bytes() should set the provided ceiling"
);
}
#[test]
fn connector_accessor_returns_stored_connector() {
let connector = test_connector();
let client = SubRequestClient::new(connector.clone());
let retrieved = client.connector();
assert!(
!std::ptr::eq(retrieved, &connector),
"connector() should return reference to the stored connector"
);
}
#[test]
fn evict_idle_circuits_returns_zero_without_circuit_breaker() {
let connector = test_connector();
let client = SubRequestClient::new(connector);
let evicted = client.evict_idle_circuits(Duration::from_secs(60));
assert_eq!(
evicted, 0,
"evict_idle_circuits should return 0 when no circuit breaker is configured"
);
}
#[test]
fn debug_output_names_the_type() {
let connector = test_connector();
let client = SubRequestClient::new(connector);
let debug_str = format!("{client:?}");
assert!(
debug_str.contains("SubRequestClient"),
"Debug impl should include type name"
);
assert!(
debug_str.contains("connector"),
"Debug impl should show connector field"
);
assert!(
debug_str.contains("max_response_bytes"),
"Debug impl should show max_response_bytes field"
);
}
#[test]
fn clone_preserves_ceiling() {
let connector = test_connector();
let client = SubRequestClient::with_max_response_bytes(connector, 5_000_000);
let cloned = client.clone();
assert_eq!(
client.max_response_bytes, cloned.max_response_bytes,
"clone should preserve max_response_bytes"
);
}
#[test]
fn with_max_response_bytes_sets_various_ceilings() {
let connector = test_connector();
let test_cases = vec![
(1_000, "small ceiling"),
(10_000_000, "large ceiling"),
(EAGER_BODY_CAPACITY, "ceiling equal to eager capacity"),
(EAGER_BODY_CAPACITY / 2, "ceiling smaller than eager capacity"),
(EAGER_BODY_CAPACITY * 2, "ceiling larger than eager capacity"),
];
for (ceiling, description) in test_cases {
let client = SubRequestClient::with_max_response_bytes(connector.clone(), ceiling);
assert_eq!(client.max_response_bytes, ceiling, "Failed for case: {description}");
}
}
#[test]
fn with_max_response_bytes_accepts_boundary_values() {
let connector = test_connector();
let zero_client = SubRequestClient::with_max_response_bytes(connector.clone(), 0);
assert_eq!(
zero_client.max_response_bytes, 0,
"a ceiling of 0 should be stored unchanged"
);
let one_client = SubRequestClient::with_max_response_bytes(connector.clone(), 1);
assert_eq!(
one_client.max_response_bytes, 1,
"a ceiling of 1 should be stored unchanged"
);
let large_client = SubRequestClient::with_max_response_bytes(connector, usize::MAX);
assert_eq!(
large_client.max_response_bytes,
usize::MAX,
"a ceiling of usize::MAX should be stored unchanged"
);
}
#[test]
fn eager_capacity_caps_preallocation() {
let test_sizes = vec![
0,
1,
1024,
EAGER_BODY_CAPACITY - 1,
EAGER_BODY_CAPACITY,
EAGER_BODY_CAPACITY + 1,
EAGER_BODY_CAPACITY * 2,
10_485_760,
];
for size in test_sizes {
let effective_limit = 67_108_864;
let pre_alloc = size.min(effective_limit).min(EAGER_BODY_CAPACITY);
if size <= EAGER_BODY_CAPACITY {
assert_eq!(
pre_alloc, size,
"sizes <= EAGER_BODY_CAPACITY should not be capped (size: {size})"
);
} else {
assert_eq!(
pre_alloc, EAGER_BODY_CAPACITY,
"sizes > EAGER_BODY_CAPACITY should be capped (size: {size})"
);
}
}
}
#[test]
fn per_call_limit_clamps_to_client_ceiling() {
let connector = test_connector();
let client_ceiling = 1_048_576;
let client = SubRequestClient::with_max_response_bytes(connector, client_ceiling);
let test_cases = vec![
(500_000, 500_000, "per-call smaller than ceiling"),
(1_048_576, 1_048_576, "per-call equal to ceiling"),
(2_000_000, 1_048_576, "per-call larger than ceiling (should clamp)"),
(10_000_000, 1_048_576, "per-call much larger (should clamp)"),
(0, 0, "per-call zero"),
];
for (per_call_limit, expected_effective, description) in test_cases {
let effective = per_call_limit.min(client.max_response_bytes);
assert_eq!(effective, expected_effective, "Failed for case: {description}");
}
}
#[test]
fn limit_clamping_across_various_ceilings() {
let connector = test_connector();
struct TestCase {
client_ceiling: usize,
per_call_limit: usize,
expected_effective: usize,
description: &'static str,
}
let test_cases = vec![
TestCase {
client_ceiling: 1_000_000,
per_call_limit: 500_000,
expected_effective: 500_000,
description: "normal case: per-call < ceiling",
},
TestCase {
client_ceiling: 1_000_000,
per_call_limit: 2_000_000,
expected_effective: 1_000_000,
description: "clamp case: per-call > ceiling",
},
TestCase {
client_ceiling: 100,
per_call_limit: 1_000_000,
expected_effective: 100,
description: "tight ceiling: per-call >> ceiling",
},
TestCase {
client_ceiling: usize::MAX,
per_call_limit: 1_000_000,
expected_effective: 1_000_000,
description: "no ceiling: per-call is effective",
},
TestCase {
client_ceiling: 0,
per_call_limit: 1_000_000,
expected_effective: 0,
description: "zero ceiling: always zero",
},
];
for tc in test_cases {
let client = SubRequestClient::with_max_response_bytes(connector.clone(), tc.client_ceiling);
let effective = tc.per_call_limit.min(client.max_response_bytes);
let description = tc.description;
assert_eq!(effective, tc.expected_effective, "Failed for case: {description}");
}
}
#[test]
fn eager_body_capacity_is_kib_aligned() {
assert_eq!(EAGER_BODY_CAPACITY % 1024, 0, "should be KiB-aligned");
assert_eq!(EAGER_BODY_CAPACITY / 1024, 128, "should be exactly 128 KiB");
}
#[test]
fn interim_responses_limit_boundary() {
let limit = MAX_INTERIM_RESPONSES;
assert!(limit > 0, "limit should be positive");
assert!(limit < 1000, "limit should be reasonable");
let acceptable_count = limit;
let unacceptable_count = limit + 1;
assert!(
acceptable_count <= limit,
"exactly {limit} interim responses should be within limit"
);
assert!(
unacceptable_count > limit,
"{unacceptable_count} interim responses should exceed limit"
);
}
#[test]
fn multiple_clients_keep_independent_limits() {
let connector = test_connector();
let client_a = SubRequestClient::with_max_response_bytes(connector.clone(), 1_000_000);
let client_b = SubRequestClient::with_max_response_bytes(connector.clone(), 5_000_000);
let client_c = SubRequestClient::new(connector);
assert_eq!(
client_a.max_response_bytes, 1_000_000,
"client_a should keep its own ceiling"
);
assert_eq!(
client_b.max_response_bytes, 5_000_000,
"client_b should keep its own ceiling"
);
assert_eq!(
client_c.max_response_bytes,
crate::config::ABSOLUTE_MAX_BODY_BYTES,
"client_c should default to ABSOLUTE_MAX_BODY_BYTES"
);
}
#[test]
fn evict_idle_circuits_returns_zero_for_any_duration() {
let connector = test_connector();
let client = SubRequestClient::new(connector);
let durations = vec![
Duration::from_secs(0),
Duration::from_millis(1),
Duration::from_secs(1),
Duration::from_secs(60),
Duration::from_secs(3600),
Duration::from_secs(86400),
];
for duration in durations {
let evicted = client.evict_idle_circuits(duration);
assert_eq!(
evicted, 0,
"should always return 0 when no circuit breaker, duration: {duration:?}"
);
}
}
fn test_connector() -> SubRequestConnector {
praxis_tls::provider::install();
SubRequestConnector::new(128, None)
}
}