Skip to main content

cpex_hosts_python/
error.rs

1// Location: ./crates/cpex-hosts-python/src/error.rs
2// Copyright 2026
3// SPDX-License-Identifier: Apache-2.0
4// Authors: Ted Habeck
5//
6// Host-internal errors and their mapping to `ppe::praxis_policy_core::error::PluginError`.
7//
8// The host distinguishes failure modes the executor cannot see — a venv that
9// would not build, a worker that died mid-flight, a task over the size cap —
10// so operators get an actionable reason. At the trait boundary each maps to a
11// `PluginError`, and the *executor* applies the configured error policy
12// (fail / ignore / disable). The host never implements that policy itself.
13
14use std::collections::HashMap;
15use std::fmt;
16
17use ppe::praxis_policy_core::error::PluginError;
18
19/// Cap on the stderr excerpt carried in `PluginError::Execution.details`.
20///
21/// pip failures are verbose (resolver backtracking can run to hundreds of
22/// lines) and the tail holds the actual error, so `stderr_excerpt` keeps the
23/// last `STDERR_EXCERPT_BYTES` rather than the first.
24const STDERR_EXCERPT_BYTES: usize = 4096;
25
26/// Structured detail keys. Stable — operators and tests match on these.
27const DETAIL_EXIT_CODE: &str = "exit_code";
28const DETAIL_STDERR: &str = "stderr_excerpt";
29const DETAIL_STDERR_TRUNCATED: &str = "stderr_truncated";
30const DETAIL_TIMEOUT_SECS: &str = "timeout_secs";
31const DETAIL_SIZE: &str = "size_bytes";
32const DETAIL_LIMIT: &str = "limit_bytes";
33
34/// Output of a failed child process, in the structured form the error carries.
35///
36/// # Redaction
37///
38/// This is a diagnostic channel that reaches logs, so it must never carry
39/// credential material. It is safe for the two producers that build it today —
40/// `python3 -m venv` and `pip install -r`, neither of which is passed a token
41/// or a credentialed URL — and it is *not* safe to reuse for a process whose
42/// argv, environment, or index URL embeds a secret. `pip`'s own output already
43/// redacts the userinfo component of an index URL, but a caller that puts a
44/// token somewhere else on the command line would defeat that; such a caller
45/// must scrub before constructing this.
46#[derive(Debug, Clone, Default)]
47pub struct ProcessOutput {
48    /// The child's exit status code, or `None` if it was killed by a signal.
49    pub exit_code: Option<i32>,
50
51    /// The child's stderr, already decoded lossily and trimmed.
52    pub stderr: String,
53}
54
55impl ProcessOutput {
56    /// Build from a `std::process::Output`, decoding stderr lossily.
57    ///
58    /// See the type-level redaction note: only use this for a child whose
59    /// output cannot contain credential material.
60    pub fn from_output(output: &std::process::Output) -> Self {
61        Self {
62            exit_code: output.status.code(),
63            stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
64        }
65    }
66
67    /// The trailing `STDERR_EXCERPT_BYTES` of stderr, and whether it was cut.
68    ///
69    /// Splits on a `char` boundary so the excerpt stays valid UTF-8.
70    fn stderr_excerpt(&self) -> (&str, bool) {
71        if self.stderr.len() <= STDERR_EXCERPT_BYTES {
72            return (self.stderr.as_str(), false);
73        }
74        let mut start = self.stderr.len() - STDERR_EXCERPT_BYTES;
75        while start < self.stderr.len() && !self.stderr.is_char_boundary(start) {
76            start += 1;
77        }
78        (&self.stderr[start..], true)
79    }
80
81    /// Render as `exited <code>: <stderr>` for the `Display` message.
82    fn describe(&self) -> String {
83        let status = match self.exit_code {
84            Some(code) => format!("exited {code}"),
85            None => "was killed by a signal".to_string(),
86        };
87        let (excerpt, _) = self.stderr_excerpt();
88        if excerpt.is_empty() {
89            status
90        } else {
91            format!("{status}: {excerpt}")
92        }
93    }
94
95    /// Insert `exit_code` / `stderr_excerpt` into a details map.
96    fn write_details(&self, details: &mut HashMap<String, serde_json::Value>) {
97        details.insert(
98            DETAIL_EXIT_CODE.to_string(),
99            match self.exit_code {
100                Some(code) => serde_json::Value::from(code),
101                None => serde_json::Value::Null,
102            },
103        );
104        let (excerpt, truncated) = self.stderr_excerpt();
105        details.insert(DETAIL_STDERR.to_string(), serde_json::Value::from(excerpt));
106        if truncated {
107            details.insert(
108                DETAIL_STDERR_TRUNCATED.to_string(),
109                serde_json::Value::Bool(true),
110            );
111        }
112    }
113}
114
115/// A failure inside the host, before or around the plugin's own logic.
116#[derive(Debug)]
117pub enum HostError {
118    /// The plugin's configuration cannot support a venv (no plugin dirs, an
119    /// unusable class name).
120    Config { message: String },
121
122    /// Building the virtualenv or installing its requirements failed.
123    VenvBuild { message: String },
124
125    /// A venv build step that ran a child process and got a non-zero exit.
126    ///
127    /// Split out from `Self::VenvBuild` so the exit code and stderr survive as
128    /// structured `details` on the `PluginError` instead of being flattened
129    /// into prose a caller has to string-match. `step` names the command that
130    /// failed ("python3 -m venv", "pip install") and `context` the thing it
131    /// was acting on (the venv path, the requirements file).
132    ///
133    /// Carries process output — see the redaction note on `ProcessOutput`.
134    VenvCommand {
135        step: &'static str,
136        context: String,
137        output: ProcessOutput,
138    },
139
140    /// The worker subprocess could not be launched.
141    WorkerStart { message: String },
142
143    /// The worker process is gone — reader EOF, or a non-zero exit — while a
144    /// request was outstanding. Distinct from a timeout: there is nothing
145    /// left to wait for.
146    WorkerDied { message: String },
147
148    /// No response arrived within the per-invocation timeout.
149    Timeout { timeout_secs: u64 },
150
151    /// The serialized task exceeded the configured `max_content_size`. Caught
152    /// before the write, so nothing was sent.
153    TaskTooLarge { size: usize, limit: usize },
154
155    /// A worker response frame exceeded the configured `max_content_size`.
156    /// Detected mid-read, so the frame was discarded rather than buffered.
157    ResponseTooLarge { size: usize, limit: usize },
158
159    /// The worker returned a structured error response for this request.
160    WorkerError { message: String },
161
162    /// A payload or response could not be serialized or parsed.
163    ///
164    /// The message must never carry payload *values* — only the shape of the
165    /// failure (which field, which type), since it reaches logs.
166    Protocol { message: String },
167
168    /// A credential-bearing hook could not be served safely, so the host
169    /// failed closed rather than dispatching without the token.
170    ///
171    /// The message never contains token material.
172    Credential { message: String },
173
174    /// A `cmf.*` hook could not be translated for a worker that predates
175    /// `cmf.*` hook support, so the host failed closed rather than dispatching
176    /// a payload the plugin would misread or skipping the plugin.
177    ///
178    /// Like `Protocol`, the message names shapes (part types, field names),
179    /// never payload values.
180    LegacyShim { hook: String, message: String },
181}
182
183impl fmt::Display for HostError {
184    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
185        match self {
186            Self::Config { message } => write!(f, "configuration error: {message}"),
187            Self::VenvBuild { message } => write!(f, "venv build failed: {message}"),
188            Self::VenvCommand {
189                step,
190                context,
191                output,
192            } => write!(
193                f,
194                "venv build failed: `{step}` on {context} {}",
195                output.describe()
196            ),
197            Self::WorkerStart { message } => write!(f, "worker failed to start: {message}"),
198            Self::WorkerDied { message } => write!(f, "worker process died: {message}"),
199            Self::Timeout { timeout_secs } => {
200                write!(f, "worker did not respond within {timeout_secs}s")
201            },
202            Self::TaskTooLarge { size, limit } => write!(
203                f,
204                "serialized task is {size} bytes, over the {limit}-byte max_content_size"
205            ),
206            Self::ResponseTooLarge { size, limit } => write!(
207                f,
208                "worker response frame is at least {size} bytes, over the \
209                 {limit}-byte max_content_size; the frame was discarded rather \
210                 than buffered"
211            ),
212            Self::WorkerError { message } => write!(f, "worker returned an error: {message}"),
213            Self::Protocol { message } => write!(f, "protocol error: {message}"),
214            Self::Credential { message } => write!(f, "credential error: {message}"),
215            Self::LegacyShim { hook, message } => write!(
216                f,
217                "hook '{hook}' cannot be served by this worker, which predates cmf hook \
218                 support: {message}"
219            ),
220        }
221    }
222}
223
224impl std::error::Error for HostError {}
225
226impl HostError {
227    /// Short, stable code for the `PluginError::Execution.code` field, so a
228    /// host can branch on the failure mode without string-matching messages.
229    pub fn code(&self) -> &'static str {
230        match self {
231            Self::Config { .. } => "config",
232            Self::VenvBuild { .. } | Self::VenvCommand { .. } => "venv_build_failed",
233            Self::WorkerStart { .. } => "worker_start_failed",
234            Self::WorkerDied { .. } => "worker_died",
235            Self::Timeout { .. } => "timeout",
236            Self::TaskTooLarge { .. } => "task_too_large",
237            Self::ResponseTooLarge { .. } => "response_too_large",
238            Self::WorkerError { .. } => "worker_error",
239            Self::Protocol { .. } => "protocol_error",
240            Self::Credential { .. } => "credential_error",
241            Self::LegacyShim { .. } => "legacy_shim_unsupported",
242        }
243    }
244
245    /// Machine-readable diagnostics for `PluginError::Execution.details`.
246    ///
247    /// The `Display` message is prose for a human; this is the same facts in a
248    /// form an operator's log pipeline or a test can match on without parsing
249    /// English. A failed `pip install` is the motivating case: its exit code
250    /// and stderr tail land here rather than only inside the message string.
251    ///
252    /// Variants whose fields are already fully described by the message (and
253    /// carry no structured facts beyond it) contribute nothing, so the map is
254    /// empty for them rather than padded with a duplicate of the message.
255    ///
256    /// Every value here reaches logs, so nothing that could hold credential
257    /// material — payload values, token bytes — is admitted. The one variant
258    /// carrying child-process output is gated by the redaction note on
259    /// `ProcessOutput`.
260    pub fn details(&self) -> HashMap<String, serde_json::Value> {
261        let mut details = HashMap::new();
262        match self {
263            Self::VenvCommand { step, output, .. } => {
264                details.insert("step".to_string(), serde_json::Value::from(*step));
265                output.write_details(&mut details);
266            },
267            Self::Timeout { timeout_secs } => {
268                details.insert(
269                    DETAIL_TIMEOUT_SECS.to_string(),
270                    serde_json::Value::from(*timeout_secs),
271                );
272            },
273            Self::TaskTooLarge { size, limit } | Self::ResponseTooLarge { size, limit } => {
274                details.insert(DETAIL_SIZE.to_string(), serde_json::Value::from(*size));
275                details.insert(DETAIL_LIMIT.to_string(), serde_json::Value::from(*limit));
276            },
277            // Message-only variants: nothing structured to add.
278            Self::Config { .. }
279            | Self::VenvBuild { .. }
280            | Self::WorkerStart { .. }
281            | Self::WorkerDied { .. }
282            | Self::WorkerError { .. }
283            | Self::Protocol { .. }
284            | Self::Credential { .. }
285            | Self::LegacyShim { .. } => {},
286        }
287        details
288    }
289
290    /// Convert to the framework error type for a named plugin.
291    ///
292    /// A timeout becomes `PluginError::Timeout` so the executor's existing
293    /// timeout accounting applies; a config fault becomes
294    /// `PluginError::Config`; everything else is an `Execution` error the
295    /// executor routes through the plugin's `on_error` policy.
296    pub fn into_plugin_error(self, plugin_name: &str) -> Box<PluginError> {
297        match self {
298            Self::Timeout { timeout_secs } => PluginError::Timeout {
299                plugin_name: plugin_name.to_string(),
300                timeout_ms: timeout_secs.saturating_mul(1000),
301                proto_error_code: None,
302            }
303            .boxed(),
304
305            Self::Config { ref message } => PluginError::Config {
306                message: format!("plugin '{plugin_name}' (isolated_venv): {message}"),
307            }
308            .boxed(),
309
310            other => {
311                let code = other.code();
312                PluginError::Execution {
313                    plugin_name: plugin_name.to_string(),
314                    message: other.to_string(),
315                    source: None,
316                    code: Some(code.to_string()),
317                    details: other.details(),
318                    proto_error_code: None,
319                }
320                .boxed()
321            },
322        }
323    }
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329
330    /// The details map for a message-only variant.
331    fn details_of(err: HostError) -> HashMap<String, serde_json::Value> {
332        err.details()
333    }
334
335    /// Extract `details` from the `Execution` arm, failing on any other arm.
336    fn execution_details(err: HostError) -> HashMap<String, serde_json::Value> {
337        match *err.into_plugin_error("p") {
338            PluginError::Execution { details, .. } => details,
339            other => panic!("expected Execution, got {other:?}"),
340        }
341    }
342
343    fn pip_failure(stderr: &str, exit_code: Option<i32>) -> HostError {
344        HostError::VenvCommand {
345            step: "pip install",
346            context: "requirements.txt".to_string(),
347            output: ProcessOutput {
348                exit_code,
349                stderr: stderr.to_string(),
350            },
351        }
352    }
353
354    /// The regression this variant exists for: a failed pip install used to
355    /// convert to an `Execution` error with an empty details map, so the exit
356    /// code and stderr were only recoverable by parsing the message prose.
357    #[test]
358    fn a_failed_pip_install_carries_its_exit_code_and_stderr_in_details() {
359        let details = execution_details(pip_failure("ERROR: No matching distribution", Some(1)));
360
361        assert_eq!(details[DETAIL_EXIT_CODE], serde_json::Value::from(1));
362        assert_eq!(
363            details[DETAIL_STDERR],
364            serde_json::Value::from("ERROR: No matching distribution")
365        );
366        assert_eq!(details["step"], serde_json::Value::from("pip install"));
367        assert!(!details.contains_key(DETAIL_STDERR_TRUNCATED));
368    }
369
370    /// The code stays `venv_build_failed` so existing branching still works.
371    #[test]
372    fn the_new_variant_shares_the_venv_build_failed_code() {
373        assert_eq!(pip_failure("boom", Some(1)).code(), "venv_build_failed");
374        assert_eq!(
375            HostError::VenvBuild {
376                message: "boom".into()
377            }
378            .code(),
379            "venv_build_failed"
380        );
381    }
382
383    /// A signal-killed child has no exit code; the slot is explicitly null
384    /// rather than absent, so a consumer can tell "killed" from "not a
385    /// process failure at all".
386    #[test]
387    fn a_signal_killed_child_reports_a_null_exit_code() {
388        let details = execution_details(pip_failure("", None));
389
390        assert_eq!(details[DETAIL_EXIT_CODE], serde_json::Value::Null);
391        assert!(
392            pip_failure("", None)
393                .to_string()
394                .contains("was killed by a signal")
395        );
396    }
397
398    /// Long pip output is cut to the tail, where the actual error lives, and
399    /// flagged so a reader knows they are not seeing the whole thing.
400    #[test]
401    fn an_oversized_stderr_is_truncated_to_its_tail_and_flagged() {
402        let stderr = format!("{}TAIL-MARKER", "x".repeat(STDERR_EXCERPT_BYTES * 2));
403        let details = execution_details(pip_failure(&stderr, Some(2)));
404
405        let excerpt = details[DETAIL_STDERR].as_str().unwrap();
406        assert!(excerpt.len() <= STDERR_EXCERPT_BYTES);
407        assert!(
408            excerpt.ends_with("TAIL-MARKER"),
409            "the tail holds the real error, so it must survive truncation"
410        );
411        assert_eq!(
412            details[DETAIL_STDERR_TRUNCATED],
413            serde_json::Value::Bool(true)
414        );
415    }
416
417    /// Truncation must not split a multi-byte char and produce invalid UTF-8.
418    #[test]
419    fn truncation_respects_char_boundaries() {
420        // "é" is two bytes, so a naive byte split lands mid-character.
421        let stderr = "é".repeat(STDERR_EXCERPT_BYTES);
422        let details = execution_details(pip_failure(&stderr, Some(1)));
423
424        let excerpt = details[DETAIL_STDERR].as_str().unwrap();
425        assert!(excerpt.chars().all(|c| c == 'é'));
426        assert!(excerpt.len() <= STDERR_EXCERPT_BYTES);
427    }
428
429    /// A timeout keeps its dedicated `PluginError::Timeout` mapping, so it
430    /// must not be diverted into the `Execution` arm by the details work.
431    #[test]
432    fn a_timeout_still_maps_to_the_timeout_variant() {
433        let err = HostError::Timeout { timeout_secs: 30 };
434        match *err.into_plugin_error("p") {
435            PluginError::Timeout { timeout_ms, .. } => assert_eq!(timeout_ms, 30_000),
436            other => panic!("expected Timeout, got {other:?}"),
437        }
438    }
439
440    #[test]
441    fn a_size_cap_breach_reports_both_the_size_and_the_limit() {
442        let details = execution_details(HostError::TaskTooLarge {
443            size: 2048,
444            limit: 1024,
445        });
446
447        assert_eq!(details[DETAIL_SIZE], serde_json::Value::from(2048));
448        assert_eq!(details[DETAIL_LIMIT], serde_json::Value::from(1024));
449    }
450
451    /// Message-only variants stay empty rather than duplicating the message
452    /// into a details key.
453    #[test]
454    fn message_only_variants_contribute_no_details() {
455        for err in [
456            HostError::VenvBuild {
457                message: "m".into(),
458            },
459            HostError::WorkerStart {
460                message: "m".into(),
461            },
462            HostError::WorkerDied {
463                message: "m".into(),
464            },
465            HostError::WorkerError {
466                message: "m".into(),
467            },
468            HostError::Protocol {
469                message: "m".into(),
470            },
471            HostError::Credential {
472                message: "m".into(),
473            },
474        ] {
475            assert!(details_of(err).is_empty());
476        }
477    }
478
479    /// The `Display` message keeps the exit code and stderr too — the details
480    /// map is an addition, not a relocation, so existing log lines that only
481    /// render the message do not regress.
482    #[test]
483    fn the_display_message_still_names_the_step_context_and_failure() {
484        let message = pip_failure("ERROR: could not build wheel", Some(1)).to_string();
485
486        assert!(message.contains("pip install"));
487        assert!(message.contains("requirements.txt"));
488        assert!(message.contains("exited 1"));
489        assert!(message.contains("ERROR: could not build wheel"));
490    }
491}