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