Skip to main content

praxis_core/subrequest/
client.rs

1// SPDX-License-Identifier: MIT
2// Copyright (c) 2026 Praxis Contributors
3
4use std::time::Duration;
5
6use bytes::Bytes;
7use http::HeaderMap;
8use metrics::histogram;
9use pingora_core::upstreams::peer::{HttpPeer, Peer as _};
10use tracing::{debug, warn};
11
12use super::{
13    body::dispose_session_abnormal,
14    internals::{
15        CircuitGuard, RawExchange, SUBREQUEST_HEADER_DURATION_SECONDS, SubRequestConnector, check_clean_completion,
16        clamp_peer_timeouts, classify_timeout, empty_body_needs_framing, ensure_host_header, min_timeout,
17        record_header_termination, strip_hop_by_hop_headers, strip_request_framing_headers, strip_reserved_headers,
18    },
19    types::{
20        FrameworkHeaders, StreamLimits, StreamingSubResponse, SubRequest, SubRequestError, SubResponse, SubResponseBody,
21    },
22};
23use crate::circuit::{CircuitCheck, PeerKey};
24
25// ---------------------------------------------------------------------------
26// SubRequestClient
27// ---------------------------------------------------------------------------
28
29/// Hardened sub-request executor wrapping a shared connector.
30///
31/// Provides a safe, bounded execution API that enforces:
32///
33/// - An overall deadline covering admission, connect, and I/O.
34/// - Bounded response body reads: each call supplies a per-call limit, clamped to the client-wide ceiling set at
35///   construction via [`with_max_response_bytes`]. The server derives this ceiling from
36///   `body_limits.max_response_bytes`.
37/// - Hop-by-hop header sanitization on both request and response.
38/// - Proper `Host` framing.
39///
40/// [`with_max_response_bytes`]: Self::with_max_response_bytes
41///
42/// Callers own routing, retries, circuit breaking, SSRF policy,
43/// depth propagation, and status interpretation.
44///
45/// Supports both buffered ([`execute()`](Self::execute)) and streaming
46/// ([`send_streaming()`](Self::send_streaming)) response modes.
47///
48/// ```
49/// use praxis_core::subrequest::{SubRequestClient, SubRequestConnector};
50///
51/// let connector = SubRequestConnector::new(128, None);
52/// let client = SubRequestClient::new(connector);
53/// ```
54#[derive(Clone, Debug)]
55pub struct SubRequestClient {
56    /// Wrapped shared connector.
57    connector: SubRequestConnector,
58
59    /// Hard ceiling on buffered response bytes. Per-call limits are
60    /// clamped to `min(this, per_call)` so callers cannot exceed it.
61    pub(super) max_response_bytes: usize,
62}
63
64impl SubRequestClient {
65    /// Create a client wrapping the given shared connector.
66    ///
67    /// Defaults the client-wide response ceiling to
68    /// [`ABSOLUTE_MAX_BODY_BYTES`] (64 MiB). Use
69    /// [`with_max_response_bytes`] for a tighter cap.
70    ///
71    /// [`ABSOLUTE_MAX_BODY_BYTES`]: crate::config::ABSOLUTE_MAX_BODY_BYTES
72    /// [`with_max_response_bytes`]: Self::with_max_response_bytes
73    pub fn new(connector: SubRequestConnector) -> Self {
74        Self {
75            connector,
76            max_response_bytes: crate::config::ABSOLUTE_MAX_BODY_BYTES,
77        }
78    }
79
80    /// Create a client with an explicit response ceiling.
81    ///
82    /// Every `execute()` call clamps its per-call limit to
83    /// `min(per_call, ceiling)`, preventing callers from
84    /// exceeding the global cap.
85    pub fn with_max_response_bytes(connector: SubRequestConnector, max_response_bytes: usize) -> Self {
86        Self {
87            connector,
88            max_response_bytes,
89        }
90    }
91
92    /// Access the underlying connector for direct pool operations.
93    pub fn connector(&self) -> &SubRequestConnector {
94        &self.connector
95    }
96
97    /// Evict idle circuit breaker entries that have been healthy for
98    /// at least `idle_threshold`. Returns the number of entries
99    /// removed, or `0` if no circuit breaker is configured.
100    pub fn evict_idle_circuits(&self, idle_threshold: Duration) -> usize {
101        self.connector
102            .circuit_breakers
103            .as_ref()
104            .map_or(0, |registry| registry.evict_idle(idle_threshold))
105    }
106
107    /// Shared transport path: admission, connect, I/O, header validation.
108    ///
109    /// Returns a live `RawExchange` owning the session, peer, sanitized
110    /// headers, circuit guard, and admission permit. Both `execute()`
111    /// and `send_streaming()` call this, then diverge.
112    #[expect(clippy::large_stack_frames, reason = "Pingora session types are large")]
113    #[expect(clippy::too_many_lines, reason = "sequential HTTP exchange steps")]
114    async fn open_exchange<'a>(
115        &'a self,
116        peer: &HttpPeer,
117        request: &SubRequest,
118        timeout: Duration,
119        framework_headers: Option<&FrameworkHeaders>,
120    ) -> Result<RawExchange<'a>, SubRequestError> {
121        let exchange_started = tokio::time::Instant::now();
122        let deadline = tokio::time::Instant::now()
123            .checked_add(timeout)
124            .ok_or(SubRequestError::DeadlineExceeded)?;
125        let mut bounded_peer = peer.clone();
126        clamp_peer_timeouts(&mut bounded_peer, timeout);
127
128        // -- 1. Validate request (before any circuit/admission state) --
129        let path = request
130            .uri
131            .path_and_query()
132            .map_or(b"/".as_slice(), |pq| pq.as_str().as_bytes());
133        let mut req_header = pingora_http::RequestHeader::build(request.method.clone(), path, None)
134            .map_err(|e| SubRequestError::InvalidRequest(e.to_string()))?;
135
136        let mut sanitized = request.headers.clone();
137        strip_hop_by_hop_headers(&mut sanitized);
138        strip_request_framing_headers(&mut sanitized);
139        strip_reserved_headers(&mut sanitized);
140        if let Some(fw) = framework_headers {
141            for (name, value) in fw.iter() {
142                sanitized.insert(name.clone(), value.clone());
143            }
144        }
145        for (name, value) in &sanitized {
146            let _append = req_header.append_header(name.clone(), value.clone());
147        }
148        ensure_host_header(&mut req_header, &bounded_peer)?;
149        if !request.body.is_empty() || empty_body_needs_framing(&request.method) {
150            let _cl = req_header.insert_header("Content-Length", request.body.len().to_string());
151        }
152
153        // -- 2. Circuit precheck --
154        let peer_key: Option<PeerKey> = bounded_peer
155            .address()
156            .as_inet()
157            .copied()
158            .map(|addr| PeerKey::new(addr, bounded_peer.sni.as_str()));
159
160        if let (Some(registry), Some(key)) = (&self.connector.circuit_breakers, &peer_key)
161            && !registry.precheck(key)
162        {
163            return Err(SubRequestError::CircuitOpen { peer: key.to_string() });
164        }
165
166        // -- 3. Admission --
167        let admission_budget = deadline.saturating_duration_since(tokio::time::Instant::now());
168        if admission_budget.is_zero() {
169            return Err(SubRequestError::DeadlineExceeded);
170        }
171        let permit = self.connector.try_acquire_permit(admission_budget).await?;
172
173        // -- 4. Circuit try_acquire --
174        let circuit_guard = match (&self.connector.circuit_breakers, peer_key) {
175            (Some(registry), Some(key)) => match registry.try_acquire(key.clone()) {
176                CircuitCheck::Rejected => {
177                    return Err(SubRequestError::CircuitOpen { peer: key.to_string() });
178                },
179                CircuitCheck::Allowed(token) => Some(CircuitGuard::new(registry, key, token)),
180            },
181            _ => None,
182        };
183
184        // -- 5. Connect + I/O --
185        let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
186        if remaining.is_zero() {
187            return Err(SubRequestError::DeadlineExceeded);
188        }
189
190        let (mut session, reused) = tokio::time::timeout(
191            remaining,
192            Box::pin(self.connector.connector().get_http_session(&bounded_peer)),
193        )
194        .await
195        .map_err(|_elapsed| SubRequestError::DeadlineExceeded)?
196        .map_err(|e| SubRequestError::Connect(e.to_string()))?;
197
198        debug!(
199            peer = %bounded_peer.address(),
200            reused,
201            method = %request.method,
202            uri = %request.uri,
203            "sub-request: connected"
204        );
205
206        let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
207        if remaining.is_zero() {
208            return Err(SubRequestError::DeadlineExceeded);
209        }
210
211        let write_timeout = min_timeout(bounded_peer.options.write_timeout, remaining);
212        tokio::time::timeout(write_timeout, session.write_request_header(Box::new(req_header)))
213            .await
214            .map_err(|_elapsed| classify_timeout(remaining, bounded_peer.options.write_timeout, "write"))?
215            .map_err(|e| SubRequestError::Io(e.to_string()))?;
216
217        if !request.body.is_empty() {
218            let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
219            if remaining.is_zero() {
220                session.shutdown().await;
221                return Err(SubRequestError::DeadlineExceeded);
222            }
223            let write_timeout = min_timeout(bounded_peer.options.write_timeout, remaining);
224            tokio::time::timeout(write_timeout, session.write_request_body(request.body.clone(), true))
225                .await
226                .map_err(|_elapsed| classify_timeout(remaining, bounded_peer.options.write_timeout, "write"))?
227                .map_err(|e| SubRequestError::Io(e.to_string()))?;
228        }
229
230        let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
231        if remaining.is_zero() {
232            session.shutdown().await;
233            return Err(SubRequestError::DeadlineExceeded);
234        }
235        let write_timeout = min_timeout(bounded_peer.options.write_timeout, remaining);
236        tokio::time::timeout(write_timeout, session.finish_request_body())
237            .await
238            .map_err(|_elapsed| classify_timeout(remaining, bounded_peer.options.write_timeout, "write"))?
239            .map_err(|e| SubRequestError::Io(e.to_string()))?;
240
241        let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
242        if remaining.is_zero() {
243            session.shutdown().await;
244            return Err(SubRequestError::DeadlineExceeded);
245        }
246        let read_timeout = min_timeout(bounded_peer.options.read_timeout, remaining);
247
248        tokio::time::timeout(read_timeout, session.read_response_header())
249            .await
250            .map_err(|_elapsed| classify_timeout(remaining, bounded_peer.options.read_timeout, "read"))?
251            .map_err(|e| SubRequestError::Io(e.to_string()))?;
252
253        // -- 6. Validate response --
254        let resp_header = session
255            .response_header()
256            .ok_or_else(|| SubRequestError::Io("no response header received".to_owned()))?;
257
258        let status = resp_header.status.as_u16();
259        if !(100..=599).contains(&status) {
260            session.shutdown().await;
261            return Err(SubRequestError::Io(format!(
262                "upstream returned unsupported HTTP status {status}"
263            )));
264        }
265        let mut resp_headers = HeaderMap::new();
266        for (name, value) in &resp_header.headers {
267            if let Ok(v) = http::header::HeaderValue::from_bytes(value.as_bytes()) {
268                resp_headers.append(name.clone(), v);
269            }
270        }
271        strip_hop_by_hop_headers(&mut resp_headers);
272        strip_reserved_headers(&mut resp_headers);
273
274        // -- 7. Return RawExchange --
275        histogram!(SUBREQUEST_HEADER_DURATION_SECONDS).record(exchange_started.elapsed().as_secs_f64());
276
277        Ok(RawExchange {
278            session,
279            peer: bounded_peer,
280            connector: &self.connector,
281            status,
282            headers: resp_headers,
283            circuit_guard,
284            permit,
285            deadline,
286        })
287    }
288
289    /// Send a streaming sub-request.
290    ///
291    /// Acquires admission, connects to `peer`, sends `request`, reads
292    /// response headers, and returns a [`StreamingSubResponse`] with
293    /// an opaque body handle for incremental chunk reads.
294    ///
295    /// Circuit breaker success is finalized only when the header
296    /// exchange completes cleanly (header-only response or a streaming
297    /// body); a header-incomplete or H2-error termination records a
298    /// failure. Late body failures only affect stream metrics.
299    ///
300    /// **Timeout semantics:** `timeout` bounds only the header phase
301    /// (connect + send + receive headers). Body reads are governed by
302    /// [`StreamLimits`]: `idle_timeout` per chunk, optional
303    /// `max_stream_duration` for end-to-end lifetime, and the peer's
304    /// configured `read_timeout`. Callers needing a single end-to-end
305    /// deadline should set `max_stream_duration` accordingly.
306    ///
307    /// # Errors
308    ///
309    /// Returns [`SubRequestError`] on admission timeout, connection
310    /// failure, I/O error, or deadline expiry during the header phase.
311    #[expect(
312        clippy::too_many_arguments,
313        reason = "framework_headers is the typed metadata injection point"
314    )]
315    #[expect(clippy::large_stack_frames, reason = "Pingora session types are large")]
316    #[expect(clippy::too_many_lines, reason = "sequential HTTP exchange steps")]
317    pub async fn send_streaming(
318        &self,
319        peer: &HttpPeer,
320        request: &SubRequest,
321        timeout: Duration,
322        limits: StreamLimits,
323        framework_headers: Option<&FrameworkHeaders>,
324    ) -> Result<StreamingSubResponse, SubRequestError> {
325        let mut exchange = self.open_exchange(peer, request, timeout, framework_headers).await?;
326
327        // Hold the circuit guard until the header-time outcome is known.
328        // Finalizing success here (before the completion check below) would
329        // record a header-incomplete or H2-error termination as a circuit
330        // success, masking a real upstream failure. On the failure paths the
331        // guard is dropped, which records a failure via its Drop impl.
332        let circuit_guard = exchange.circuit_guard.take();
333
334        // Check for header-time completion (HEAD, 204, 304, zero-length).
335        if exchange.session.response_done() {
336            match check_clean_completion(&mut exchange.session) {
337                Ok(true) => {},
338                Ok(false) => {
339                    let e = SubRequestError::Io(
340                        "upstream indicated response done but stream is not cleanly terminated".to_owned(),
341                    );
342                    return Err(Box::pin(fail_header_exchange(exchange, circuit_guard, "header_incomplete", e)).await);
343                },
344                Err(e) => return Err(Box::pin(fail_header_exchange(exchange, circuit_guard, "h2_error", e)).await),
345            }
346            if let Some(guard) = circuit_guard {
347                guard.finalize_success();
348            }
349            exchange
350                .connector
351                .connector()
352                .release_http_session(exchange.session, &exchange.peer, None)
353                .await;
354            record_header_termination("header_only");
355            return Ok(StreamingSubResponse {
356                status: exchange.status,
357                headers: exchange.headers,
358                body: SubResponseBody::new_done(),
359            });
360        }
361
362        // Valid headers received and the response is streaming: the header
363        // exchange succeeded, so finalize the circuit guard as success. The
364        // body may still fail later, but the guard is scoped to the header
365        // exchange.
366        if let Some(guard) = circuit_guard {
367            guard.finalize_success();
368        }
369
370        // Capture the operator-configured read timeout before clearing
371        // Pingora's internal timer. next_chunk() enforces it externally
372        // alongside idle_timeout and stream_deadline.
373        let read_timeout = exchange.peer.options.read_timeout;
374        exchange.session.set_read_timeout(None);
375
376        // Compute stream deadline from max_stream_duration.
377        let stream_deadline = limits
378            .max_stream_duration
379            .map(|d| {
380                tokio::time::Instant::now()
381                    .checked_add(d)
382                    .ok_or(SubRequestError::DeadlineExceeded)
383            })
384            .transpose()?;
385
386        let body = SubResponseBody {
387            session: Some(exchange.session),
388            peer: Some(exchange.peer),
389            connector: Some(exchange.connector.clone()),
390            permit: exchange.permit,
391            read_timeout,
392            idle_timeout: limits.idle_timeout,
393            stream_deadline,
394            max_total_bytes: limits.max_total_bytes,
395            received_bytes: 0,
396            chunk_count: 0,
397            stream_started_at: tokio::time::Instant::now(),
398            done: false,
399        };
400
401        debug!(
402            status = exchange.status,
403            header_count = exchange.headers.len(),
404            "sub-request: streaming handoff"
405        );
406
407        Ok(StreamingSubResponse {
408            status: exchange.status,
409            headers: exchange.headers,
410            body,
411        })
412    }
413
414    /// Execute a buffered sub-request.
415    ///
416    /// Acquires an admission permit (inside the deadline), connects
417    /// to `peer`, sends `request`, reads the full response (bounded
418    /// by `max_response_bytes`), and returns a [`SubResponse`].
419    ///
420    /// Transport-level headers (hop-by-hop, `Connection`-nominated)
421    /// and reserved internal headers (`x-praxis-*`, `x-ext-*`) are
422    /// stripped from both request and response.
423    ///
424    /// `framework_headers` are injected **after** all sanitisation
425    /// passes. The [`FrameworkHeaders`] type validates at insertion
426    /// time that no transport-level or reserved internal header
427    /// (`x-praxis-*`, `x-ext-*`) can be added, so callers cannot
428    /// reintroduce sanitised headers.
429    ///
430    /// # Errors
431    ///
432    /// Returns [`SubRequestError`] on admission timeout, connection
433    /// failure, I/O error, response body exceeding the size limit,
434    /// or deadline expiry.
435    #[expect(
436        clippy::too_many_arguments,
437        reason = "framework_headers is the typed metadata injection point"
438    )]
439    #[expect(clippy::large_stack_frames, reason = "Pingora session types are large")]
440    #[expect(clippy::too_many_lines, reason = "inline body collection loop")]
441    pub async fn execute(
442        &self,
443        peer: &HttpPeer,
444        request: &SubRequest,
445        max_response_bytes: usize,
446        timeout: Duration,
447        framework_headers: Option<&FrameworkHeaders>,
448    ) -> Result<SubResponse, SubRequestError> {
449        let exchange = self.open_exchange(peer, request, timeout, framework_headers).await;
450
451        let RawExchange {
452            mut session,
453            peer: bounded_peer,
454            connector,
455            status,
456            headers: resp_headers,
457            circuit_guard,
458            permit: _permit,
459            deadline,
460        } = match exchange {
461            Ok(ex) => ex,
462            Err(e) => return Err(e),
463        };
464
465        let effective_limit = max_response_bytes.min(self.max_response_bytes);
466
467        // Enforce deadline on body collection phase.
468        let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
469        if remaining.is_zero() {
470            return Err(SubRequestError::DeadlineExceeded);
471        }
472
473        let body_result: Result<Bytes, SubRequestError> = tokio::time::timeout(remaining, async {
474            let mut body_buf = Vec::new();
475            while !session.response_done() {
476                match session.read_response_body().await {
477                    Ok(Some(chunk)) => {
478                        if body_buf.len() + chunk.len() > effective_limit {
479                            warn!(
480                                current = body_buf.len(),
481                                chunk = chunk.len(),
482                                limit = effective_limit,
483                                "sub-request response body exceeded limit"
484                            );
485                            session.shutdown().await;
486                            return Err(SubRequestError::ResponseTooLarge {
487                                actual: body_buf.len() + chunk.len(),
488                                limit: effective_limit,
489                            });
490                        }
491                        body_buf.extend_from_slice(&chunk);
492                    },
493                    Ok(None) => break,
494                    Err(e) => {
495                        session.shutdown().await;
496                        return Err(SubRequestError::Io(e.to_string()));
497                    },
498                }
499            }
500
501            debug!(status, body_bytes = body_buf.len(), "sub-request: response received");
502
503            connector
504                .connector()
505                .release_http_session(session, &bounded_peer, None)
506                .await;
507
508            Ok(Bytes::from(body_buf))
509        })
510        .await
511        .unwrap_or_else(|_elapsed| Err(SubRequestError::DeadlineExceeded));
512
513        // Finalize circuit guard with full-exchange outcome.
514        let result = body_result.map(|body| SubResponse {
515            status,
516            headers: resp_headers,
517            body,
518        });
519        if let Some(guard) = circuit_guard {
520            guard.finalize(&result);
521        }
522        result
523    }
524}
525
526// ---------------------------------------------------------------------------
527// Private Utilities
528// ---------------------------------------------------------------------------
529
530/// Tear down an abnormally terminated header exchange: drop the circuit
531/// guard (recording a failure via its `Drop` impl), discard the session,
532/// record the termination metric, and hand back the error to return.
533async fn fail_header_exchange(
534    exchange: RawExchange<'_>,
535    circuit_guard: Option<CircuitGuard<'_>>,
536    termination: &str,
537    error: SubRequestError,
538) -> SubRequestError {
539    drop(circuit_guard);
540    dispose_session_abnormal(
541        exchange.session,
542        Some(&exchange.peer),
543        Some(exchange.connector.connector()),
544    )
545    .await;
546    record_header_termination(termination);
547    error
548}