1use 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
30mod hop_by_hop;
32mod no_body;
34mod normalize;
36mod request_body_filter;
38mod request_filter;
40mod reserved_headers;
42mod response_body_filter;
44mod response_filter;
46mod upstream_peer;
48mod upstream_request;
50mod upstream_response;
52mod via;
54mod with_body;
56
57pub use no_body::PingoraHttpHandlerNoBody;
58pub use with_body::PingoraHttpHandler;
59
60const MAX_RETRIES: usize = 3;
66
67const RETRY_BODY_LIMIT: u64 = 65_536; pub 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 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
138fn 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
160fn 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 (m, None | Some(_)) => m,
193 }
194}
195
196fn 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
233fn 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
273async 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
294fn 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
329fn 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
356fn 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
392fn h2c_server_options() -> HttpServerOptions {
396 let mut opts = HttpServerOptions::default();
397 opts.h2c = true;
398 opts
399}
400
401fn 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
418fn 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#[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
456fn 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
472struct BodyFilterOutput {
477 cluster: Option<Arc<str>>,
479 upstream: Option<Upstream>,
481 extensions: RequestExtensions,
483 filter_metadata: HashMap<String, String>,
485 filter_state: HashMap<usize, Box<dyn std::any::Any + Send + Sync>>,
487 executed_filter_indices: Vec<bool>,
489 body_done_indices: Vec<bool>,
491}
492
493impl BodyFilterOutput {
494 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 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#[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 [], ®istry).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 [], ®istry).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 [], ®istry).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 [], ®istry).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 [], ®istry).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 [], ®istry).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 fn make_error() -> Box<pingora_core::Error> {
1079 pingora_core::Error::explain(pingora_core::ErrorType::ConnectError, "test connect failure")
1080 }
1081
1082 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 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 [], ®istry).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}