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 | EngineError::UpdateNotWritable { name, .. } => {
225 ("not-writable".to_owned(), configured(name), None)
226 }
227 EngineError::NoSuchItem { .. }
228 | EngineError::NoSuchTask { .. }
229 | EngineError::NoSuchProject { .. }
230 | EngineError::NoSuchDocument { .. } => ("no-such-item".to_owned(), None, None),
231 EngineError::NoSuchComment { .. } => ("no-such-comment".to_owned(), None, None),
232 EngineError::NotCreatable { name, .. } | EngineError::RenderingNotWritable { name, .. } => {
233 ("not-writable".to_owned(), configured(name), None)
234 }
235 EngineError::MissingAnswers { .. } => ("template-missing-required".to_owned(), None, None),
236 EngineError::Template { error } => (error.kind().to_owned(), None, None),
237 EngineError::NoStoredAnswers { .. } => ("no-stored-answers".to_owned(), None, None),
238 EngineError::NoTemplate { .. } => ("no-template".to_owned(), None, None),
239 EngineError::TemplateNotAFile { .. } => ("template-not-a-file".to_owned(), None, None),
240 EngineError::MalformedProvenance { .. } => ("malformed-provenance".to_owned(), None, None),
241 EngineError::StaleOrigin { .. } => ("stale-origin".to_owned(), None, None),
242 EngineError::NotAMember { .. } => ("not-a-member".to_owned(), None, None),
243 EngineError::UnrecordedMember { .. } => ("unrecorded-member".to_owned(), None, None),
244 EngineError::DestinationUnavailable { name, error }
245 | EngineError::SourceRefused { name, error }
246 | EngineError::SourceUnavailable { name, error }
247 | EngineError::SourceFailed { name, error } => {
248 (source_kind(error), configured(name), Some(error))
249 }
250 EngineError::CopyNotUndone { error, .. } => cause(error),
251 }
252}
253
254impl From<&EngineError> for Failure {
255 fn from(error: &EngineError) -> Self {
256 let (kind, source, caused_by) = cause(error);
257 Self::caused(kind, source, caused_by, error.to_string())
258 }
259}
260
261impl From<&ConfigError> for Failure {
262 /// A configuration this product will not run on. No source caused it — a plugin
263 /// refusing its own block is refused here, at load, before any source is built.
264 fn from(error: &ConfigError) -> Self {
265 let kind = match error {
266 ConfigError::Read { .. } => "config-read",
267 ConfigError::Syntax { .. } => "config-syntax",
268 ConfigError::Setting { .. } => "config-setting",
269 };
270 Self::decided(kind, error.to_string())
271 }
272}