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 `cpex_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 cpex_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
175impl fmt::Display for HostError {
176    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
177        match self {
178            Self::Config { message } => write!(f, "configuration error: {message}"),
179            Self::VenvBuild { message } => write!(f, "venv build failed: {message}"),
180            Self::VenvCommand {
181                step,
182                context,
183                output,
184            } => write!(
185                f,
186                "venv build failed: `{step}` on {context} {}",
187                output.describe()
188            ),
189            Self::WorkerStart { message } => write!(f, "worker failed to start: {message}"),
190            Self::WorkerDied { message } => write!(f, "worker process died: {message}"),
191            Self::Timeout { timeout_secs } => {
192                write!(f, "worker did not respond within {timeout_secs}s")
193            },
194            Self::TaskTooLarge { size, limit } => write!(
195                f,
196                "serialized task is {size} bytes, over the {limit}-byte max_content_size"
197            ),
198            Self::ResponseTooLarge { size, limit } => write!(
199                f,
200                "worker response frame is at least {size} bytes, over the \
201                 {limit}-byte max_content_size; the frame was discarded rather \
202                 than buffered"
203            ),
204            Self::WorkerError { message } => write!(f, "worker returned an error: {message}"),
205            Self::Protocol { message } => write!(f, "protocol error: {message}"),
206            Self::Credential { message } => write!(f, "credential error: {message}"),
207        }
208    }
209}
210
211impl std::error::Error for HostError {}
212
213impl HostError {
214    /// Short, stable code for the `PluginError::Execution.code` field, so a
215    /// host can branch on the failure mode without string-matching messages.
216    pub fn code(&self) -> &'static str {
217        match self {
218            Self::Config { .. } => "config",
219            Self::VenvBuild { .. } | Self::VenvCommand { .. } => "venv_build_failed",
220            Self::WorkerStart { .. } => "worker_start_failed",
221            Self::WorkerDied { .. } => "worker_died",
222            Self::Timeout { .. } => "timeout",
223            Self::TaskTooLarge { .. } => "task_too_large",
224            Self::ResponseTooLarge { .. } => "response_too_large",
225            Self::WorkerError { .. } => "worker_error",
226            Self::Protocol { .. } => "protocol_error",
227            Self::Credential { .. } => "credential_error",
228        }
229    }
230
231    /// Machine-readable diagnostics for `PluginError::Execution.details`.
232    ///
233    /// The `Display` message is prose for a human; this is the same facts in a
234    /// form an operator's log pipeline or a test can match on without parsing
235    /// English. A failed `pip install` is the motivating case: its exit code
236    /// and stderr tail land here rather than only inside the message string.
237    ///
238    /// Variants whose fields are already fully described by the message (and
239    /// carry no structured facts beyond it) contribute nothing, so the map is
240    /// empty for them rather than padded with a duplicate of the message.
241    ///
242    /// Every value here reaches logs, so nothing that could hold credential
243    /// material — payload values, token bytes — is admitted. The one variant
244    /// carrying child-process output is gated by the redaction note on
245    /// `ProcessOutput`.
246    pub fn details(&self) -> HashMap<String, serde_json::Value> {
247        let mut details = HashMap::new();
248        match self {
249            Self::VenvCommand { step, output, .. } => {
250                details.insert("step".to_string(), serde_json::Value::from(*step));
251                output.write_details(&mut details);
252            },
253            Self::Timeout { timeout_secs } => {
254                details.insert(
255                    DETAIL_TIMEOUT_SECS.to_string(),
256                    serde_json::Value::from(*timeout_secs),
257                );
258            },
259            Self::TaskTooLarge { size, limit } | Self::ResponseTooLarge { size, limit } => {
260                details.insert(DETAIL_SIZE.to_string(), serde_json::Value::from(*size));
261                details.insert(DETAIL_LIMIT.to_string(), serde_json::Value::from(*limit));
262            },
263            // Message-only variants: nothing structured to add.
264            Self::Config { .. }
265            | Self::VenvBuild { .. }
266            | Self::WorkerStart { .. }
267            | Self::WorkerDied { .. }
268            | Self::WorkerError { .. }
269            | Self::Protocol { .. }
270            | Self::Credential { .. } => {},
271        }
272        details
273    }
274
275    /// Convert to the framework error type for a named plugin.
276    ///
277    /// A timeout becomes `PluginError::Timeout` so the executor's existing
278    /// timeout accounting applies; a config fault becomes
279    /// `PluginError::Config`; everything else is an `Execution` error the
280    /// executor routes through the plugin's `on_error` policy.
281    pub fn into_plugin_error(self, plugin_name: &str) -> Box<PluginError> {
282        match self {
283            Self::Timeout { timeout_secs } => PluginError::Timeout {
284                plugin_name: plugin_name.to_string(),
285                timeout_ms: timeout_secs.saturating_mul(1000),
286                proto_error_code: None,
287            }
288            .boxed(),
289
290            Self::Config { ref message } => PluginError::Config {
291                message: format!("plugin '{plugin_name}' (isolated_venv): {message}"),
292            }
293            .boxed(),
294
295            other => {
296                let code = other.code();
297                PluginError::Execution {
298                    plugin_name: plugin_name.to_string(),
299                    message: other.to_string(),
300                    source: None,
301                    code: Some(code.to_string()),
302                    details: other.details(),
303                    proto_error_code: None,
304                }
305                .boxed()
306            },
307        }
308    }
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    /// The details map for a message-only variant.
316    fn details_of(err: HostError) -> HashMap<String, serde_json::Value> {
317        err.details()
318    }
319
320    /// Extract `details` from the `Execution` arm, failing on any other arm.
321    fn execution_details(err: HostError) -> HashMap<String, serde_json::Value> {
322        match *err.into_plugin_error("p") {
323            PluginError::Execution { details, .. } => details,
324            other => panic!("expected Execution, got {other:?}"),
325        }
326    }
327
328    fn pip_failure(stderr: &str, exit_code: Option<i32>) -> HostError {
329        HostError::VenvCommand {
330            step: "pip install",
331            context: "requirements.txt".to_string(),
332            output: ProcessOutput {
333                exit_code,
334                stderr: stderr.to_string(),
335            },
336        }
337    }
338
339    /// The regression this variant exists for: a failed pip install used to
340    /// convert to an `Execution` error with an empty details map, so the exit
341    /// code and stderr were only recoverable by parsing the message prose.
342    #[test]
343    fn a_failed_pip_install_carries_its_exit_code_and_stderr_in_details() {
344        let details = execution_details(pip_failure("ERROR: No matching distribution", Some(1)));
345
346        assert_eq!(details[DETAIL_EXIT_CODE], serde_json::Value::from(1));
347        assert_eq!(
348            details[DETAIL_STDERR],
349            serde_json::Value::from("ERROR: No matching distribution")
350        );
351        assert_eq!(details["step"], serde_json::Value::from("pip install"));
352        assert!(!details.contains_key(DETAIL_STDERR_TRUNCATED));
353    }
354
355    /// The code stays `venv_build_failed` so existing branching still works.
356    #[test]
357    fn the_new_variant_shares_the_venv_build_failed_code() {
358        assert_eq!(pip_failure("boom", Some(1)).code(), "venv_build_failed");
359        assert_eq!(
360            HostError::VenvBuild {
361                message: "boom".into()
362            }
363            .code(),
364            "venv_build_failed"
365        );
366    }
367
368    /// A signal-killed child has no exit code; the slot is explicitly null
369    /// rather than absent, so a consumer can tell "killed" from "not a
370    /// process failure at all".
371    #[test]
372    fn a_signal_killed_child_reports_a_null_exit_code() {
373        let details = execution_details(pip_failure("", None));
374
375        assert_eq!(details[DETAIL_EXIT_CODE], serde_json::Value::Null);
376        assert!(pip_failure("", None)
377            .to_string()
378            .contains("was killed by a signal"));
379    }
380
381    /// Long pip output is cut to the tail, where the actual error lives, and
382    /// flagged so a reader knows they are not seeing the whole thing.
383    #[test]
384    fn an_oversized_stderr_is_truncated_to_its_tail_and_flagged() {
385        let stderr = format!("{}TAIL-MARKER", "x".repeat(STDERR_EXCERPT_BYTES * 2));
386        let details = execution_details(pip_failure(&stderr, Some(2)));
387
388        let excerpt = details[DETAIL_STDERR].as_str().unwrap();
389        assert!(excerpt.len() <= STDERR_EXCERPT_BYTES);
390        assert!(
391            excerpt.ends_with("TAIL-MARKER"),
392            "the tail holds the real error, so it must survive truncation"
393        );
394        assert_eq!(
395            details[DETAIL_STDERR_TRUNCATED],
396            serde_json::Value::Bool(true)
397        );
398    }
399
400    /// Truncation must not split a multi-byte char and produce invalid UTF-8.
401    #[test]
402    fn truncation_respects_char_boundaries() {
403        // "é" is two bytes, so a naive byte split lands mid-character.
404        let stderr = "é".repeat(STDERR_EXCERPT_BYTES);
405        let details = execution_details(pip_failure(&stderr, Some(1)));
406
407        let excerpt = details[DETAIL_STDERR].as_str().unwrap();
408        assert!(excerpt.chars().all(|c| c == 'é'));
409        assert!(excerpt.len() <= STDERR_EXCERPT_BYTES);
410    }
411
412    /// A timeout keeps its dedicated `PluginError::Timeout` mapping, so it
413    /// must not be diverted into the `Execution` arm by the details work.
414    #[test]
415    fn a_timeout_still_maps_to_the_timeout_variant() {
416        let err = HostError::Timeout { timeout_secs: 30 };
417        match *err.into_plugin_error("p") {
418            PluginError::Timeout { timeout_ms, .. } => assert_eq!(timeout_ms, 30_000),
419            other => panic!("expected Timeout, got {other:?}"),
420        }
421    }
422
423    #[test]
424    fn a_size_cap_breach_reports_both_the_size_and_the_limit() {
425        let details = execution_details(HostError::TaskTooLarge {
426            size: 2048,
427            limit: 1024,
428        });
429
430        assert_eq!(details[DETAIL_SIZE], serde_json::Value::from(2048));
431        assert_eq!(details[DETAIL_LIMIT], serde_json::Value::from(1024));
432    }
433
434    /// Message-only variants stay empty rather than duplicating the message
435    /// into a details key.
436    #[test]
437    fn message_only_variants_contribute_no_details() {
438        for err in [
439            HostError::VenvBuild {
440                message: "m".into(),
441            },
442            HostError::WorkerStart {
443                message: "m".into(),
444            },
445            HostError::WorkerDied {
446                message: "m".into(),
447            },
448            HostError::WorkerError {
449                message: "m".into(),
450            },
451            HostError::Protocol {
452                message: "m".into(),
453            },
454            HostError::Credential {
455                message: "m".into(),
456            },
457        ] {
458            assert!(details_of(err).is_empty());
459        }
460    }
461
462    /// The `Display` message keeps the exit code and stderr too — the details
463    /// map is an addition, not a relocation, so existing log lines that only
464    /// render the message do not regress.
465    #[test]
466    fn the_display_message_still_names_the_step_context_and_failure() {
467        let message = pip_failure("ERROR: could not build wheel", Some(1)).to_string();
468
469        assert!(message.contains("pip install"));
470        assert!(message.contains("requirements.txt"));
471        assert!(message.contains("exited 1"));
472        assert!(message.contains("ERROR: could not build wheel"));
473    }
474}