Skip to main content

praxis_protocol/http/pingora/handler/
mod.rs

1// SPDX-License-Identifier: MIT
2// Copyright (c) 2024 Praxis Contributors
3
4//! Pingora `ProxyHttp` implementation: the main HTTP reverse-proxy
5//! handler.
6//!
7//! Bridges Pingora's hook-based lifecycle (`request_filter`,
8//! `upstream_peer`, `upstream_request_filter`, etc.) to the Praxis
9//! filter pipeline. Two handler variants exist:
10//! `PingoraHttpHandler` enables body filter hooks when the
11//! pipeline declares body access; `PingoraHttpHandlerNoBody`
12//! skips them for zero-overhead forwarding.
13//!
14//! Each submodule implements one Pingora hook. The pipeline is held
15//! behind `Arc<ArcSwap<FilterPipeline>>` for lock-free hot reload.
16
17use std::{collections::HashMap, sync::Arc, time::Duration};
18
19use arc_swap::ArcSwap;
20use bytes::Bytes;
21use pingora_core::{Result, apps::HttpServerOptions, server::Server, services::listening::Service};
22use pingora_proxy::{Session, http_proxy};
23use praxis_core::{config::ABSOLUTE_MAX_BODY_BYTES, connectivity::Upstream};
24use praxis_filter::{BodyBuffer, BodyMode, CompressionConfig, FilterPipeline, HttpFilterContext, RequestExtensions};
25use tokio::sync::Semaphore;
26use tracing::{debug, warn};
27
28use super::{context::PingoraRequestCtx, metrics};
29
30/// Shared hop-by-hop header stripping logic.
31mod hop_by_hop;
32/// HTTP handler without body filter hooks.
33mod no_body;
34/// Request header normalization (duplicate headers, obs-fold).
35mod normalize;
36/// Request body filter hook.
37mod request_body_filter;
38/// Request filter hook.
39mod request_filter;
40/// Reserved internal header helpers.
41mod reserved_headers;
42/// Response body filter hook.
43mod response_body_filter;
44/// Response filter hook.
45mod response_filter;
46/// Upstream peer selection hook.
47mod upstream_peer;
48/// Upstream request transformation hook.
49mod upstream_request;
50/// Upstream response hop-by-hop stripping hook.
51mod upstream_response;
52/// Via header injection hook.
53mod via;
54/// HTTP handler with body filter hooks.
55mod with_body;
56
57pub use no_body::PingoraHttpHandlerNoBody;
58pub use with_body::PingoraHttpHandler;
59
60// -----------------------------------------------------------------------------
61// Constants
62// -----------------------------------------------------------------------------
63
64/// Maximum number of upstream connection retries for idempotent requests.
65const MAX_RETRIES: usize = 3;
66
67/// Pingora still replays retry attempts from a fixed-size internal buffer.
68///
69/// `StreamBuffer` initial forwarding uses Praxis-owned `pre_read_body`, but
70/// retry replay cannot safely cover bodies larger than Pingora's retry
71/// buffer.
72const RETRY_BODY_LIMIT: u64 = 65_536; // 64 KiB
73
74// -----------------------------------------------------------------------------
75// Load Handler
76// -----------------------------------------------------------------------------
77
78/// Load an HTTP handler for a single listener.
79///
80/// Any TLS certificate watcher shutdown senders are appended to
81/// `cert_watcher_shutdowns`. The caller must keep this `Vec` alive
82/// until server shutdown; dropping the senders signals the watcher
83/// tasks to stop.
84///
85/// ```ignore
86/// use std::sync::Arc;
87///
88/// use pingora_core::server::Server;
89/// use praxis_core::config::Listener;
90/// use praxis_filter::{FilterPipeline, FilterRegistry};
91/// use praxis_protocol::http::pingora::handler::load_http_handler;
92///
93/// let mut server = Server::new(None).unwrap();
94/// server.bootstrap();
95/// let registry = FilterRegistry::with_builtins();
96/// let pipeline = Arc::new(FilterPipeline::build(&mut [], &registry).unwrap());
97/// let listener = Listener {
98///     name: "http".into(),
99///     address: "127.0.0.1:8080".into(),
100///     cluster: None,
101///     downstream_read_timeout_ms: None,
102///     filter_chains: vec![],
103///     max_connections: None,
104///     protocol: Default::default(),
105///     tcp_session_timeout_ms: None,
106///     tcp_max_duration_secs: None,
107///     tls: None,
108///     upstream: None,
109/// };
110/// let mut shutdowns = Vec::new();
111/// load_http_handler(&mut server, &listener, pipeline, &mut shutdowns).unwrap();
112/// ```
113///
114/// # Errors
115///
116/// Returns [`ProxyError`] if the listener fails to bind.
117///
118/// [`ProxyError`]: praxis_core::ProxyError
119pub fn load_http_handler(
120    server: &mut Server,
121    listener: &praxis_core::config::Listener,
122    pipeline: Arc<ArcSwap<FilterPipeline>>,
123    cert_watcher_shutdowns: &mut Vec<tokio::sync::watch::Sender<bool>>,
124) -> Result<(), praxis_core::ProxyError> {
125    let downstream_read_timeout = listener.downstream_read_timeout_ms.map(Duration::from_millis);
126    let connection_semaphore = listener
127        .max_connections
128        .map(|max| Arc::new(Semaphore::new(max as usize)));
129
130    // Always use the body-capable handler: a reload may add body
131    // filters, and compression init is one-shot in Pingora.
132    debug!(listener = %listener.name, "loading HTTP handler with body filters");
133    let handler = PingoraHttpHandler::new(pipeline, downstream_read_timeout, connection_semaphore);
134    wire_service(server, listener, handler, cert_watcher_shutdowns)?;
135    Ok(())
136}
137
138/// Create a Pingora HTTP proxy service, bind the listener, and add it to the server.
139fn wire_service<H>(
140    server: &mut Server,
141    listener: &praxis_core::config::Listener,
142    handler: H,
143    cert_watcher_shutdowns: &mut Vec<tokio::sync::watch::Sender<bool>>,
144) -> Result<(), praxis_core::ProxyError>
145where
146    H: pingora_proxy::ProxyHttp + Send + Sync + 'static,
147    H::CTX: Send + Sync,
148{
149    let service_name = format!("http-proxy:{name}", name = listener.name);
150    let mut proxy = http_proxy(&server.configuration, handler);
151    proxy.server_options = Some(h2c_server_options());
152    let mut service = Service::new(service_name, proxy);
153    if let Some(tx) = super::listener::add_listener(&mut service, listener)? {
154        cert_watcher_shutdowns.push(tx);
155    }
156    server.add_service(service);
157    Ok(())
158}
159
160// -----------------------------------------------------------------------------
161// Shared Utilities
162// -----------------------------------------------------------------------------
163
164/// Clamp a runtime-selected body mode to the byte ceiling implied by `baseline`.
165///
166/// `baseline` is the mode established before request/response-phase filter hooks
167/// run (typically from pipeline capabilities + global body limits). Runtime
168/// `set_*_body_mode` calls may widen limits; this helper preserves the original
169/// ceiling while still allowing upgrades between body mode variants.
170///
171/// `Stream` mode passes through unconditionally because it delivers chunks
172/// as they arrive without accumulating them — there is no buffer to cap.
173/// A filter that downgrades from `StreamBuffer` to `Stream` at runtime is
174/// opting out of buffering entirely, which is always safe from a memory
175/// perspective. The pipeline-level body size limit (enforced separately
176/// via `SizeLimit`) remains the backstop for oversized payloads.
177fn clamp_body_mode_to_ceiling(mode: BodyMode, baseline: BodyMode) -> BodyMode {
178    let ceiling = match baseline {
179        BodyMode::StreamBuffer { max_bytes: Some(v) } | BodyMode::SizeLimit { max_bytes: v } => Some(v),
180        _ => None,
181    };
182
183    match (mode, ceiling) {
184        (BodyMode::StreamBuffer { max_bytes }, Some(limit)) => BodyMode::StreamBuffer {
185            max_bytes: Some(max_bytes.map_or(limit, |v| v.min(limit))),
186        },
187        (BodyMode::SizeLimit { max_bytes }, Some(limit)) => BodyMode::SizeLimit {
188            max_bytes: max_bytes.min(limit),
189        },
190        // Stream has no buffer to clamp; other modes pass through when the
191        // baseline imposes no ceiling (e.g. unbounded StreamBuffer).
192        (m, None | Some(_)) => m,
193    }
194}
195
196/// Apply compression settings from the pipeline config to the Pingora response.
197fn adjust_compression(
198    session: &mut Session,
199    upstream_response: &pingora_http::ResponseHeader,
200    compression: Option<&CompressionConfig>,
201) {
202    use pingora_core::{modules::http::compression::ResponseCompression, protocols::http::compression::Algorithm};
203
204    let Some(cfg) = compression else {
205        return;
206    };
207
208    let Some(module) = session.downstream_modules_ctx.get_mut::<ResponseCompression>() else {
209        return;
210    };
211
212    let headers = &upstream_response.headers;
213
214    if !cfg.should_compress(headers) {
215        debug!("disabling compression: response does not qualify");
216        module.adjust_level(0);
217        return;
218    }
219
220    for (enabled, level, algo) in [
221        (cfg.gzip_enabled, cfg.gzip_level, Algorithm::Gzip),
222        (cfg.brotli_enabled, cfg.brotli_level, Algorithm::Brotli),
223        (cfg.zstd_enabled, cfg.zstd_level, Algorithm::Zstd),
224    ] {
225        if !enabled {
226            module.adjust_algorithm_level(algo, 0);
227        } else if let Some(lvl) = level {
228            module.adjust_algorithm_level(algo, lvl);
229        }
230    }
231}
232
233/// Handle upstream connect failures with retry logic.
234///
235/// Retries are skipped when the effective forwarded body size exceeds
236/// Pingora's retry buffer limit.
237///
238/// Body-mutating filters can change payload length after `request_filter`,
239/// so the retry guard uses the larger of the original and mutated lengths.
240fn handle_connect_failure(ctx: &mut PingoraRequestCtx, e: Box<pingora_core::Error>) -> Box<pingora_core::Error> {
241    if ctx.request_is_idempotent {
242        let mutated_len = ctx.mutated_request_body_len.unwrap_or(0) as u64;
243        let effective_body_size = std::cmp::max(ctx.request_body_bytes, mutated_len);
244        if effective_body_size > RETRY_BODY_LIMIT {
245            warn!(
246                body_bytes = ctx.request_body_bytes,
247                mutated_len = ?ctx.mutated_request_body_len,
248                limit = RETRY_BODY_LIMIT,
249                "skipping retry: request body exceeds Pingora retry buffer limit"
250            );
251            return e;
252        }
253        if (ctx.retries as usize) < MAX_RETRIES {
254            ctx.retries += 1;
255            debug!(
256                retries = ctx.retries,
257                max = MAX_RETRIES,
258                "retrying idempotent request after connect failure"
259            );
260            let mut e = e;
261            e.set_retry(true);
262            return e;
263        }
264        warn!(
265            retries = ctx.retries,
266            max = MAX_RETRIES,
267            "retry limit reached for idempotent request"
268        );
269    }
270    e
271}
272
273/// Run response filters during the logging phase if the
274/// response phase never executed (upstream error, filter
275/// rejection, etc.).
276async fn logging_cleanup(pipeline: &FilterPipeline, ctx: &mut PingoraRequestCtx) {
277    if !ctx.response_phase_done
278        && let Some(mut filter_ctx) = ctx.filter_context_for(pipeline, None)
279    {
280        let _result = pipeline.execute_http_response(&mut filter_ctx).await;
281        let extensions = filter_ctx.extensions;
282        let metadata = filter_ctx.filter_metadata;
283        let state = filter_ctx.filter_state;
284        let exec_idx = filter_ctx.executed_filter_indices;
285        let body_idx = filter_ctx.body_done_indices;
286        ctx.extensions = extensions;
287        ctx.filter_metadata = metadata;
288        ctx.filter_state = state;
289        ctx.cached_executed_filter_indices = exec_idx;
290        ctx.cached_body_done_indices = body_idx;
291    }
292}
293
294/// Emit Prometheus metrics for a completed HTTP request.
295///
296/// No-op when the Prometheus recorder has not been installed.
297fn emit_request_metrics(session: &Session, ctx: &PingoraRequestCtx) {
298    if !metrics::is_recorder_installed() {
299        return;
300    }
301
302    let status_code = session.response_written().map_or(0, |resp| resp.status.as_u16());
303    let status_class = metrics::status_class(status_code);
304
305    let request_method = session.req_header().method.as_str();
306    let raw_method = if request_method.is_empty() {
307        ctx.request_snapshot.as_ref().map_or("UNKNOWN", |r| r.method.as_str())
308    } else {
309        request_method
310    };
311    let method = metrics::method_label(raw_method);
312
313    let cluster = ctx
314        .metrics_cluster_shared
315        .clone()
316        .unwrap_or_else(|| ::metrics::SharedString::const_str("none"));
317
318    let labels = metrics::RequestMetricLabels {
319        cluster,
320        method,
321        route: "unknown",
322        status_class,
323    };
324
325    let duration_secs = ctx.request_start.elapsed().as_secs_f64();
326    metrics::record_request_metrics(labels, duration_secs);
327}
328
329/// Record a passive health observation for the selected upstream endpoint.
330///
331/// Called from the `logging` hook on every completed request. Determines
332/// success/failure from the error argument and the stashed upstream
333/// response status code.
334///
335/// No-op when no upstream was selected, no health registry is available,
336/// or passive checking is not configured for the cluster.
337fn record_passive_health(pipeline: &FilterPipeline, error: Option<&pingora_core::Error>, ctx: &PingoraRequestCtx) {
338    let cluster_name = ctx.cluster.as_ref().or(ctx.metrics_cluster.as_ref());
339    let Some(cluster_name) = cluster_name else {
340        return;
341    };
342    let Some(idx) = ctx.selected_endpoint_index else {
343        return;
344    };
345    let Some(registry) = pipeline.health_registry() else {
346        return;
347    };
348    let Some(health) = registry.get(cluster_name) else {
349        return;
350    };
351
352    let is_failure = error.is_some() || ctx.upstream_response_status.is_some_and(|s| s >= 500);
353    apply_passive_threshold(health, idx, cluster_name, is_failure);
354}
355
356/// Apply passive health threshold for a single endpoint observation.
357fn apply_passive_threshold(
358    health: &praxis_core::health::ClusterHealthEntry,
359    idx: usize,
360    cluster_name: &Arc<str>,
361    is_failure: bool,
362) {
363    if is_failure {
364        if let Some(threshold) = health.passive_unhealthy_threshold()
365            && health
366                .endpoints()
367                .get(idx)
368                .is_some_and(|ep| ep.record_failure(threshold))
369        {
370            tracing::warn!(
371                cluster = %cluster_name,
372                endpoint_index = idx,
373                threshold,
374                "passive health: endpoint marked unhealthy"
375            );
376        }
377    } else if let Some(threshold) = health.passive_healthy_threshold()
378        && health
379            .endpoints()
380            .get(idx)
381            .is_some_and(|ep| ep.record_success(threshold))
382    {
383        tracing::info!(
384            cluster = %cluster_name,
385            endpoint_index = idx,
386            threshold,
387            "passive health: endpoint recovered"
388        );
389    }
390}
391
392/// Build [`HttpServerOptions`] with h2c enabled.
393///
394/// [`HttpServerOptions`]: pingora_core::apps::HttpServerOptions
395fn h2c_server_options() -> HttpServerOptions {
396    let mut opts = HttpServerOptions::default();
397    opts.h2c = true;
398    opts
399}
400
401/// Accumulate `chunk.len()` into `accumulated_bytes` and return `true` when
402/// the total exceeds `max_bytes`. Returns `false` when the body is `None`.
403fn check_body_size_limit(body: &Option<Bytes>, accumulated_bytes: &mut u64, max_bytes: usize) -> bool {
404    if let Some(chunk) = body {
405        #[expect(clippy::allow_attributes, reason = "cast lint is platform-dependent")]
406        #[allow(clippy::cast_possible_truncation, reason = "chunk length fits u64")]
407        let chunk_len = chunk.len() as u64;
408        *accumulated_bytes += chunk_len;
409
410        #[expect(clippy::allow_attributes, reason = "cast lint is platform-dependent")]
411        #[allow(clippy::cast_possible_truncation, reason = "max_bytes fits u64")]
412        let limit = max_bytes as u64;
413        return *accumulated_bytes > limit;
414    }
415    false
416}
417
418/// Push `chunk` into the stream buffer, creating it if absent. At end-of-stream
419/// the buffer is frozen into `body`. Returns `true` when the push overflows.
420fn accumulate_stream_buffer(
421    body: &mut Option<Bytes>,
422    body_buffer: &mut Option<BodyBuffer>,
423    end_of_stream: bool,
424    max_bytes: Option<usize>,
425) -> bool {
426    if let Some(chunk) = &*body {
427        let limit = max_bytes.unwrap_or(ABSOLUTE_MAX_BODY_BYTES);
428        let buf = body_buffer.get_or_insert_with(|| BodyBuffer::new(limit));
429
430        if buf.push(chunk.clone()).is_err() {
431            return true;
432        }
433    }
434
435    if end_of_stream {
436        tracing::trace!("stream buffer: freezing accumulated body before pipeline at EOS");
437        *body = body_buffer.take().map(BodyBuffer::freeze);
438    } else {
439        tracing::trace!("stream buffer: filters see the original chunk");
440    }
441    false
442}
443
444/// Suppress the body chunk while the stream buffer is still accumulating
445/// (i.e. `Continue`/`BodyDone` before release).
446#[expect(
447    clippy::fn_params_excessive_bools,
448    reason = "mirrors the caller's existing condition flags"
449)]
450fn suppress_stream_buffer_chunk(body: &mut Option<Bytes>, is_stream_buffer: bool, released: bool, end_of_stream: bool) {
451    if is_stream_buffer && !released && !end_of_stream {
452        *body = None;
453    }
454}
455
456/// Release the accumulated stream buffer on `FilterAction::Release`.
457fn release_stream_buffer(
458    body: &mut Option<Bytes>,
459    is_stream_buffer: bool,
460    released: &mut bool,
461    body_buffer: &mut Option<BodyBuffer>,
462    end_of_stream: bool,
463) {
464    if is_stream_buffer && !*released {
465        *released = true;
466        if !end_of_stream {
467            *body = body_buffer.take().map(BodyBuffer::freeze);
468        }
469    }
470}
471
472/// Shared fields extracted from an `HttpFilterContext` after body filter
473/// execution. Written back to `PingoraRequestCtx` via [`write_back`].
474///
475/// [`write_back`]: BodyFilterOutput::write_back
476struct BodyFilterOutput {
477    /// Cluster selected by the filter pipeline.
478    cluster: Option<Arc<str>>,
479    /// Upstream endpoint selected by the load balancer.
480    upstream: Option<Upstream>,
481    /// Type-safe request-scoped extension container.
482    extensions: RequestExtensions,
483    /// Durable per-request metadata that persists across phases.
484    filter_metadata: HashMap<String, String>,
485    /// Typed per-filter state keyed by stable filter invocation ID.
486    filter_state: HashMap<usize, Box<dyn std::any::Any + Send + Sync>>,
487    /// Per-filter execution tracking indices.
488    executed_filter_indices: Vec<bool>,
489    /// Per-filter body-done tracking indices.
490    body_done_indices: Vec<bool>,
491}
492
493impl BodyFilterOutput {
494    /// Move the shared fields out of the filter context, replacing each
495    /// with its `Default` value (zero-allocation no-ops for the types involved).
496    fn take_from(fctx: &mut HttpFilterContext<'_>) -> Self {
497        Self {
498            cluster: fctx.cluster.take(),
499            upstream: fctx.upstream.take(),
500            extensions: std::mem::take(&mut fctx.extensions),
501            filter_metadata: std::mem::take(&mut fctx.filter_metadata),
502            filter_state: std::mem::take(&mut fctx.filter_state),
503            executed_filter_indices: std::mem::take(&mut fctx.executed_filter_indices),
504            body_done_indices: std::mem::take(&mut fctx.body_done_indices),
505        }
506    }
507
508    /// Write the shared fields back to the protocol context.
509    fn write_back(self, ctx: &mut PingoraRequestCtx) {
510        ctx.cluster = self.cluster;
511        ctx.upstream = self.upstream;
512        ctx.extensions = self.extensions;
513        ctx.filter_metadata = self.filter_metadata;
514        ctx.filter_state = self.filter_state;
515        ctx.cached_executed_filter_indices = self.executed_filter_indices;
516        ctx.cached_body_done_indices = self.body_done_indices;
517    }
518}
519
520// -----------------------------------------------------------------------------
521// Tests
522// -----------------------------------------------------------------------------
523
524#[cfg(test)]
525#[expect(clippy::allow_attributes, reason = "blanket test suppressions")]
526#[allow(
527    clippy::unwrap_used,
528    clippy::expect_used,
529    clippy::indexing_slicing,
530    clippy::field_reassign_with_default,
531    clippy::too_many_lines,
532    clippy::cast_possible_truncation,
533    clippy::significant_drop_tightening,
534    reason = "tests"
535)]
536mod tests {
537    use std::sync::Arc;
538
539    use praxis_core::connectivity::ConnectionOptions;
540
541    use super::*;
542
543    #[test]
544    fn first_failure_idempotent_sets_retry() {
545        let mut ctx = PingoraRequestCtx::default();
546        ctx.request_is_idempotent = true;
547        let e = handle_connect_failure(&mut ctx, make_error());
548        assert!(e.retry(), "first failure should set retry flag");
549        assert_eq!(ctx.retries, 1);
550    }
551
552    #[test]
553    fn large_body_skips_retry() {
554        let mut ctx = PingoraRequestCtx::default();
555        ctx.request_is_idempotent = true;
556        ctx.request_body_bytes = RETRY_BODY_LIMIT + 1;
557        let e = handle_connect_failure(&mut ctx, make_error());
558        assert!(!e.retry(), "should not retry when body exceeds retry buffer limit");
559        assert_eq!(ctx.retries, 0, "retry counter should not increment");
560    }
561
562    #[test]
563    fn mutated_body_exceeding_limit_skips_retry() {
564        let mut ctx = PingoraRequestCtx::default();
565        ctx.request_is_idempotent = true;
566        ctx.request_body_bytes = 1024;
567        ctx.mutated_request_body_len = Some((RETRY_BODY_LIMIT + 1) as usize);
568        let e = handle_connect_failure(&mut ctx, make_error());
569        assert!(
570            !e.retry(),
571            "should not retry when mutated body exceeds retry buffer limit"
572        );
573        assert_eq!(ctx.retries, 0);
574    }
575
576    #[test]
577    fn body_at_limit_allows_retry() {
578        let mut ctx = PingoraRequestCtx::default();
579        ctx.request_is_idempotent = true;
580        ctx.request_body_bytes = RETRY_BODY_LIMIT;
581        let e = handle_connect_failure(&mut ctx, make_error());
582        assert!(e.retry(), "body exactly at limit should allow retry");
583        assert_eq!(ctx.retries, 1);
584    }
585
586    #[test]
587    fn zero_body_allows_retry() {
588        let mut ctx = PingoraRequestCtx::default();
589        ctx.request_is_idempotent = true;
590        ctx.request_body_bytes = 0;
591        let e = handle_connect_failure(&mut ctx, make_error());
592        assert!(e.retry(), "zero-length body should allow retry");
593        assert_eq!(ctx.retries, 1);
594    }
595
596    #[test]
597    fn max_retries_exhausted_does_not_retry() {
598        let mut ctx = PingoraRequestCtx::default();
599        ctx.request_is_idempotent = true;
600        ctx.retries = MAX_RETRIES as u32;
601        let e = handle_connect_failure(&mut ctx, make_error());
602        assert!(!e.retry(), "should not retry after MAX_RETRIES");
603        assert_eq!(ctx.retries as usize, MAX_RETRIES);
604    }
605
606    #[test]
607    fn counter_increments_across_calls() {
608        let mut ctx = PingoraRequestCtx::default();
609        ctx.request_is_idempotent = true;
610        for expected in 1..=MAX_RETRIES {
611            let _result = handle_connect_failure(&mut ctx, make_error());
612            assert_eq!(ctx.retries as usize, expected);
613        }
614        let e = handle_connect_failure(&mut ctx, make_error());
615        assert!(!e.retry(), "should not retry after reaching MAX_RETRIES");
616        assert_eq!(ctx.retries as usize, MAX_RETRIES);
617    }
618
619    #[test]
620    fn non_idempotent_request_never_retries() {
621        let mut ctx = PingoraRequestCtx::default();
622        ctx.request_is_idempotent = false;
623        let e = handle_connect_failure(&mut ctx, make_error());
624        assert!(!e.retry(), "non-idempotent request should never retry");
625        assert_eq!(ctx.retries, 0);
626    }
627
628    #[tokio::test]
629    async fn logging_cleanup_noop_when_response_phase_done() {
630        let registry = praxis_filter::FilterRegistry::with_builtins();
631        let pipeline = FilterPipeline::build(&mut [], &registry).unwrap();
632        let mut ctx = PingoraRequestCtx::default();
633        ctx.response_phase_done = true;
634        ctx.request_snapshot = Some(praxis_filter::Request {
635            method: http::Method::GET,
636            uri: "/".parse().unwrap(),
637            headers: http::HeaderMap::new(),
638        });
639        logging_cleanup(&pipeline, &mut ctx).await;
640    }
641
642    #[tokio::test]
643    async fn logging_cleanup_noop_when_no_snapshot() {
644        let registry = praxis_filter::FilterRegistry::with_builtins();
645        let pipeline = FilterPipeline::build(&mut [], &registry).unwrap();
646        let mut ctx = PingoraRequestCtx::default();
647        ctx.response_phase_done = false;
648        ctx.request_snapshot = None;
649        logging_cleanup(&pipeline, &mut ctx).await;
650    }
651
652    #[tokio::test]
653    async fn logging_cleanup_runs_response_pipeline_when_needed() {
654        let registry = praxis_filter::FilterRegistry::with_builtins();
655        let pipeline = FilterPipeline::build(&mut [], &registry).unwrap();
656        let mut ctx = PingoraRequestCtx::default();
657        ctx.response_phase_done = false;
658        ctx.cluster = Some(Arc::from("test-cluster"));
659        ctx.request_snapshot = Some(praxis_filter::Request {
660            method: http::Method::GET,
661            uri: "/test".parse().unwrap(),
662            headers: http::HeaderMap::new(),
663        });
664        logging_cleanup(&pipeline, &mut ctx).await;
665        assert!(ctx.cluster.is_none(), "cluster should be taken by logging_cleanup");
666        assert!(ctx.upstream.is_none(), "upstream should be taken by logging_cleanup");
667    }
668
669    #[tokio::test]
670    async fn logging_cleanup_preserves_filter_metadata() {
671        let registry = praxis_filter::FilterRegistry::with_builtins();
672        let pipeline = FilterPipeline::build(&mut [], &registry).unwrap();
673        let mut ctx = PingoraRequestCtx::default();
674        ctx.response_phase_done = false;
675        ctx.filter_metadata
676            .insert("json_rpc.method".to_owned(), "service/invoke".to_owned());
677        ctx.request_snapshot = Some(praxis_filter::Request {
678            method: http::Method::POST,
679            uri: "/api".parse().unwrap(),
680            headers: http::HeaderMap::new(),
681        });
682        logging_cleanup(&pipeline, &mut ctx).await;
683        assert_eq!(
684            ctx.filter_metadata.get("json_rpc.method").map(String::as_str),
685            Some("service/invoke"),
686            "filter_metadata should survive logging_cleanup"
687        );
688    }
689
690    #[tokio::test]
691    async fn logging_cleanup_preserves_extensions() {
692        let registry = praxis_filter::FilterRegistry::with_builtins();
693        let pipeline = FilterPipeline::build(&mut [], &registry).unwrap();
694        let mut ctx = PingoraRequestCtx::default();
695        ctx.response_phase_done = false;
696        ctx.extensions.insert(42_u32);
697        ctx.request_snapshot = Some(praxis_filter::Request {
698            method: http::Method::POST,
699            uri: "/test".parse().unwrap(),
700            headers: http::HeaderMap::new(),
701        });
702        logging_cleanup(&pipeline, &mut ctx).await;
703        assert_eq!(
704            ctx.extensions.get::<u32>(),
705            Some(&42),
706            "extensions should survive logging_cleanup"
707        );
708    }
709
710    #[test]
711    fn passive_health_error_is_failure() {
712        let (pipeline, ctx) = make_passive_scenario(Some(3), Some(2));
713        let error = make_error();
714        record_passive_health(&pipeline, Some(&error), &ctx);
715
716        let registry = pipeline.health_registry().unwrap();
717        let entry = registry.get("test-cluster").unwrap();
718        assert!(
719            entry.endpoints()[0].is_healthy(),
720            "single failure should not yet mark unhealthy (threshold=3)"
721        );
722    }
723
724    #[test]
725    fn passive_health_status_500_is_failure() {
726        let (pipeline, mut ctx) = make_passive_scenario(Some(3), Some(2));
727        ctx.upstream_response_status = Some(500);
728        record_passive_health(&pipeline, None, &ctx);
729
730        let registry = pipeline.health_registry().unwrap();
731        let entry = registry.get("test-cluster").unwrap();
732        assert!(
733            entry.endpoints()[0].is_healthy(),
734            "single 500 should not yet mark unhealthy (threshold=3)"
735        );
736    }
737
738    #[test]
739    fn passive_health_status_below_500_is_success() {
740        let (pipeline, mut ctx) = make_passive_scenario(Some(2), Some(1));
741        ctx.upstream_response_status = Some(499);
742        record_passive_health(&pipeline, None, &ctx);
743
744        let registry = pipeline.health_registry().unwrap();
745        let entry = registry.get("test-cluster").unwrap();
746        assert!(entry.endpoints()[0].is_healthy(), "status 499 should count as success");
747    }
748
749    #[test]
750    fn passive_unhealthy_threshold_transition() {
751        let (pipeline, ctx) = make_passive_scenario(Some(2), Some(1));
752        let error = make_error();
753        record_passive_health(&pipeline, Some(&error), &ctx);
754        record_passive_health(&pipeline, Some(&error), &ctx);
755
756        let registry = pipeline.health_registry().unwrap();
757        let entry = registry.get("test-cluster").unwrap();
758        assert!(
759            !entry.endpoints()[0].is_healthy(),
760            "2 consecutive failures should mark unhealthy (threshold=2)"
761        );
762    }
763
764    #[test]
765    fn passive_healthy_threshold_recovery() {
766        let (pipeline, ctx) = make_passive_scenario(Some(1), Some(2));
767        let error = make_error();
768        record_passive_health(&pipeline, Some(&error), &ctx);
769
770        let registry = pipeline.health_registry().unwrap();
771        let entry = registry.get("test-cluster").unwrap();
772        assert!(
773            !entry.endpoints()[0].is_healthy(),
774            "should be unhealthy after 1 failure"
775        );
776
777        let ctx_ok = make_passive_ctx("test-cluster", 0, Some(200));
778        record_passive_health(&pipeline, None, &ctx_ok);
779        assert!(
780            !entry.endpoints()[0].is_healthy(),
781            "one success should not recover (threshold=2)"
782        );
783
784        record_passive_health(&pipeline, None, &ctx_ok);
785        assert!(
786            entry.endpoints()[0].is_healthy(),
787            "2 consecutive successes should recover (threshold=2)"
788        );
789    }
790
791    #[test]
792    fn passive_health_no_thresholds_is_noop() {
793        let (pipeline, ctx) = make_passive_scenario(None, None);
794        let error = make_error();
795        record_passive_health(&pipeline, Some(&error), &ctx);
796
797        let registry = pipeline.health_registry().unwrap();
798        let entry = registry.get("test-cluster").unwrap();
799        assert!(
800            entry.endpoints()[0].is_healthy(),
801            "no passive thresholds means failures are no-op"
802        );
803    }
804
805    #[test]
806    fn passive_health_endpoint_index_out_of_bounds() {
807        let (pipeline, mut ctx) = make_passive_scenario(Some(1), Some(1));
808        ctx.selected_endpoint_index = Some(999);
809        let error = make_error();
810        record_passive_health(&pipeline, Some(&error), &ctx);
811
812        let registry = pipeline.health_registry().unwrap();
813        let entry = registry.get("test-cluster").unwrap();
814        assert!(entry.endpoints()[0].is_healthy(), "out-of-bounds index should be no-op");
815    }
816
817    #[test]
818    fn passive_health_missing_cluster_is_noop() {
819        let (pipeline, mut ctx) = make_passive_scenario(Some(1), Some(1));
820        ctx.cluster = None;
821        ctx.metrics_cluster = None;
822        let error = make_error();
823        record_passive_health(&pipeline, Some(&error), &ctx);
824    }
825
826    #[test]
827    fn passive_health_falls_back_to_metrics_cluster() {
828        let (pipeline, mut ctx) = make_passive_scenario(Some(2), Some(1));
829        ctx.cluster = None;
830        ctx.metrics_cluster = Some(Arc::from("test-cluster"));
831        let error = make_error();
832        record_passive_health(&pipeline, Some(&error), &ctx);
833        record_passive_health(&pipeline, Some(&error), &ctx);
834
835        let registry = pipeline.health_registry().unwrap();
836        let entry = registry.get("test-cluster").unwrap();
837        assert!(
838            !entry.endpoints()[0].is_healthy(),
839            "fallback to metrics_cluster should still record passive health"
840        );
841    }
842
843    #[test]
844    fn passive_health_missing_endpoint_index_is_noop() {
845        let (pipeline, mut ctx) = make_passive_scenario(Some(1), Some(1));
846        ctx.selected_endpoint_index = None;
847        let error = make_error();
848        record_passive_health(&pipeline, Some(&error), &ctx);
849    }
850
851    #[test]
852    fn passive_health_missing_registry_is_noop() {
853        let registry = praxis_filter::FilterRegistry::with_builtins();
854        let pipeline = FilterPipeline::build(&mut [], &registry).unwrap();
855        let mut ctx = PingoraRequestCtx::default();
856        ctx.cluster = Some(Arc::from("test-cluster"));
857        ctx.selected_endpoint_index = Some(0);
858        let error = make_error();
859        record_passive_health(&pipeline, Some(&error), &ctx);
860    }
861
862    #[test]
863    fn passive_health_unknown_cluster_is_noop() {
864        let (pipeline, mut ctx) = make_passive_scenario(Some(1), Some(1));
865        ctx.cluster = Some(Arc::from("nonexistent"));
866        let error = make_error();
867        record_passive_health(&pipeline, Some(&error), &ctx);
868    }
869
870    #[test]
871    fn size_limit_none_body_returns_false() {
872        let mut bytes = 0_u64;
873        assert!(!check_body_size_limit(&None, &mut bytes, 100));
874        assert_eq!(bytes, 0, "accumulated bytes unchanged for None body");
875    }
876
877    #[test]
878    fn size_limit_within_limit() {
879        let mut bytes = 0_u64;
880        let body = Some(Bytes::from_static(b"hello"));
881        assert!(!check_body_size_limit(&body, &mut bytes, 10));
882        assert_eq!(bytes, 5);
883    }
884
885    #[test]
886    fn size_limit_at_exact_limit() {
887        let mut bytes = 0_u64;
888        let body = Some(Bytes::from_static(b"exact"));
889        assert!(!check_body_size_limit(&body, &mut bytes, 5));
890        assert_eq!(bytes, 5);
891    }
892
893    #[test]
894    fn size_limit_exceeds_limit() {
895        let mut bytes = 0_u64;
896        let body = Some(Bytes::from_static(b"toolong"));
897        assert!(check_body_size_limit(&body, &mut bytes, 3));
898    }
899
900    #[test]
901    fn size_limit_cumulative_overflow() {
902        let mut bytes = 0_u64;
903        let first = Some(Bytes::from_static(b"aaa"));
904        assert!(!check_body_size_limit(&first, &mut bytes, 5));
905
906        let second = Some(Bytes::from_static(b"bbb"));
907        assert!(check_body_size_limit(&second, &mut bytes, 5));
908        assert_eq!(bytes, 6);
909    }
910
911    #[test]
912    fn stream_buffer_accumulates_chunks() {
913        let mut body = Some(Bytes::from_static(b"hello "));
914        let mut buf: Option<BodyBuffer> = None;
915        assert!(!accumulate_stream_buffer(&mut body, &mut buf, false, Some(100)));
916        assert!(buf.is_some());
917
918        body = Some(Bytes::from_static(b"world"));
919        assert!(!accumulate_stream_buffer(&mut body, &mut buf, false, Some(100)));
920
921        let frozen = buf.take().unwrap().freeze();
922        assert_eq!(frozen, Bytes::from_static(b"hello world"));
923    }
924
925    #[test]
926    fn stream_buffer_freezes_at_eos() {
927        let mut body = Some(Bytes::from_static(b"data"));
928        let mut buf: Option<BodyBuffer> = None;
929        assert!(!accumulate_stream_buffer(&mut body, &mut buf, false, Some(100)));
930
931        body = Some(Bytes::from_static(b" end"));
932        assert!(!accumulate_stream_buffer(&mut body, &mut buf, true, Some(100)));
933        assert!(buf.is_none(), "buffer should be taken at EOS");
934        assert_eq!(body.unwrap(), Bytes::from_static(b"data end"));
935    }
936
937    #[test]
938    fn stream_buffer_overflow() {
939        let mut body = Some(Bytes::from_static(b"too long"));
940        let mut buf: Option<BodyBuffer> = None;
941        assert!(accumulate_stream_buffer(&mut body, &mut buf, false, Some(5)));
942    }
943
944    #[test]
945    fn stream_buffer_none_body() {
946        let mut body: Option<Bytes> = None;
947        let mut buf: Option<BodyBuffer> = None;
948        assert!(!accumulate_stream_buffer(&mut body, &mut buf, false, Some(100)));
949        assert!(buf.is_none());
950    }
951
952    #[test]
953    fn stream_buffer_uses_absolute_max_when_none() {
954        let mut body = Some(Bytes::from_static(b"data"));
955        let mut buf: Option<BodyBuffer> = None;
956        assert!(!accumulate_stream_buffer(&mut body, &mut buf, false, None));
957        assert!(buf.is_some(), "should create buffer with absolute max");
958    }
959
960    #[test]
961    fn suppress_clears_body_when_buffering() {
962        let mut body = Some(Bytes::from_static(b"data"));
963        suppress_stream_buffer_chunk(&mut body, true, false, false);
964        assert!(body.is_none());
965    }
966
967    #[test]
968    fn suppress_noop_when_not_stream_buffer() {
969        let mut body = Some(Bytes::from_static(b"data"));
970        suppress_stream_buffer_chunk(&mut body, false, false, false);
971        assert!(body.is_some());
972    }
973
974    #[test]
975    fn suppress_noop_when_released() {
976        let mut body = Some(Bytes::from_static(b"data"));
977        suppress_stream_buffer_chunk(&mut body, true, true, false);
978        assert!(body.is_some());
979    }
980
981    #[test]
982    fn suppress_noop_at_eos() {
983        let mut body = Some(Bytes::from_static(b"data"));
984        suppress_stream_buffer_chunk(&mut body, true, false, true);
985        assert!(body.is_some());
986    }
987
988    #[test]
989    fn release_sets_flag_and_flushes_buffer() {
990        let mut body: Option<Bytes> = None;
991        let mut released = false;
992        let mut buf = Some(BodyBuffer::new(100));
993        buf.as_mut().unwrap().push(Bytes::from_static(b"buffered")).unwrap();
994
995        release_stream_buffer(&mut body, true, &mut released, &mut buf, false);
996        assert!(released);
997        assert_eq!(body.unwrap(), Bytes::from_static(b"buffered"));
998        assert!(buf.is_none());
999    }
1000
1001    #[test]
1002    fn release_noop_when_already_released() {
1003        let mut body: Option<Bytes> = None;
1004        let mut released = true;
1005        let mut buf: Option<BodyBuffer> = None;
1006
1007        release_stream_buffer(&mut body, true, &mut released, &mut buf, false);
1008        assert!(body.is_none(), "body should be unchanged when already released");
1009    }
1010
1011    #[test]
1012    fn release_noop_when_not_stream_buffer() {
1013        let mut body: Option<Bytes> = None;
1014        let mut released = false;
1015        let mut buf: Option<BodyBuffer> = None;
1016
1017        release_stream_buffer(&mut body, false, &mut released, &mut buf, false);
1018        assert!(!released, "released flag should be unchanged for non-stream-buffer");
1019    }
1020
1021    #[test]
1022    fn release_at_eos_sets_flag_but_no_flush() {
1023        let mut body: Option<Bytes> = None;
1024        let mut released = false;
1025        let mut buf = Some(BodyBuffer::new(100));
1026        buf.as_mut().unwrap().push(Bytes::from_static(b"data")).unwrap();
1027
1028        release_stream_buffer(&mut body, true, &mut released, &mut buf, true);
1029        assert!(released);
1030        assert!(body.is_none(), "body should not be overwritten at EOS");
1031        assert!(buf.is_some(), "buffer should not be taken at EOS");
1032    }
1033
1034    #[test]
1035    fn write_back_transfers_fields() {
1036        let mut ctx = PingoraRequestCtx::default();
1037
1038        let mut extensions = RequestExtensions::new();
1039        extensions.insert(42_u32);
1040
1041        let state_val: Box<dyn std::any::Any + Send + Sync> = Box::new(99_i32);
1042        let filter_state = HashMap::from([(0_usize, state_val)]);
1043
1044        let output = BodyFilterOutput {
1045            cluster: Some(Arc::from("test-cluster")),
1046            upstream: Some(Upstream {
1047                address: Arc::from("10.0.0.1:80"),
1048                connection: Arc::new(ConnectionOptions::default()),
1049                tls: None,
1050            }),
1051            extensions,
1052            filter_metadata: HashMap::from([("key".to_owned(), "val".to_owned())]),
1053            filter_state,
1054            executed_filter_indices: vec![true, false],
1055            body_done_indices: vec![false, true],
1056        };
1057        output.write_back(&mut ctx);
1058
1059        assert_eq!(ctx.cluster.as_deref(), Some("test-cluster"));
1060        assert!(ctx.upstream.is_some(), "upstream should transfer");
1061        assert_eq!(ctx.upstream.as_ref().unwrap().address.as_ref(), "10.0.0.1:80");
1062        assert_eq!(ctx.extensions.get::<u32>(), Some(&42));
1063        assert_eq!(ctx.filter_metadata.get("key").map(String::as_str), Some("val"));
1064        assert_eq!(ctx.filter_state.len(), 1, "filter_state should transfer");
1065        assert_eq!(
1066            ctx.filter_state.get(&0).and_then(|v| v.downcast_ref::<i32>()),
1067            Some(&99)
1068        );
1069        assert_eq!(ctx.cached_executed_filter_indices, vec![true, false]);
1070        assert_eq!(ctx.cached_body_done_indices, vec![false, true]);
1071    }
1072
1073    // -------------------------------------------------------------------------
1074    // Test Utilities
1075    // -------------------------------------------------------------------------
1076
1077    /// Create a connect error for tests.
1078    fn make_error() -> Box<pingora_core::Error> {
1079        pingora_core::Error::explain(pingora_core::ErrorType::ConnectError, "test connect failure")
1080    }
1081
1082    /// Build a [`PingoraRequestCtx`] for passive health testing.
1083    fn make_passive_ctx(cluster: &str, endpoint_idx: usize, status: Option<u16>) -> PingoraRequestCtx {
1084        let mut ctx = PingoraRequestCtx::default();
1085        ctx.cluster = Some(Arc::from(cluster));
1086        ctx.selected_endpoint_index = Some(endpoint_idx);
1087        ctx.upstream_response_status = status;
1088        ctx
1089    }
1090
1091    /// Build a pipeline with a health registry and a matching context
1092    /// for passive health testing.
1093    fn make_passive_scenario(
1094        passive_unhealthy: Option<u32>,
1095        passive_healthy: Option<u32>,
1096    ) -> (FilterPipeline, PingoraRequestCtx) {
1097        use std::collections::HashMap;
1098
1099        use praxis_core::health::{ClusterHealthEntry, EndpointHealth};
1100
1101        let entry = ClusterHealthEntry::new(
1102            vec![EndpointHealth::new()],
1103            vec![Arc::from("10.0.0.1:80")],
1104            passive_unhealthy,
1105            passive_healthy,
1106        );
1107        let mut map = HashMap::new();
1108        map.insert(Arc::from("test-cluster"), Arc::new(entry));
1109        let health_registry = Arc::new(map);
1110
1111        let registry = praxis_filter::FilterRegistry::with_builtins();
1112        let mut pipeline = FilterPipeline::build(&mut [], &registry).unwrap();
1113        pipeline.set_health_registry(health_registry);
1114
1115        let ctx = make_passive_ctx("test-cluster", 0, None);
1116
1117        (pipeline, ctx)
1118    }
1119}