1use 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
32mod fail_to_proxy;
34mod hop_by_hop;
36mod normalize;
38mod request_body_filter;
40mod request_filter;
42mod reserved_headers;
44mod response_body_filter;
46mod response_filter;
48mod upstream_peer;
50mod upstream_request;
52mod upstream_response;
54mod via;
56mod with_body;
58
59pub use upstream_peer::{arm_upstream_retry_gate, lock_upstream_retry_gate_tests};
60pub use with_body::PingoraHttpHandler;
61
62const MAX_RETRIES: usize = 3;
68
69const RETRY_BODY_LIMIT: u64 = 65_536; pub 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 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
145fn 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
168fn 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 (m, None | Some(_)) => m,
201 }
202}
203
204fn 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
241fn 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
261fn 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
298fn 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
305async 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
326fn 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
367fn 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
394fn 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
432fn 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
447fn h2c_server_options() -> HttpServerOptions {
451 let mut opts = HttpServerOptions::default();
452 opts.h2c = true;
453 opts
454}
455
456fn h2_server_options() -> H2Options {
465 let mut opts = H2Options::new();
466 opts.max_header_list_size(65_536); opts.max_concurrent_streams(128);
468 opts
469}
470
471fn 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
488fn 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#[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
526fn 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
542struct BodyFilterOutput {
547 cluster: Option<Arc<str>>,
549 upstream: Option<Upstream>,
551 extensions: RequestExtensions,
553 filter_metadata: HashMap<String, String>,
555 filter_state: HashMap<usize, Box<dyn std::any::Any + Send + Sync>>,
557 executed_filter_indices: Vec<bool>,
559 body_done_indices: Vec<bool>,
561}
562
563impl BodyFilterOutput {
564 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 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#[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 [], ®istry).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 [], ®istry).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 [], ®istry).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 [], ®istry).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 [], ®istry).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 [], ®istry).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 fn make_error() -> Box<pingora_core::Error> {
1160 pingora_core::Error::explain(pingora_core::ErrorType::ConnectError, "test connect failure")
1161 }
1162
1163 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 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 [], ®istry).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}