guardrail_core/pipeline.rs
1//! The pipeline abstraction: `Stage` trait, `Pipeline`, and `PipelineBuilder`.
2
3use std::sync::Arc;
4use std::time::Duration;
5
6use crate::{
7 decision::{BlockCode, Decision},
8 error::GuardrailError,
9 request::GuardrailRequest,
10};
11
12/// Block code returned when a stage configured with [`FailMode::Closed`]
13/// errors instead of producing a decision.
14pub const STAGE_ERROR_CODE: &str = "stage_error";
15
16/// What the pipeline does when a stage returns `Err` instead of a decision.
17///
18/// Set per stage with [`PipelineBuilder::stage_with_fail_mode`]. Stages added
19/// with [`PipelineBuilder::stage`] use [`FailMode::Open`].
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
21pub enum FailMode {
22 /// Treat the error as `Decision::Allow` and keep evaluating later
23 /// stages (fail-open). The default.
24 #[default]
25 Open,
26 /// Stop and return `Decision::Block` with code [`STAGE_ERROR_CODE`]
27 /// (fail-closed).
28 Closed,
29}
30
31/// How one stage evaluation ended. Reported to the observer passed to
32/// [`Pipeline::run_with_events`].
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34#[non_exhaustive]
35pub enum StageOutcome {
36 /// The stage returned a decision (allow, redact, or block).
37 Completed,
38 /// The stage errored and the request continued unchecked by it
39 /// ([`FailMode::Open`]).
40 ErrorAllowed,
41 /// The stage errored and the request was blocked ([`FailMode::Closed`]).
42 ErrorBlocked,
43}
44
45/// One stage evaluation, as reported to [`Pipeline::run_with_events`].
46#[derive(Debug, Clone, Copy)]
47pub struct StageEvent {
48 /// The stage's [`Stage::name`].
49 pub stage: &'static str,
50 /// Wall-clock time the stage's `evaluate` call took.
51 pub elapsed: Duration,
52 /// How the evaluation ended.
53 pub outcome: StageOutcome,
54}
55
56/// Every processing unit in the pipeline implements this trait.
57///
58/// Stages are stateless with respect to individual requests; all shared state
59/// (e.g., compiled regexes, ONNX sessions) is held behind `Arc` in the
60/// implementor. This allows stages to be shared across many concurrent requests
61/// without synchronization overhead on the hot path.
62///
63/// ## Contract
64///
65/// - A stage **must not** mutate the request in-place; instead it expresses
66/// mutations by returning `Decision::Redact` with a new `GuardrailRequest`.
67/// - A stage **must** be `Send + Sync + 'static` so it can be used in a
68/// multi-threaded Tokio runtime.
69/// - On error (e.g. a failed model inference), return `Err` rather than
70/// choosing an outcome yourself. The pipeline applies the stage's
71/// [`FailMode`]: allow and continue by default, or block if the stage was
72/// added with [`FailMode::Closed`]. Swallowing the error inside the stage
73/// would silently bypass a fail-closed configuration.
74// `async_trait` marks the generated method `#[must_use]` even though its
75// boxed-future return type already is; clippy 1.99+ flags the redundancy
76// inside the macro expansion, which we can't change from here.
77#[allow(clippy::double_must_use)]
78#[async_trait::async_trait]
79pub trait Stage: Send + Sync + 'static {
80 /// Human-readable identifier used in logs, metrics, and traces.
81 fn name(&self) -> &'static str;
82
83 /// Evaluate a request and return a `Decision`.
84 ///
85 /// # Errors
86 ///
87 /// Returns an error if the stage could not reach a decision (for
88 /// example, model inference failed). The pipeline turns the error into
89 /// `Allow` or `Block` according to the stage's [`FailMode`].
90 async fn evaluate(&self, req: &GuardrailRequest) -> Result<Decision, GuardrailError>;
91}
92
93/// A sequential pipeline of stages evaluated in order.
94///
95/// The first `Block` decision short-circuits the pipeline; no subsequent stages
96/// are run. `Redact` decisions update the working request for subsequent stages.
97///
98/// # Examples
99///
100/// ```rust
101/// use guardrail_core::{Pipeline, PipelineBuilder, Decision};
102///
103/// # tokio_test::block_on(async {
104/// let pipeline = PipelineBuilder::default().build();
105/// let req = guardrail_core::test_helpers::clean_request();
106/// let (decision, _) = pipeline.run(req).await.unwrap();
107/// assert_eq!(decision, Decision::Allow);
108/// # });
109/// ```
110pub struct Pipeline {
111 stages: Vec<(Arc<dyn Stage>, FailMode)>,
112}
113
114impl Pipeline {
115 /// Create a pipeline from a list of stages, all using [`FailMode::Open`].
116 ///
117 /// Use [`PipelineBuilder::stage_with_fail_mode`] to set a different
118 /// fail mode per stage.
119 pub fn new(stages: Vec<Arc<dyn Stage>>) -> Self {
120 Self {
121 stages: stages.into_iter().map(|s| (s, FailMode::Open)).collect(),
122 }
123 }
124
125 /// Start building a pipeline fluently.
126 ///
127 /// Equivalent to [`PipelineBuilder::default()`]; provided as a
128 /// convenience entry point so call sites can write `Pipeline::builder()`
129 /// without importing `PipelineBuilder` directly.
130 ///
131 /// # Examples
132 ///
133 /// ```rust
134 /// use guardrail_core::Pipeline;
135 /// use guardrail_core::test_helpers::PassthroughStage;
136 ///
137 /// let pipeline = Pipeline::builder()
138 /// .stage(PassthroughStage)
139 /// .build();
140 /// assert_eq!(pipeline.len(), 1);
141 /// ```
142 pub fn builder() -> PipelineBuilder {
143 PipelineBuilder::default()
144 }
145
146 /// Returns the number of stages in this pipeline.
147 pub fn len(&self) -> usize {
148 self.stages.len()
149 }
150
151 /// Returns `true` if the pipeline contains no stages.
152 pub fn is_empty(&self) -> bool {
153 self.stages.is_empty()
154 }
155
156 /// Evaluate a request against all configured stages and return a final decision.
157 ///
158 /// Stages are run in order. The first `Block` decision short-circuits evaluation.
159 /// `Redact` decisions update the request for downstream stages.
160 ///
161 /// Run every stage in order against `req`, short-circuiting on the
162 /// first `Decision::Block`.
163 ///
164 /// If one or more stages return `Decision::Redact`, evaluation
165 /// continues (subsequent stages see the mutated request), and the
166 /// **final returned decision is `Decision::Redact`** — not `Allow` —
167 /// with `reason` being every redacting stage's reason joined by `"; "`
168 /// and `entities` being the de-duplicated union of every redacting
169 /// stage's entity list. Only if no stage redacted *and* no stage
170 /// blocked does the pipeline return `Decision::Allow`.
171 ///
172 /// A stage that returns `Err` is handled by its [`FailMode`]: with
173 /// [`FailMode::Open`] (the default) evaluation continues as if it had
174 /// allowed; with [`FailMode::Closed`] the pipeline stops and returns
175 /// `Decision::Block` with code [`STAGE_ERROR_CODE`]. Use
176 /// [`Pipeline::run_with_events`] to find out whether that happened.
177 ///
178 /// # Errors
179 ///
180 /// Currently never returns `Err`: stage errors are resolved by each
181 /// stage's [`FailMode`] as described above. The `Result` is kept so
182 /// future pipeline-level failures can be reported without an API break.
183 ///
184 /// # Examples
185 ///
186 /// ```rust
187 /// use guardrail_core::{Pipeline, PipelineBuilder, Decision};
188 ///
189 /// # tokio_test::block_on(async {
190 /// let pipeline = PipelineBuilder::default().build();
191 /// let req = guardrail_core::test_helpers::clean_request();
192 /// let (decision, _req) = pipeline.run(req).await.unwrap();
193 /// assert_eq!(decision, Decision::Allow);
194 /// # });
195 /// ```
196 ///
197 /// A pipeline with a redacting stage returns `Decision::Redact` as its
198 /// **final** decision, not `Allow` — this is the behavior callers such
199 /// as `guardrail-proxy`'s audit log and `redacted_total` metric depend
200 /// on:
201 ///
202 /// ```rust
203 /// use guardrail_core::{Pipeline, Decision};
204 /// # use guardrail_core::{Stage, error::GuardrailError, request::GuardrailRequest};
205 /// # use async_trait::async_trait;
206 /// #
207 /// # struct AlwaysRedacts;
208 /// # #[async_trait]
209 /// # impl Stage for AlwaysRedacts {
210 /// # fn name(&self) -> &'static str { "always_redacts" }
211 /// # async fn evaluate(&self, req: &GuardrailRequest) -> Result<Decision, GuardrailError> {
212 /// # Ok(Decision::Redact {
213 /// # reason: "test redaction".into(),
214 /// # mutated: req.clone(),
215 /// # entities: vec!["email".into()],
216 /// # })
217 /// # }
218 /// # }
219 /// # tokio_test::block_on(async {
220 /// let pipeline = Pipeline::builder().stage(AlwaysRedacts).build();
221 /// let req = guardrail_core::test_helpers::clean_request();
222 /// let (decision, _req) = pipeline.run(req).await.unwrap();
223 /// match decision {
224 /// Decision::Redact { entities, .. } => assert_eq!(entities, vec!["email"]),
225 /// other => panic!("expected Redact, got {other:?}"),
226 /// }
227 /// # });
228 /// ```
229 pub async fn run(
230 &self,
231 req: GuardrailRequest,
232 ) -> Result<(Decision, GuardrailRequest), GuardrailError> {
233 self.run_with_observer(req, |_stage, _elapsed| {}).await
234 }
235
236 /// Like [`Pipeline::run`], but invokes `observer(stage_name, elapsed)`
237 /// after each stage evaluation (including failed/blocked stages).
238 ///
239 /// This is used by `guardrail-proxy` to record per-stage latency
240 /// histograms without coupling `guardrail-core` to a metrics backend.
241 ///
242 /// # Examples
243 ///
244 /// ```rust
245 /// use guardrail_core::{PipelineBuilder, test_helpers::clean_request};
246 /// use std::time::Duration;
247 ///
248 /// # tokio_test::block_on(async {
249 /// let pipeline = PipelineBuilder::default().build();
250 /// let mut observed: Vec<(String, Duration)> = Vec::new();
251 ///
252 /// let (decision, _req) = pipeline
253 /// .run_with_observer(clean_request(), |stage, elapsed| {
254 /// observed.push((stage.to_string(), elapsed));
255 /// })
256 /// .await
257 /// .unwrap();
258 ///
259 /// assert!(observed.is_empty()); // empty pipeline has no stages
260 /// # });
261 /// ```
262 pub async fn run_with_observer<F>(
263 &self,
264 req: GuardrailRequest,
265 mut observer: F,
266 ) -> Result<(Decision, GuardrailRequest), GuardrailError>
267 where
268 F: FnMut(&str, std::time::Duration),
269 {
270 self.run_with_events(req, |event| observer(event.stage, event.elapsed))
271 .await
272 }
273
274 /// Like [`Pipeline::run`], but invokes `observer` with a [`StageEvent`]
275 /// after each stage evaluation, including whether the stage errored and
276 /// how its [`FailMode`] resolved the error.
277 ///
278 /// This is how callers make fail-open events visible: count
279 /// [`StageOutcome::ErrorAllowed`] to see requests that went through
280 /// without a stage's check.
281 ///
282 /// # Errors
283 ///
284 /// Same as [`Pipeline::run`]: currently never returns `Err`.
285 ///
286 /// # Examples
287 ///
288 /// ```rust
289 /// use guardrail_core::pipeline::{FailMode, StageOutcome};
290 /// use guardrail_core::test_helpers::{clean_request, ErrorStage};
291 /// use guardrail_core::{Decision, PipelineBuilder};
292 ///
293 /// # tokio_test::block_on(async {
294 /// let pipeline = PipelineBuilder::default()
295 /// .stage_with_fail_mode(ErrorStage, FailMode::Closed)
296 /// .build();
297 ///
298 /// let mut outcomes = Vec::new();
299 /// let (decision, _req) = pipeline
300 /// .run_with_events(clean_request(), |event| outcomes.push(event.outcome))
301 /// .await
302 /// .unwrap();
303 ///
304 /// assert!(matches!(decision, Decision::Block { .. }));
305 /// assert_eq!(outcomes, vec![StageOutcome::ErrorBlocked]);
306 /// # });
307 /// ```
308 pub async fn run_with_events<F>(
309 &self,
310 mut req: GuardrailRequest,
311 mut observer: F,
312 ) -> Result<(Decision, GuardrailRequest), GuardrailError>
313 where
314 F: FnMut(&StageEvent),
315 {
316 // Accumulated across all stages that redact (not just the first or
317 // last). A pipeline with both `pii_redactor` and a custom
318 // redacting stage must report the union of both as the final
319 // decision — previously this state was discarded entirely and the
320 // pipeline always returned `Decision::Allow` even after a
321 // successful redaction, which meant `Decision::Redact` could never
322 // actually reach callers (the audit log's "redact" case, the
323 // `guardrail_redacted_total` metric, and `guardrail check`'s
324 // redact output were all unreachable dead code).
325 let mut redaction_reasons: Vec<String> = Vec::new();
326 let mut redaction_entities: Vec<String> = Vec::new();
327
328 for (stage, fail_mode) in &self.stages {
329 let stage_name = stage.name();
330 tracing::debug!(stage = stage_name, "evaluating stage");
331
332 let stage_start = std::time::Instant::now();
333 let result = stage.evaluate(&req).await;
334 let elapsed = stage_start.elapsed();
335
336 let decision = match result {
337 Ok(d) => {
338 observer(&StageEvent {
339 stage: stage_name,
340 elapsed,
341 outcome: StageOutcome::Completed,
342 });
343 d
344 }
345 Err(e) => match fail_mode {
346 FailMode::Open => {
347 tracing::warn!(
348 stage = stage_name,
349 error = %e,
350 "stage failed; failing open (allow)"
351 );
352 observer(&StageEvent {
353 stage: stage_name,
354 elapsed,
355 outcome: StageOutcome::ErrorAllowed,
356 });
357 Decision::Allow
358 }
359 FailMode::Closed => {
360 tracing::error!(
361 stage = stage_name,
362 error = %e,
363 "stage failed; failing closed (block)"
364 );
365 observer(&StageEvent {
366 stage: stage_name,
367 elapsed,
368 outcome: StageOutcome::ErrorBlocked,
369 });
370 // The error text stays in the log: it can name model
371 // paths and other internals, and this reason is
372 // returned to the caller in the 403 body.
373 Decision::Block {
374 reason: format!(
375 "stage '{stage_name}' failed and is configured to fail closed"
376 ),
377 code: BlockCode::Custom(STAGE_ERROR_CODE.into()),
378 }
379 }
380 },
381 };
382
383 tracing::debug!(
384 stage = stage_name,
385 decision = decision.name(),
386 "stage decision"
387 );
388
389 match decision {
390 Decision::Allow => continue,
391 Decision::Redact {
392 reason,
393 mutated,
394 entities,
395 } => {
396 tracing::info!(stage = stage_name, reason = %reason, "request redacted");
397 req = mutated;
398 redaction_reasons.push(reason);
399 for entity in entities {
400 if !redaction_entities.contains(&entity) {
401 redaction_entities.push(entity);
402 }
403 }
404 }
405 Decision::Block { reason, code } => {
406 tracing::info!(
407 stage = stage_name,
408 reason = %reason,
409 code = %code,
410 "request blocked"
411 );
412 return Ok((Decision::Block { reason, code }, req));
413 }
414 }
415 }
416
417 if redaction_reasons.is_empty() {
418 Ok((Decision::Allow, req))
419 } else {
420 Ok((
421 Decision::Redact {
422 reason: redaction_reasons.join("; "),
423 mutated: req.clone(),
424 entities: redaction_entities,
425 },
426 req,
427 ))
428 }
429 }
430}
431
432/// Builder for ergonomic pipeline construction.
433///
434/// # Examples
435///
436/// ```rust
437/// use guardrail_core::PipelineBuilder;
438///
439/// let pipeline = PipelineBuilder::default()
440/// // .stage(MyStage::new()) // add stages here
441/// .build();
442///
443/// assert_eq!(pipeline.len(), 0);
444/// ```
445#[derive(Default)]
446pub struct PipelineBuilder {
447 stages: Vec<(Arc<dyn Stage>, FailMode)>,
448}
449
450impl PipelineBuilder {
451 /// Add a stage to the pipeline with [`FailMode::Open`]. Stages are
452 /// evaluated in the order they are added.
453 pub fn stage(self, s: impl Stage) -> Self {
454 self.stage_with_fail_mode(s, FailMode::Open)
455 }
456
457 /// Add a pre-boxed stage (useful when the stage is already behind an
458 /// `Arc`) with [`FailMode::Open`].
459 pub fn arc_stage(self, s: Arc<dyn Stage>) -> Self {
460 self.arc_stage_with_fail_mode(s, FailMode::Open)
461 }
462
463 /// Add a stage with an explicit [`FailMode`] for when it returns `Err`.
464 ///
465 /// # Examples
466 ///
467 /// ```rust
468 /// use guardrail_core::pipeline::{FailMode, STAGE_ERROR_CODE};
469 /// use guardrail_core::test_helpers::{clean_request, ErrorStage};
470 /// use guardrail_core::{Decision, PipelineBuilder};
471 ///
472 /// # tokio_test::block_on(async {
473 /// let pipeline = PipelineBuilder::default()
474 /// .stage_with_fail_mode(ErrorStage, FailMode::Closed)
475 /// .build();
476 /// let (decision, _req) = pipeline.run(clean_request()).await.unwrap();
477 /// match decision {
478 /// Decision::Block { code, .. } => assert_eq!(code.as_str(), STAGE_ERROR_CODE),
479 /// other => panic!("expected Block, got {other:?}"),
480 /// }
481 /// # });
482 /// ```
483 pub fn stage_with_fail_mode(self, s: impl Stage, fail_mode: FailMode) -> Self {
484 self.arc_stage_with_fail_mode(Arc::new(s), fail_mode)
485 }
486
487 /// Add a pre-boxed stage with an explicit [`FailMode`].
488 pub fn arc_stage_with_fail_mode(mut self, s: Arc<dyn Stage>, fail_mode: FailMode) -> Self {
489 self.stages.push((s, fail_mode));
490 self
491 }
492
493 /// Consume the builder and return a configured `Pipeline`.
494 pub fn build(self) -> Pipeline {
495 Pipeline {
496 stages: self.stages,
497 }
498 }
499}
500
501#[cfg(test)]
502mod tests {
503 use super::*;
504 use crate::test_helpers::{clean_request, BlockingStage, PassthroughStage};
505
506 #[tokio::test]
507 async fn test_empty_pipeline_allows() {
508 let pipeline = PipelineBuilder::default().build();
509 let req = clean_request();
510 let (decision, _) = pipeline.run(req).await.unwrap();
511 assert_eq!(decision, Decision::Allow);
512 }
513
514 #[tokio::test]
515 async fn test_single_pass_stage_allows() {
516 let pipeline = PipelineBuilder::default().stage(PassthroughStage).build();
517 let req = clean_request();
518 let (decision, _) = pipeline.run(req).await.unwrap();
519 assert_eq!(decision, Decision::Allow);
520 }
521
522 #[tokio::test]
523 async fn test_blocking_stage_short_circuits() {
524 let pipeline = PipelineBuilder::default()
525 .stage(BlockingStage)
526 .stage(PassthroughStage)
527 .build();
528 let req = clean_request();
529 let (decision, _) = pipeline.run(req).await.unwrap();
530 assert!(matches!(decision, Decision::Block { .. }));
531 }
532
533 #[tokio::test]
534 async fn test_single_redacting_stage_returns_redact_not_allow() {
535 // Regression test: the pipeline previously always collapsed a
536 // successful redaction down to `Decision::Allow` by the time
537 // `run()` returned, even though the stage itself correctly
538 // returned `Decision::Redact`. This made `Decision::Redact`
539 // unreachable from any caller of `Pipeline::run`.
540 use crate::test_helpers::RedactingStage;
541
542 let pipeline = PipelineBuilder::default()
543 .stage(RedactingStage::new(
544 "PII detected and redacted: Email",
545 vec!["email".to_string()],
546 ))
547 .build();
548
549 let req = clean_request();
550 let (decision, _) = pipeline.run(req).await.unwrap();
551
552 match decision {
553 Decision::Redact {
554 reason, entities, ..
555 } => {
556 assert_eq!(reason, "PII detected and redacted: Email");
557 assert_eq!(entities, vec!["email".to_string()]);
558 }
559 other => panic!("expected Decision::Redact, got {other:?}"),
560 }
561 }
562
563 #[tokio::test]
564 async fn test_multiple_redacting_stages_accumulate_reasons_and_entities() {
565 use crate::test_helpers::RedactingStage;
566
567 let pipeline = PipelineBuilder::default()
568 .stage(RedactingStage::with_name(
569 "stage_a",
570 "redacted email",
571 vec!["email".to_string()],
572 ))
573 .stage(RedactingStage::with_name(
574 "stage_b",
575 "redacted phone",
576 vec!["phone".to_string()],
577 ))
578 .build();
579
580 let req = clean_request();
581 let (decision, _) = pipeline.run(req).await.unwrap();
582
583 match decision {
584 Decision::Redact {
585 reason, entities, ..
586 } => {
587 assert_eq!(reason, "redacted email; redacted phone");
588 assert_eq!(entities, vec!["email".to_string(), "phone".to_string()]);
589 }
590 other => panic!("expected Decision::Redact, got {other:?}"),
591 }
592 }
593
594 #[tokio::test]
595 async fn test_duplicate_entities_across_stages_are_deduplicated() {
596 use crate::test_helpers::RedactingStage;
597
598 // Two stages both report "email" — the final entity list must not
599 // contain it twice.
600 let pipeline = PipelineBuilder::default()
601 .stage(RedactingStage::with_name(
602 "stage_a",
603 "first pass",
604 vec!["email".to_string()],
605 ))
606 .stage(RedactingStage::with_name(
607 "stage_b",
608 "second pass",
609 vec!["email".to_string(), "phone".to_string()],
610 ))
611 .build();
612
613 let req = clean_request();
614 let (decision, _) = pipeline.run(req).await.unwrap();
615
616 match decision {
617 Decision::Redact { entities, .. } => {
618 assert_eq!(entities, vec!["email".to_string(), "phone".to_string()]);
619 }
620 other => panic!("expected Decision::Redact, got {other:?}"),
621 }
622 }
623
624 #[tokio::test]
625 async fn test_redact_then_block_returns_block_not_redact() {
626 // A request that gets redacted by an earlier stage and then blocked
627 // by a later stage must surface as Block — blocking always takes
628 // priority since it's a stronger guarantee (the request never
629 // reaches the upstream either way).
630 use crate::test_helpers::RedactingStage;
631
632 let pipeline = PipelineBuilder::default()
633 .stage(RedactingStage::new(
634 "redacted first",
635 vec!["email".to_string()],
636 ))
637 .stage(BlockingStage)
638 .build();
639
640 let req = clean_request();
641 let (decision, _) = pipeline.run(req).await.unwrap();
642
643 assert!(matches!(decision, Decision::Block { .. }));
644 }
645
646 #[tokio::test]
647 async fn test_redacted_request_is_passed_to_subsequent_stages() {
648 // The mutated request from a redacting stage must be visible to
649 // stages that run after it (already correctly implemented before
650 // this fix, but verified here alongside the new accumulation logic
651 // to guard against a future regression).
652 use crate::test_helpers::RedactingStage;
653
654 let pipeline = PipelineBuilder::default()
655 .stage(RedactingStage::new("redact", vec!["email".to_string()]))
656 .stage(PassthroughStage)
657 .build();
658
659 let req = clean_request();
660 let (decision, final_req) = pipeline.run(req.clone()).await.unwrap();
661
662 assert!(matches!(decision, Decision::Redact { .. }));
663 // RedactingStage's `mutated` is `req.clone()` (identical content in
664 // this test helper), but the key invariant is that the SAME final
665 // request flows through to PassthroughStage and out the other end
666 // without being silently reset to the original.
667 assert_eq!(final_req.user_text(), req.user_text());
668 }
669
670 #[tokio::test]
671 async fn test_pipeline_len() {
672 let pipeline = PipelineBuilder::default()
673 .stage(PassthroughStage)
674 .stage(PassthroughStage)
675 .build();
676 assert_eq!(pipeline.len(), 2);
677 assert!(!pipeline.is_empty());
678 }
679
680 #[tokio::test]
681 async fn test_run_with_observer_records_each_stage() {
682 let pipeline = PipelineBuilder::default()
683 .stage(PassthroughStage)
684 .stage(PassthroughStage)
685 .build();
686
687 let mut observed = Vec::new();
688 let (decision, _) = pipeline
689 .run_with_observer(clean_request(), |stage, elapsed| {
690 observed.push((stage.to_string(), elapsed));
691 })
692 .await
693 .unwrap();
694
695 assert_eq!(decision, Decision::Allow);
696 assert_eq!(observed.len(), 2);
697 assert!(observed.iter().all(|(name, _)| name == "passthrough"));
698 }
699
700 #[tokio::test]
701 async fn test_erroring_stage_fails_open_by_default_and_continues() {
702 use crate::test_helpers::ErrorStage;
703
704 let pipeline = PipelineBuilder::default()
705 .stage(ErrorStage)
706 .stage(BlockingStage)
707 .build();
708
709 let mut outcomes = Vec::new();
710 let (decision, _) = pipeline
711 .run_with_events(clean_request(), |e| outcomes.push((e.stage, e.outcome)))
712 .await
713 .unwrap();
714
715 // The error is treated as Allow, so the next stage still runs.
716 assert!(matches!(decision, Decision::Block { .. }));
717 assert_eq!(
718 outcomes,
719 vec![
720 ("error_stage", StageOutcome::ErrorAllowed),
721 ("blocking", StageOutcome::Completed),
722 ]
723 );
724 }
725
726 #[tokio::test]
727 async fn test_erroring_stage_with_fail_closed_blocks_and_short_circuits() {
728 use crate::test_helpers::ErrorStage;
729
730 let pipeline = PipelineBuilder::default()
731 .stage_with_fail_mode(ErrorStage, FailMode::Closed)
732 .stage(PassthroughStage)
733 .build();
734
735 let mut outcomes = Vec::new();
736 let (decision, _) = pipeline
737 .run_with_events(clean_request(), |e| outcomes.push((e.stage, e.outcome)))
738 .await
739 .unwrap();
740
741 match decision {
742 Decision::Block { code, reason } => {
743 assert_eq!(code.as_str(), STAGE_ERROR_CODE);
744 assert!(reason.contains("error_stage"));
745 // The internal error text must not reach the caller.
746 assert!(!reason.contains("simulated stage failure"));
747 }
748 other => panic!("expected Block, got {other:?}"),
749 }
750 assert_eq!(outcomes, vec![("error_stage", StageOutcome::ErrorBlocked)]);
751 }
752
753 #[tokio::test]
754 async fn test_fail_mode_is_per_stage() {
755 use crate::test_helpers::ErrorStage;
756
757 // A fail-open erroring stage followed by a fail-closed healthy one:
758 // only the stage that errors has its fail mode applied.
759 let pipeline = PipelineBuilder::default()
760 .stage_with_fail_mode(ErrorStage, FailMode::Open)
761 .stage_with_fail_mode(PassthroughStage, FailMode::Closed)
762 .build();
763
764 let (decision, _) = pipeline.run(clean_request()).await.unwrap();
765 assert_eq!(decision, Decision::Allow);
766 }
767
768 #[tokio::test]
769 async fn test_pipeline_new_uses_fail_open() {
770 use crate::test_helpers::ErrorStage;
771
772 let pipeline = Pipeline::new(vec![Arc::new(ErrorStage)]);
773 let (decision, _) = pipeline.run(clean_request()).await.unwrap();
774 assert_eq!(decision, Decision::Allow);
775 }
776
777 #[tokio::test]
778 async fn test_run_with_observer_stops_after_block() {
779 let pipeline = PipelineBuilder::default()
780 .stage(BlockingStage)
781 .stage(PassthroughStage)
782 .build();
783
784 let mut observed = Vec::new();
785 let (decision, _) = pipeline
786 .run_with_observer(clean_request(), |stage, elapsed| {
787 observed.push((stage.to_string(), elapsed));
788 })
789 .await
790 .unwrap();
791
792 assert!(matches!(decision, Decision::Block { .. }));
793 // Only the blocking stage should have been observed.
794 assert_eq!(observed.len(), 1);
795 assert_eq!(observed[0].0, "blocking");
796 }
797}