Skip to main content

featherbit/graph/
engine.rs

1//! Policy compilation and graph execution.
2//!
3//! Turns a [`PolicyConfig`] (nodes + `from`/`to` edges from `gateway.yaml`)
4//! into a [`CompiledGraph`] with instantiated plugins, then walks that graph
5//! per request: success edges on `Ok`, error edges (or the policy-level
6//! catch-all handler) on failure, stopping at terminal `client` nodes.
7
8use std::collections::{HashMap, HashSet};
9use std::sync::Arc;
10
11use crate::config::PolicyConfig;
12use crate::context::Context;
13use crate::debug::{EdgeKind, StepOutcome, TraceRecorder};
14use crate::plugins::resources::PluginResources;
15use crate::plugins::{self, Plugin};
16
17/// A compiled, ready-to-execute graph with instantiated plugins.
18///
19/// Built by [`compile_policy`] and shared read-only by the data-plane; one
20/// instance serves all requests matching the route that references its policy.
21pub struct CompiledGraph {
22    nodes: HashMap<String, Box<dyn Plugin>>,
23    /// node_id -> next node_id on success
24    success_edges: HashMap<String, String>,
25    /// node_id -> next node_id on error
26    error_edges: HashMap<String, String>,
27    /// The node that starts the pipeline (first node after listener)
28    entry_node_id: String,
29    /// Node IDs that are terminal (client nodes) — execution stops when we reach one
30    terminal_node_ids: HashSet<String>,
31    /// Policy-level catch-all error handler node ID
32    catch_all_handler: Option<String>,
33    /// Policy name, used as a metrics label.
34    policy_name: String,
35    /// Process-wide services (metrics registry, shared clients); per-node
36    /// metrics recording is disabled when `resources.metrics` is `None`.
37    resources: Arc<PluginResources>,
38}
39
40impl CompiledGraph {
41    /// Executes the graph with the given initial context and returns the
42    /// final context (with `response` populated).
43    ///
44    /// Walks nodes starting at the entry node (the first node after the
45    /// listener), following the success edge after each `Ok` result. On a
46    /// plugin error the error is tagged with the failing node's ID, pushed
47    /// onto `ctx.errors`, and execution jumps to the node's `error` edge if
48    /// one exists, otherwise to the policy-level catch-all handler; with
49    /// neither, execution stops and a generic JSON 500 response is written.
50    /// Reaching a terminal `client` node (or a node with no success edge)
51    /// ends the walk. This method never fails: every outcome is expressed
52    /// through the returned context's response.
53    pub async fn execute(&self, ctx: Context) -> Context {
54        self.run(ctx, None).await.0
55    }
56
57    /// Same walk as [`execute`](Self::execute), additionally recording a
58    /// [`Trace`] of every node: the context after each one, the outcome, and
59    /// which edge the engine followed.
60    ///
61    /// Used by debug mode and the sandbox. Deliberately the *same* loop rather
62    /// than a parallel implementation — a trace that could drift from the real
63    /// execution path would be worse than no trace at all.
64    pub async fn execute_traced(
65        &self,
66        ctx: Context,
67        recorder: TraceRecorder,
68    ) -> (Context, TraceRecorder) {
69        let (ctx, rec) = self.run(ctx, Some(recorder)).await;
70        (
71            ctx,
72            rec.expect("recorder is returned when one was supplied"),
73        )
74    }
75
76    /// The single graph walk, optionally recording each step.
77    ///
78    /// When `recorder` is `None` the only added cost is one `Option`
79    /// discriminant check per node — the same shape as the existing metrics
80    /// branch, and perfectly branch-predicted.
81    async fn run(
82        &self,
83        mut ctx: Context,
84        mut recorder: Option<TraceRecorder>,
85    ) -> (Context, Option<TraceRecorder>) {
86        let mut current_node_id = self.entry_node_id.clone();
87
88        loop {
89            let node = match self.nodes.get(&current_node_id) {
90                Some(n) => n,
91                None => {
92                    ctx.response.status_code = 500;
93                    ctx.response.body = bytes::Bytes::from(format!(
94                        "Graph error: node '{}' not found",
95                        current_node_id
96                    ));
97                    if let Some(r) = recorder.as_mut() {
98                        r.record_step(
99                            &current_node_id,
100                            "<missing>",
101                            StepOutcome::Error {
102                                code: "NODE_NOT_FOUND".to_string(),
103                                message: format!("node '{}' not found", current_node_id),
104                            },
105                            std::time::Duration::ZERO,
106                            EdgeKind::NodeNotFound,
107                            None,
108                            &ctx,
109                        );
110                    }
111                    break;
112                }
113            };
114
115            let named_inputs = HashMap::new();
116
117            let node_type = node.plugin_type().to_string();
118            let started = std::time::Instant::now();
119            let result = node.execute(ctx, &named_inputs).await;
120            let elapsed = started.elapsed();
121            if let Some(ref m) = self.resources.metrics {
122                m.node_execution_count
123                    .with_label_values(&[&self.policy_name, &current_node_id, &node_type])
124                    .inc();
125                m.node_execution_duration
126                    .with_label_values(&[&self.policy_name, &current_node_id])
127                    .observe(elapsed.as_secs_f64());
128            }
129
130            match result {
131                Ok(output) => {
132                    ctx = output.context;
133
134                    // If this is a terminal node (client), we're done
135                    let terminal = self.terminal_node_ids.contains(&current_node_id);
136                    let next = if terminal {
137                        None
138                    } else {
139                        self.success_edges.get(&current_node_id)
140                    };
141                    if let Some(r) = recorder.as_mut() {
142                        let edge = if terminal {
143                            EdgeKind::Terminal
144                        } else if next.is_some() {
145                            EdgeKind::Success
146                        } else {
147                            EdgeKind::EndOfChain
148                        };
149                        r.record_step(
150                            &current_node_id,
151                            &node_type,
152                            StepOutcome::Success,
153                            elapsed,
154                            edge,
155                            next.map(String::as_str),
156                            &ctx,
157                        );
158                    }
159                    match next {
160                        Some(next_id) => current_node_id = next_id.clone(),
161                        // Terminal, or no success edge — end of chain.
162                        None => break,
163                    }
164                }
165                Err(mut err) => {
166                    if let Some(ref m) = self.resources.metrics {
167                        m.node_errors
168                            .with_label_values(&[
169                                &self.policy_name,
170                                &current_node_id,
171                                &err.error.code,
172                            ])
173                            .inc();
174                    }
175
176                    // Tag the error with the node_id that produced it
177                    err.error.node_id = current_node_id.clone();
178                    let outcome = StepOutcome::Error {
179                        code: err.error.code.clone(),
180                        message: err.error.message.clone(),
181                    };
182                    err.context.errors.push(err.error);
183                    ctx = err.context;
184
185                    // Try per-node error edge first, then the policy catch-all.
186                    let next_error = self
187                        .error_edges
188                        .get(&current_node_id)
189                        .map(|id| (id.clone(), EdgeKind::Error))
190                        .or_else(|| {
191                            self.catch_all_handler
192                                .as_ref()
193                                .map(|id| (id.clone(), EdgeKind::CatchAll))
194                        });
195
196                    if next_error.is_none() {
197                        // No error handling — return 500
198                        ctx.response.status_code = 500;
199                        ctx.response.body = bytes::Bytes::from(
200                            r#"{"error": "internal_error", "message": "Unhandled error in routing policy"}"#,
201                        );
202                        ctx.response.headers.insert(
203                            "content-type".to_string(),
204                            vec!["application/json".to_string()],
205                        );
206                    }
207
208                    if let Some(r) = recorder.as_mut() {
209                        let (edge, next_id) = match &next_error {
210                            Some((id, kind)) => (*kind, Some(id.as_str())),
211                            None => (EdgeKind::Unhandled, None),
212                        };
213                        r.record_step(
214                            &current_node_id,
215                            &node_type,
216                            outcome,
217                            elapsed,
218                            edge,
219                            next_id,
220                            &ctx,
221                        );
222                    }
223
224                    match next_error {
225                        Some((id, _)) => current_node_id = id,
226                        None => break,
227                    }
228                }
229            }
230        }
231
232        (ctx, recorder)
233    }
234}
235
236/// Compiles a [`PolicyConfig`] into a ready-to-execute [`CompiledGraph`].
237///
238/// Instantiates each node's plugin via `plugins::create_plugin`, records
239/// `client` nodes as terminals, and indexes edges by source port: `success`
240/// and `out` become success edges, `error` becomes error edges. The entry
241/// node is the target of the listener's success/out edge. Fails if the
242/// policy has no `listener` node, a plugin cannot be constructed, or an edge
243/// uses an unknown source port.
244///
245/// Edge endpoints use the `node_id.port` form:
246///
247/// ```yaml
248/// edges:
249///   - from: listener.out
250///     to: rewrite.in
251///   - from: rewrite.success
252///     to: client.in
253///   - from: rewrite.error
254///     to: error-handler.in
255/// ```
256pub fn compile_policy(
257    policy: &PolicyConfig,
258    resources: Arc<PluginResources>,
259) -> Result<CompiledGraph, String> {
260    let mut nodes: HashMap<String, Box<dyn Plugin>> = HashMap::new();
261    let mut success_edges: HashMap<String, String> = HashMap::new();
262    let mut error_edges: HashMap<String, String> = HashMap::new();
263    let mut listener_node_id = None;
264    let mut terminal_node_ids = HashSet::new();
265
266    // Instantiate all nodes
267    for node_config in &policy.nodes {
268        tracing::debug!(
269            "Compiling node '{}' type='{}' config={:?}",
270            node_config.id,
271            node_config.node_type,
272            node_config.config
273        );
274        // Interpolate `${ENV_VAR}` in the node config before instantiating the
275        // plugin. This is the source-agnostic choke point: config authored in the
276        // Web UI / Admin API (or delivered over etcd) arrives as parsed JSON and
277        // never sees the file-text interpolation in `load_yaml_with_env`, so we
278        // resolve env vars here. File-loaded values are already resolved (no-op).
279        let mut config = node_config.config.clone();
280        for value in config.values_mut() {
281            crate::config::interpolate_env_json(value);
282        }
283        let plugin = plugins::create_plugin(&node_config.node_type, &config, &resources)?;
284        if node_config.node_type == "listener" {
285            listener_node_id = Some(node_config.id.clone());
286        }
287        if node_config.node_type == "client" {
288            terminal_node_ids.insert(node_config.id.clone());
289        }
290        nodes.insert(node_config.id.clone(), plugin);
291    }
292
293    let listener_node_id = listener_node_id.ok_or("Policy must have a listener node")?;
294
295    // Parse edges
296    for edge in &policy.edges {
297        let (from_node, from_port) = parse_edge_endpoint(&edge.from)?;
298        let (to_node, _to_port) = parse_edge_endpoint(&edge.to)?;
299
300        match from_port.as_str() {
301            "success" | "out" => {
302                success_edges.insert(from_node, to_node);
303            }
304            "error" => {
305                error_edges.insert(from_node, to_node);
306            }
307            other => {
308                return Err(format!("Unknown edge port: '{}'", other));
309            }
310        }
311    }
312
313    // The entry node is the first node connected from the listener's success/out edge
314    let entry_node_id = success_edges
315        .get(&listener_node_id)
316        .cloned()
317        .unwrap_or_else(|| listener_node_id.clone());
318
319    Ok(CompiledGraph {
320        nodes,
321        success_edges,
322        error_edges,
323        entry_node_id,
324        terminal_node_ids,
325        catch_all_handler: policy.error_handler.clone(),
326        policy_name: policy.name.clone(),
327        resources,
328    })
329}
330
331/// Parses `"node_id.port"` into `(node_id, port)`, defaulting the port to
332/// `"out"` when no dot is present. Splits on the last dot, so node IDs may
333/// themselves contain dots.
334fn parse_edge_endpoint(endpoint: &str) -> Result<(String, String), String> {
335    if let Some(dot_pos) = endpoint.rfind('.') {
336        let node_id = endpoint[..dot_pos].to_string();
337        let port = endpoint[dot_pos + 1..].to_string();
338        Ok((node_id, port))
339    } else {
340        Ok((endpoint.to_string(), "out".to_string()))
341    }
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347    use crate::config::{EdgeConfig, NodeConfig, PolicyConfig};
348    use crate::context::{GatewayRequest, GatewayResponse, Protocol};
349    use bytes::Bytes;
350
351    #[test]
352    fn test_parse_edge_endpoint() {
353        let (node, port) = parse_edge_endpoint("listener.out").unwrap();
354        assert_eq!(node, "listener");
355        assert_eq!(port, "out");
356
357        let (node, port) = parse_edge_endpoint("upstream.error").unwrap();
358        assert_eq!(node, "upstream");
359        assert_eq!(port, "error");
360
361        let (node, port) = parse_edge_endpoint("rewrite.success").unwrap();
362        assert_eq!(node, "rewrite");
363        assert_eq!(port, "success");
364    }
365
366    fn test_context(path: &str) -> Context {
367        Context {
368            request: GatewayRequest {
369                method: "GET".to_string(),
370                path: path.to_string(),
371                host: "localhost".to_string(),
372                scheme: "http".to_string(),
373                headers: HashMap::new(),
374                query_params: HashMap::new(),
375                body: Bytes::new(),
376                remote_addr: "127.0.0.1:12345".to_string(),
377                protocol: Protocol::Http1,
378            },
379            response: GatewayResponse {
380                status_code: 0,
381                headers: HashMap::new(),
382                body: Bytes::new(),
383            },
384            message: HashMap::new(),
385            errors: Vec::new(),
386        }
387    }
388
389    #[tokio::test]
390    async fn test_graph_proxy_rewrite_pipeline() {
391        let mut rewrite_config = HashMap::new();
392        rewrite_config.insert(
393            "strip_path_prefix".to_string(),
394            serde_json::Value::String("/api/v1".to_string()),
395        );
396        rewrite_config.insert(
397            "phase".to_string(),
398            serde_json::Value::String("request".to_string()),
399        );
400
401        let policy = PolicyConfig {
402            name: "test".to_string(),
403            error_handler: None,
404            nodes: vec![
405                NodeConfig {
406                    id: "listener".to_string(),
407                    node_type: "listener".to_string(),
408                    config: HashMap::new(),
409                    position: None,
410                },
411                NodeConfig {
412                    id: "rewrite".to_string(),
413                    node_type: "proxy-rewrite".to_string(),
414                    config: rewrite_config,
415                    position: None,
416                },
417                NodeConfig {
418                    id: "client".to_string(),
419                    node_type: "client".to_string(),
420                    config: HashMap::new(),
421                    position: None,
422                },
423            ],
424            edges: vec![
425                EdgeConfig {
426                    from: "listener.out".to_string(),
427                    to: "rewrite.in".to_string(),
428                },
429                EdgeConfig {
430                    from: "rewrite.success".to_string(),
431                    to: "client.in".to_string(),
432                },
433            ],
434        };
435
436        let graph = compile_policy(&policy, PluginResources::empty()).unwrap();
437        let ctx = test_context("/api/v1/users");
438        let result = graph.execute(ctx).await;
439
440        assert_eq!(result.request.path, "/users");
441    }
442
443    #[tokio::test]
444    async fn test_graph_records_node_metrics() {
445        let policy = PolicyConfig {
446            name: "metrics-test".to_string(),
447            error_handler: None,
448            nodes: vec![
449                NodeConfig {
450                    id: "listener".to_string(),
451                    node_type: "listener".to_string(),
452                    config: HashMap::new(),
453                    position: None,
454                },
455                NodeConfig {
456                    id: "client".to_string(),
457                    node_type: "client".to_string(),
458                    config: HashMap::new(),
459                    position: None,
460                },
461            ],
462            edges: vec![EdgeConfig {
463                from: "listener.out".to_string(),
464                to: "client.in".to_string(),
465            }],
466        };
467
468        let metrics = Arc::new(crate::metrics::GatewayMetrics::new());
469        let graph = compile_policy(&policy, PluginResources::new(Some(metrics.clone()))).unwrap();
470        graph.execute(test_context("/test")).await;
471
472        assert_eq!(
473            metrics
474                .node_execution_count
475                .with_label_values(&["metrics-test", "client", "client"])
476                .get(),
477            1
478        );
479    }
480
481    #[tokio::test]
482    async fn test_graph_error_handler_catch_all() {
483        let policy = PolicyConfig {
484            name: "test".to_string(),
485            error_handler: Some("error-handler".to_string()),
486            nodes: vec![
487                NodeConfig {
488                    id: "listener".to_string(),
489                    node_type: "listener".to_string(),
490                    config: HashMap::new(),
491                    position: None,
492                },
493                NodeConfig {
494                    id: "error-handler".to_string(),
495                    node_type: "error-handler".to_string(),
496                    config: {
497                        let mut c = HashMap::new();
498                        c.insert("status_code".to_string(), serde_json::json!(503));
499                        c.insert(
500                            "body_template".to_string(),
501                            serde_json::Value::String(r#"{"error": "{{error.code}}"}"#.to_string()),
502                        );
503                        c
504                    },
505                    position: None,
506                },
507                NodeConfig {
508                    id: "client".to_string(),
509                    node_type: "client".to_string(),
510                    config: HashMap::new(),
511                    position: None,
512                },
513            ],
514            edges: vec![
515                EdgeConfig {
516                    from: "listener.out".to_string(),
517                    to: "error-handler.in".to_string(),
518                },
519                EdgeConfig {
520                    from: "error-handler.success".to_string(),
521                    to: "client.in".to_string(),
522                },
523            ],
524        };
525
526        let graph = compile_policy(&policy, PluginResources::empty()).unwrap();
527        let ctx = test_context("/test");
528        let result = graph.execute(ctx).await;
529
530        assert_eq!(result.response.status_code, 503);
531    }
532
533    // ---- debug tracing ---------------------------------------------------
534
535    use crate::debug::{CaptureOptions, EdgeKind, StepOutcome, TraceRecorder, TraceSource};
536    use std::time::Duration;
537
538    fn recorder(ctx: &Context) -> TraceRecorder {
539        TraceRecorder::new(ctx, CaptureOptions::default(), 100)
540    }
541
542    fn finish(rec: TraceRecorder, ctx: &Context) -> crate::debug::Trace {
543        rec.finish(
544            "t".to_string(),
545            0,
546            TraceSource::Request,
547            Some("r".to_string()),
548            "p".to_string(),
549            ctx,
550            Duration::from_millis(1),
551        )
552    }
553
554    /// A node that always fails, so error-edge routing can be exercised without
555    /// depending on a real plugin's failure mode.
556    struct AlwaysFails;
557
558    #[async_trait::async_trait]
559    impl Plugin for AlwaysFails {
560        fn plugin_type(&self) -> &str {
561            "always-fails"
562        }
563        async fn execute(
564            &self,
565            ctx: Context,
566            _: &HashMap<String, serde_json::Value>,
567        ) -> crate::plugins::PluginResult {
568            Err(crate::plugins::PluginExecutionError {
569                context: ctx,
570                error: crate::context::GatewayError {
571                    node_id: String::new(),
572                    code: "BOOM".to_string(),
573                    message: "exploded".to_string(),
574                    metadata: HashMap::new(),
575                },
576            })
577        }
578    }
579
580    /// Builds a graph by hand so a failing node can be injected.
581    fn failing_graph(
582        error_edges: HashMap<String, String>,
583        catch_all: Option<String>,
584    ) -> CompiledGraph {
585        let mut nodes: HashMap<String, Box<dyn Plugin>> = HashMap::new();
586        nodes.insert("boom".to_string(), Box::new(AlwaysFails));
587        nodes.insert(
588            "client".to_string(),
589            plugins::create_plugin("client", &HashMap::new(), &PluginResources::empty()).unwrap(),
590        );
591        CompiledGraph {
592            nodes,
593            success_edges: HashMap::new(),
594            error_edges,
595            entry_node_id: "boom".to_string(),
596            terminal_node_ids: HashSet::from(["client".to_string()]),
597            catch_all_handler: catch_all,
598            policy_name: "p".to_string(),
599            resources: PluginResources::empty(),
600        }
601    }
602
603    fn rewrite_policy() -> PolicyConfig {
604        let mut cfg = HashMap::new();
605        cfg.insert(
606            "strip_path_prefix".to_string(),
607            serde_json::Value::String("/api/v1".to_string()),
608        );
609        cfg.insert(
610            "phase".to_string(),
611            serde_json::Value::String("request".to_string()),
612        );
613        PolicyConfig {
614            name: "traced".to_string(),
615            error_handler: None,
616            nodes: vec![
617                NodeConfig {
618                    id: "listener".to_string(),
619                    node_type: "listener".to_string(),
620                    config: HashMap::new(),
621                    position: None,
622                },
623                NodeConfig {
624                    id: "rewrite".to_string(),
625                    node_type: "proxy-rewrite".to_string(),
626                    config: cfg,
627                    position: None,
628                },
629                NodeConfig {
630                    id: "client".to_string(),
631                    node_type: "client".to_string(),
632                    config: HashMap::new(),
633                    position: None,
634                },
635            ],
636            edges: vec![
637                EdgeConfig {
638                    from: "listener.out".to_string(),
639                    to: "rewrite.in".to_string(),
640                },
641                EdgeConfig {
642                    from: "rewrite.success".to_string(),
643                    to: "client.in".to_string(),
644                },
645            ],
646        }
647    }
648
649    #[tokio::test]
650    async fn test_trace_records_each_node_in_order() {
651        let graph = compile_policy(&rewrite_policy(), PluginResources::empty()).unwrap();
652        let ctx = test_context("/api/v1/users");
653        let rec = recorder(&ctx);
654        let (out, rec) = graph.execute_traced(ctx, rec).await;
655        let trace = finish(rec, &out);
656
657        let ids: Vec<&str> = trace.steps.iter().map(|s| s.node_id.as_str()).collect();
658        assert_eq!(ids, vec!["rewrite", "client"]);
659        assert_eq!(trace.steps[0].node_type, "proxy-rewrite");
660        assert_eq!(trace.steps[0].edge, EdgeKind::Success);
661        assert_eq!(trace.steps[0].next_node_id.as_deref(), Some("client"));
662        // The terminal node ends the walk.
663        assert_eq!(trace.steps[1].edge, EdgeKind::Terminal);
664        assert_eq!(trace.steps[1].next_node_id, None);
665    }
666
667    /// The trace must show the rewrite: initial path in, rewritten path out.
668    #[tokio::test]
669    async fn test_trace_captures_what_the_plugin_changed() {
670        let graph = compile_policy(&rewrite_policy(), PluginResources::empty()).unwrap();
671        let ctx = test_context("/api/v1/users");
672        let rec = recorder(&ctx);
673        let (out, rec) = graph.execute_traced(ctx, rec).await;
674        let trace = finish(rec, &out);
675
676        assert_eq!(trace.initial.request.path, "/api/v1/users");
677        assert_eq!(trace.steps[0].after.request.path, "/users");
678
679        let changes = crate::debug::diff::diff(&trace.initial, &trace.steps[0].after);
680        let c = changes
681            .iter()
682            .find(|c| c.path == "request.path")
683            .expect("path change");
684        assert_eq!(c.before.as_deref(), Some("/api/v1/users"));
685        assert_eq!(c.after.as_deref(), Some("/users"));
686    }
687
688    /// Tracing must never change what the gateway does. If this ever fails,
689    /// the debug feature has become a heisenbug generator.
690    #[tokio::test]
691    async fn test_tracing_does_not_alter_behaviour() {
692        let policy = rewrite_policy();
693        let plain = compile_policy(&policy, PluginResources::empty()).unwrap();
694        let traced = compile_policy(&policy, PluginResources::empty()).unwrap();
695
696        let untraced_out = plain.execute(test_context("/api/v1/users")).await;
697        let ctx = test_context("/api/v1/users");
698        let rec = recorder(&ctx);
699        let (traced_out, _) = traced.execute_traced(ctx, rec).await;
700
701        assert_eq!(untraced_out.request.path, traced_out.request.path);
702        assert_eq!(
703            untraced_out.response.status_code,
704            traced_out.response.status_code
705        );
706        assert_eq!(untraced_out.response.body, traced_out.response.body);
707        assert_eq!(untraced_out.message, traced_out.message);
708        assert_eq!(untraced_out.errors, traced_out.errors);
709    }
710
711    #[tokio::test]
712    async fn test_trace_records_error_edge() {
713        let graph = failing_graph(
714            HashMap::from([("boom".to_string(), "client".to_string())]),
715            None,
716        );
717        let ctx = test_context("/x");
718        let rec = recorder(&ctx);
719        let (out, rec) = graph.execute_traced(ctx, rec).await;
720        let trace = finish(rec, &out);
721
722        assert_eq!(trace.steps[0].node_id, "boom");
723        assert_eq!(
724            trace.steps[0].outcome,
725            StepOutcome::Error {
726                code: "BOOM".to_string(),
727                message: "exploded".to_string()
728            }
729        );
730        assert_eq!(trace.steps[0].edge, EdgeKind::Error);
731        assert_eq!(trace.steps[0].next_node_id.as_deref(), Some("client"));
732        // The error was recorded on the context the step captured.
733        assert_eq!(trace.steps[0].after.errors.len(), 1);
734        assert_eq!(trace.steps[0].after.errors[0].node_id, "boom");
735    }
736
737    #[tokio::test]
738    async fn test_trace_records_catch_all_edge() {
739        let graph = failing_graph(HashMap::new(), Some("client".to_string()));
740        let ctx = test_context("/x");
741        let rec = recorder(&ctx);
742        let (out, rec) = graph.execute_traced(ctx, rec).await;
743        let trace = finish(rec, &out);
744        assert_eq!(trace.steps[0].edge, EdgeKind::CatchAll);
745    }
746
747    /// The unwired-error-port case: this is the footgun the sandbox's
748    /// `on_error: "stop"` mode deliberately exposes.
749    #[tokio::test]
750    async fn test_trace_records_unhandled_error() {
751        let graph = failing_graph(HashMap::new(), None);
752        let ctx = test_context("/x");
753        let rec = recorder(&ctx);
754        let (out, rec) = graph.execute_traced(ctx, rec).await;
755        let trace = finish(rec, &out);
756
757        assert_eq!(trace.steps.len(), 1);
758        assert_eq!(trace.steps[0].edge, EdgeKind::Unhandled);
759        assert_eq!(trace.steps[0].next_node_id, None);
760        // ...and the engine wrote its generic 500.
761        assert_eq!(out.response.status_code, 500);
762        assert_eq!(trace.steps[0].after.response.status_code, 500);
763    }
764}