Skip to main content

onetaskgraph_core/
failure.rs

1//! Why a command failed, in a form a caller can branch on.
2//!
3//! A person reads the stderr line; a program acting on a failure needs one question
4//! answered first — would asking again, unchanged, ever get a different answer? A refusal
5//! never will, and retrying one on a timer spends a hosted source's allowance for nothing.
6//! So every failure this product reports carries a [`FailureClass`], and the class is
7//! decided in exactly one place, [`classify`], which both the failure document and each
8//! entry of a partial answer's `errors` call rather than restating.
9
10use onetaskgraph_plugin_api::{SourceError, SourceName};
11use schemars::JsonSchema;
12use serde::{Deserialize, Serialize};
13
14use crate::config::ConfigError;
15use crate::engine::EngineError;
16
17/// Whether repeating a failed request unchanged could change the answer.
18///
19/// Closed on purpose: a caller acts on this alone, so a third value would be one every
20/// caller written before it silently misreads. What the failure *was* is
21/// [`Failure`]'s `kind`, which is the open half.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
23#[serde(rename_all = "kebab-case")]
24pub enum FailureClass {
25    /// The store or a source declined the request; repeating it unchanged cannot alter
26    /// the answer.
27    Refused,
28    /// The request got no ruling a caller could act on, so the same request may succeed
29    /// later.
30    Transient,
31}
32
33/// The class of a failure, from the source error that caused it — or `None` when no
34/// source caused it.
35///
36/// The whole mapping, stated once. A rate limit and a source that could not be reached
37/// are the only failures a wait can change; a source that answered with a refusal, a
38/// configuration it will not run on, a credential it rejected or data it cannot represent
39/// will answer the same way next time, and so will every failure the engine decided on
40/// its own — an id that names nothing, a stale origin, a source nothing configures.
41#[must_use]
42pub fn classify(cause: Option<&SourceError>) -> FailureClass {
43    match cause {
44        Some(SourceError::RateLimited { .. } | SourceError::Unavailable { .. }) => {
45            FailureClass::Transient
46        }
47        Some(
48            SourceError::Refused { .. }
49            | SourceError::Config { .. }
50            | SourceError::Auth { .. }
51            | SourceError::Malformed { .. },
52        )
53        | None => FailureClass::Refused,
54    }
55}
56
57/// The document a command writes to standard output when it exits `1` under machine
58/// output: one object whose single member is the [`Failure`].
59///
60/// An object around the failure rather than the failure itself, so a reader can tell
61/// this document from every answer document by its one key before reading anything else.
62#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)]
63pub struct FailureDocument {
64    /// Why the command failed.
65    pub failure: Failure,
66}
67
68/// Why one command failed.
69///
70/// Every member is always written, `source` and `retry_after_seconds` as `null` when they
71/// have nothing to say, so a caller reads a fixed shape.
72// Built only from an engine error, a configuration error or `Failure::decided`, so `class`
73// always follows `classify` and `message` is always the failure's own rendering. `Deserialize`
74// is for reading one back out of a report that carries it — a delivered task a copy could not
75// keep in step — and builds nothing a caller could not already have been handed.
76#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
77#[schemars(transform = every_member_required)]
78pub struct Failure {
79    /// Whether repeating the request unchanged could change the answer.
80    class: FailureClass,
81    /// What failed: the causing source error's own `kind` when a source caused it, and
82    /// otherwise this product's kebab-case name for the failure, such as `no-such-item`.
83    // llmlint: ignore[invalid_states_unrepresentable] Open by contract: the kind a source error carries is copied verbatim, and a subprocess-hosted plugin speaks the same vocabulary a version later than this binary, so no closed type here could hold every kind a caller is owed. `class` is the closed half a caller acts on.
84    kind: String,
85    /// The configured source the failure came from, or `null` when none did.
86    source: Option<SourceName>,
87    /// What the command reported on standard error, without its `onetaskgraph: ` prefix.
88    message: String,
89    /// How many seconds a rate limit asked the caller to wait, or `null` when it named no
90    /// wait.
91    retry_after_seconds: Option<u64>,
92}
93
94/// Mark every member of an object schema required.
95///
96/// `#[schemars(required)]` on an `Option` member drops `null` from its type, which would
97/// declare the very `null` this document writes invalid; required-and-nullable is what
98/// `Failure` actually is, so the list is filled in after the members are described.
99fn every_member_required(schema: &mut schemars::Schema) {
100    let members: Vec<serde_json::Value> = schema
101        .get("properties")
102        .and_then(serde_json::Value::as_object)
103        .map(|properties| {
104            properties
105                .keys()
106                .cloned()
107                .map(serde_json::Value::from)
108                .collect()
109        })
110        .unwrap_or_default();
111    schema.insert("required".to_owned(), serde_json::Value::Array(members));
112}
113
114// These doc comments are the Rust surface alone: the type's own doc comment is the schema's
115// description, so what a linking caller reads is said here rather than there.
116/// What a linking caller reads.
117///
118/// The same members the failure document writes, through [`Failure::class`],
119/// [`Failure::kind`], [`Failure::source`], [`Failure::message`] and
120/// [`Failure::retry_after_seconds`], so a Rust caller never serialises a failure to branch on
121/// it. They read and nothing more: no caller can build or alter a failure whose class
122/// disagrees with [`classify`].
123impl Failure {
124    /// A failure this product decided on its own, with no source behind it.
125    #[must_use]
126    pub fn decided(kind: &str, message: impl Into<String>) -> Self {
127        Self::caused(kind.to_owned(), None, None, message.into())
128    }
129
130    /// Whether repeating the request unchanged could change the answer — the one closed
131    /// value a caller branches on, as [`classify`] decided it for this failure's cause.
132    #[must_use]
133    pub fn class(&self) -> FailureClass {
134        self.class
135    }
136
137    /// What failed: the causing source error's own `kind` when a source caused it, and
138    /// otherwise this product's kebab-case name for the failure, such as `no-such-item`.
139    #[must_use]
140    pub fn kind(&self) -> &str {
141        &self.kind
142    }
143
144    /// The configured source the failure came from, or `None` when none did.
145    #[must_use]
146    pub fn source(&self) -> Option<&SourceName> {
147        self.source.as_ref()
148    }
149
150    /// What a person reads: the stderr line without its prefix.
151    #[must_use]
152    pub fn message(&self) -> &str {
153        &self.message
154    }
155
156    /// How many seconds a rate limit asked the caller to wait, or `None` when it named no
157    /// wait.
158    #[must_use]
159    pub fn retry_after_seconds(&self) -> Option<u64> {
160        self.retry_after_seconds
161    }
162
163    /// One failure, classed by what caused it.
164    fn caused(
165        kind: String,
166        source: Option<SourceName>,
167        cause: Option<&SourceError>,
168        message: String,
169    ) -> Self {
170        Self {
171            class: classify(cause),
172            kind,
173            source,
174            message,
175            retry_after_seconds: match cause {
176                Some(SourceError::RateLimited {
177                    retry_after_seconds,
178                    ..
179                }) => *retry_after_seconds,
180                _ => None,
181            },
182        }
183    }
184}
185
186/// The `kind` a source error is written with on the wire, verbatim.
187///
188/// Read off its own serialisation rather than matched here, so this cannot spell a kind
189/// differently from the `SourceError` a partial answer carries beside it.
190fn source_kind(error: &SourceError) -> String {
191    serde_json::to_value(error)
192        .ok()
193        .and_then(|wire| wire.get("kind")?.as_str().map(str::to_owned))
194        .expect("a source error is a kind-tagged object")
195}
196
197/// The failure an engine error amounts to, walking to the one it wraps.
198///
199/// A failure that wraps another — a destination that could not be built, a source that
200/// refused part of a copy, a copy that could not be undone — takes the class and kind of
201/// the failure it wraps, because that is what a caller has to act on.
202fn cause(error: &EngineError) -> (String, Option<SourceName>, Option<&SourceError>) {
203    // Every name below that is reported as a source was a configured `SourceName` before
204    // the engine rendered it into the error, so it parses back; a name that did not would
205    // be one no configuration holds, which is exactly what `null` says.
206    let configured = |name: &str| SourceName::new(name.to_owned()).ok();
207    match error {
208        EngineError::UnknownSource { .. } => ("unknown-source".to_owned(), None, None),
209        EngineError::Token { .. } => ("page-token".to_owned(), None, None),
210        EngineError::NoSources => ("no-sources".to_owned(), None, None),
211        EngineError::NotWritable { name, .. } => {
212            ("not-writable".to_owned(), configured(name), None)
213        }
214        EngineError::NoDocuments { name, .. } => {
215            ("no-documents".to_owned(), configured(name), None)
216        }
217        EngineError::NoComments { name, .. } => ("no-comments".to_owned(), configured(name), None),
218        EngineError::NoPriority { name, .. } => ("no-priority".to_owned(), configured(name), None),
219        EngineError::CommentsNotWritable { name, .. }
220        | EngineError::StatusNotWritable { name, .. }
221        | EngineError::MetadataNotWritable { name, .. }
222        | EngineError::PriorityNotWritable { name, .. }
223        | EngineError::ContentNotWritable { name, .. } => {
224            ("not-writable".to_owned(), configured(name), None)
225        }
226        EngineError::NoSuchItem { .. }
227        | EngineError::NoSuchTask { .. }
228        | EngineError::NoSuchProject { .. }
229        | EngineError::NoSuchDocument { .. } => ("no-such-item".to_owned(), None, None),
230        EngineError::NoSuchComment { .. } => ("no-such-comment".to_owned(), None, None),
231        EngineError::NotCreatable { name, .. } | EngineError::RenderingNotWritable { name, .. } => {
232            ("not-writable".to_owned(), configured(name), None)
233        }
234        EngineError::MissingAnswers { .. } => ("template-missing-required".to_owned(), None, None),
235        EngineError::Template { error } => (error.kind().to_owned(), None, None),
236        EngineError::NoStoredAnswers { .. } => ("no-stored-answers".to_owned(), None, None),
237        EngineError::NoTemplate { .. } => ("no-template".to_owned(), None, None),
238        EngineError::TemplateNotAFile { .. } => ("template-not-a-file".to_owned(), None, None),
239        EngineError::MalformedProvenance { .. } => ("malformed-provenance".to_owned(), None, None),
240        EngineError::StaleOrigin { .. } => ("stale-origin".to_owned(), None, None),
241        EngineError::NotAMember { .. } => ("not-a-member".to_owned(), None, None),
242        EngineError::UnrecordedMember { .. } => ("unrecorded-member".to_owned(), None, None),
243        EngineError::DestinationUnavailable { name, error }
244        | EngineError::SourceRefused { name, error }
245        | EngineError::SourceUnavailable { name, error }
246        | EngineError::SourceFailed { name, error } => {
247            (source_kind(error), configured(name), Some(error))
248        }
249        EngineError::CopyNotUndone { error, .. } => cause(error),
250    }
251}
252
253impl From<&EngineError> for Failure {
254    fn from(error: &EngineError) -> Self {
255        let (kind, source, caused_by) = cause(error);
256        Self::caused(kind, source, caused_by, error.to_string())
257    }
258}
259
260impl From<&ConfigError> for Failure {
261    /// A configuration this product will not run on. No source caused it — a plugin
262    /// refusing its own block is refused here, at load, before any source is built.
263    fn from(error: &ConfigError) -> Self {
264        let kind = match error {
265            ConfigError::Read { .. } => "config-read",
266            ConfigError::Syntax { .. } => "config-syntax",
267            ConfigError::Setting { .. } => "config-setting",
268        };
269        Self::decided(kind, error.to_string())
270    }
271}