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
114impl Failure {
115 /// A failure this product decided on its own, with no source behind it.
116 #[must_use]
117 pub fn decided(kind: &str, message: impl Into<String>) -> Self {
118 Self::caused(kind.to_owned(), None, None, message.into())
119 }
120
121 /// What a person reads: the stderr line without its prefix.
122 #[must_use]
123 pub fn message(&self) -> &str {
124 &self.message
125 }
126
127 /// One failure, classed by what caused it.
128 fn caused(
129 kind: String,
130 source: Option<SourceName>,
131 cause: Option<&SourceError>,
132 message: String,
133 ) -> Self {
134 Self {
135 class: classify(cause),
136 kind,
137 source,
138 message,
139 retry_after_seconds: match cause {
140 Some(SourceError::RateLimited {
141 retry_after_seconds,
142 ..
143 }) => *retry_after_seconds,
144 _ => None,
145 },
146 }
147 }
148}
149
150/// The `kind` a source error is written with on the wire, verbatim.
151///
152/// Read off its own serialisation rather than matched here, so this cannot spell a kind
153/// differently from the `SourceError` a partial answer carries beside it.
154fn source_kind(error: &SourceError) -> String {
155 serde_json::to_value(error)
156 .ok()
157 .and_then(|wire| wire.get("kind")?.as_str().map(str::to_owned))
158 .expect("a source error is a kind-tagged object")
159}
160
161/// The failure an engine error amounts to, walking to the one it wraps.
162///
163/// A failure that wraps another — a destination that could not be built, a source that
164/// refused part of a copy, a copy that could not be undone — takes the class and kind of
165/// the failure it wraps, because that is what a caller has to act on.
166fn cause(error: &EngineError) -> (String, Option<SourceName>, Option<&SourceError>) {
167 // Every name below that is reported as a source was a configured `SourceName` before
168 // the engine rendered it into the error, so it parses back; a name that did not would
169 // be one no configuration holds, which is exactly what `null` says.
170 let configured = |name: &str| SourceName::new(name.to_owned()).ok();
171 match error {
172 EngineError::UnknownSource { .. } => ("unknown-source".to_owned(), None, None),
173 EngineError::Token { .. } => ("page-token".to_owned(), None, None),
174 EngineError::NoSources => ("no-sources".to_owned(), None, None),
175 EngineError::NotWritable { name, .. } => {
176 ("not-writable".to_owned(), configured(name), None)
177 }
178 EngineError::NoDocuments { name, .. } => {
179 ("no-documents".to_owned(), configured(name), None)
180 }
181 EngineError::NoComments { name, .. } => ("no-comments".to_owned(), configured(name), None),
182 EngineError::CommentsNotWritable { name, .. }
183 | EngineError::StatusNotWritable { name, .. } => {
184 ("not-writable".to_owned(), configured(name), None)
185 }
186 EngineError::NoSuchItem { .. } | EngineError::NoSuchTask { .. } => {
187 ("no-such-item".to_owned(), None, None)
188 }
189 EngineError::NoSuchComment { .. } => ("no-such-comment".to_owned(), None, None),
190 EngineError::StaleOrigin { .. } => ("stale-origin".to_owned(), None, None),
191 EngineError::NotAMember { .. } => ("not-a-member".to_owned(), None, None),
192 EngineError::UnrecordedMember { .. } => ("unrecorded-member".to_owned(), None, None),
193 EngineError::DestinationUnavailable { name, error }
194 | EngineError::SourceRefused { name, error }
195 | EngineError::SourceUnavailable { name, error }
196 | EngineError::SourceFailed { name, error } => {
197 (source_kind(error), configured(name), Some(error))
198 }
199 EngineError::CopyNotUndone { error, .. } => cause(error),
200 }
201}
202
203impl From<&EngineError> for Failure {
204 fn from(error: &EngineError) -> Self {
205 let (kind, source, caused_by) = cause(error);
206 Self::caused(kind, source, caused_by, error.to_string())
207 }
208}
209
210impl From<&ConfigError> for Failure {
211 /// A configuration this product will not run on. No source caused it — a plugin
212 /// refusing its own block is refused here, at load, before any source is built.
213 fn from(error: &ConfigError) -> Self {
214 let kind = match error {
215 ConfigError::Read { .. } => "config-read",
216 ConfigError::Syntax { .. } => "config-syntax",
217 ConfigError::Setting { .. } => "config-setting",
218 };
219 Self::decided(kind, error.to_string())
220 }
221}