1use std::fmt;
4
5use sc_observability_types::{ErrorCode, RecoverableSteps, Remediation};
6use serde::de::{self, DeserializeOwned};
7use serde::ser::SerializeMap;
8use serde::{Deserialize, Deserializer, Serialize, Serializer};
9use serde_json::Value;
10
11use crate::DISCRIMINANT_CODE;
12use crate::exact::{require_exact_keys, required};
13
14const JUSTIFICATION_KEY: &str = "justification";
16
17const ALL_KINDS: &[ErrorKind] = &[
18 ErrorKind::InvalidInput,
19 ErrorKind::NotFound,
20 ErrorKind::Conflict,
21 ErrorKind::Unavailable,
22 ErrorKind::Internal,
23];
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
45#[non_exhaustive]
46#[serde(rename_all = "snake_case")]
47pub enum ErrorKind {
48 InvalidInput,
50 NotFound,
52 Conflict,
54 Unavailable,
56 Internal,
58}
59
60impl ErrorKind {
61 #[must_use]
63 pub const fn all() -> &'static [Self] {
64 ALL_KINDS
65 }
66
67 #[must_use]
72 pub const fn http_status_code(self) -> u16 {
73 match self {
74 Self::InvalidInput => 400,
75 Self::NotFound => 404,
76 Self::Conflict => 409,
77 Self::Unavailable => 503,
78 Self::Internal => 500,
79 }
80 }
81
82 #[must_use]
84 pub const fn is_caller_error(self) -> bool {
85 matches!(self, Self::InvalidInput | Self::NotFound | Self::Conflict)
86 }
87
88 const fn wire_name(self) -> &'static str {
89 match self {
90 Self::InvalidInput => "invalid_input",
91 Self::NotFound => "not_found",
92 Self::Conflict => "conflict",
93 Self::Unavailable => "unavailable",
94 Self::Internal => "internal",
95 }
96 }
97}
98
99impl fmt::Display for ErrorKind {
100 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
101 formatter.write_str(self.wire_name())
102 }
103}
104
105#[derive(Debug, Clone, PartialEq)]
124pub struct OpError {
125 pub kind: ErrorKind,
127 pub code: ErrorCode,
129 pub message: String,
131 pub details: Option<Value>,
133 pub remediation: Option<Remediation>,
135}
136
137impl OpError {
138 #[must_use]
140 pub fn new(kind: ErrorKind, code: ErrorCode, message: impl Into<String>) -> Self {
141 Self {
142 kind,
143 code,
144 message: message.into(),
145 details: None,
146 remediation: None,
147 }
148 }
149
150 #[must_use]
169 pub fn from_code(kind: ErrorKind, code: &'static str, message: impl Into<String>) -> Self {
170 Self::new(kind, ErrorCode::new_static(code), message)
171 }
172
173 #[must_use]
175 pub fn with_details(mut self, details: Value) -> Self {
176 self.details = Some(details);
177 self
178 }
179
180 #[must_use]
185 pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
186 self.remediation = Some(Remediation::recoverable(
187 suggestion,
188 std::iter::empty::<String>(),
189 ));
190 self
191 }
192
193 #[must_use]
195 pub fn with_remediation(mut self, remediation: Remediation) -> Self {
196 self.remediation = Some(remediation);
197 self
198 }
199
200 #[must_use]
203 pub fn suggested_action(&self) -> Option<&str> {
204 self.remediation.as_ref().and_then(wire_suggestion)
205 }
206
207 #[must_use]
237 pub fn from_transport_strings(
238 code: &str,
239 endpoint: &str,
240 cause: &str,
241 suggested_action: Option<&str>,
242 ) -> Self {
243 let daemon_down = code == "DAEMON.NOT_RUNNING";
244 let kind = if daemon_down {
245 ErrorKind::Unavailable
246 } else {
247 ErrorKind::Internal
248 };
249 let message = if daemon_down {
250 "the daemon is not running".to_owned()
251 } else {
252 format!("transport failure {code} at {endpoint}: {cause}")
253 };
254 Self {
255 kind,
256 code: ErrorCode::new_owned(code),
257 message,
258 details: Some(serde_json::json!({
259 "endpoint": endpoint,
260 "cause": cause,
261 })),
262 remediation: remediation_from_action(suggested_action),
263 }
264 }
265}
266
267impl fmt::Display for OpError {
268 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
269 write!(formatter, "{}: {}", self.code, self.message)
270 }
271}
272
273impl std::error::Error for OpError {}
274
275impl Serialize for OpError {
276 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
277 where
278 S: Serializer,
279 {
280 let mut map = serializer.serialize_map(Some(5))?;
281 map.serialize_entry("kind", &self.kind)?;
282 map.serialize_entry("code", &self.code)?;
283 map.serialize_entry("message", &self.message)?;
284 map.serialize_entry(
285 "details",
286 &details_json(self.details.as_ref(), self.remediation.as_ref()),
287 )?;
288 map.serialize_entry(
289 "suggested_action",
290 &suggestion_json(self.remediation.as_ref()),
291 )?;
292 map.end()
293 }
294}
295
296impl<'de> Deserialize<'de> for OpError {
297 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
298 where
299 D: Deserializer<'de>,
300 {
301 let value = Value::deserialize(deserializer)?;
302 op_error_from_value(value).map_err(de::Error::custom)
303 }
304}
305
306fn remediation_from_action(suggested_action: Option<&str>) -> Option<Remediation> {
307 let text = suggested_action?.trim();
308 if text.is_empty() {
309 None
310 } else {
311 Some(Remediation::recoverable(text, std::iter::empty::<String>()))
312 }
313}
314
315fn wire_suggestion(remediation: &Remediation) -> Option<&str> {
316 match remediation {
317 Remediation::Recoverable { steps } => {
318 let step = steps.first_step()?;
319 if step.is_empty() { None } else { Some(step) }
320 }
321 Remediation::NotRecoverable { .. } => None,
322 }
323}
324
325fn suggestion_json(remediation: Option<&Remediation>) -> Value {
326 match remediation.and_then(wire_suggestion) {
327 None => Value::Null,
328 Some(text) => Value::String(text.to_owned()),
329 }
330}
331
332fn details_json(details: Option<&Value>, remediation: Option<&Remediation>) -> Value {
333 let Some(Remediation::NotRecoverable { justification }) = remediation else {
334 return details.cloned().unwrap_or(Value::Null);
335 };
336 match details {
337 None => serde_json::json!({ JUSTIFICATION_KEY: justification }),
338 Some(Value::Object(map)) => {
339 let mut map = map.clone();
340 map.insert(
341 JUSTIFICATION_KEY.to_owned(),
342 Value::String(justification.clone()),
343 );
344 Value::Object(map)
345 }
346 Some(other) => serde_json::json!({
347 "value": other,
348 "justification": justification,
349 }),
350 }
351}
352
353fn op_error_from_value(value: Value) -> Result<OpError, String> {
354 let map = match value {
355 Value::Object(map) => map,
356 _ => {
357 return Err(format!("{DISCRIMINANT_CODE}: error must be a JSON object"));
358 }
359 };
360 require_exact_keys(
361 &map,
362 &["kind", "code", "message", "details", "suggested_action"],
363 )?;
364 let kind = from_json_value::<ErrorKind>(required(&map, "kind")?)?;
365 let code = from_json_value::<ErrorCode>(required(&map, "code")?)?;
366 let message = match required(&map, "message")? {
367 Value::String(message) => message.clone(),
368 _ => {
369 return Err(format!(
370 "{DISCRIMINANT_CODE}: message must be a JSON string"
371 ));
372 }
373 };
374 let details = match required(&map, "details")? {
375 Value::Null => None,
376 other => Some(other.clone()),
377 };
378 let remediation = remediation_from_wire(required(&map, "suggested_action")?)?;
379 Ok(OpError {
380 kind,
381 code,
382 message,
383 details,
384 remediation,
385 })
386}
387
388fn remediation_from_wire(value: &Value) -> Result<Option<Remediation>, String> {
389 match value {
390 Value::Null => Ok(None),
391 Value::String(text) if text.is_empty() => Ok(None),
392 Value::String(text) => Ok(Some(Remediation::Recoverable {
393 steps: RecoverableSteps::first(text.clone()),
394 })),
395 _ => Err(format!(
396 "{DISCRIMINANT_CODE}: suggested_action must be a JSON string or null"
397 )),
398 }
399}
400
401fn from_json_value<T>(value: &Value) -> Result<T, String>
402where
403 T: DeserializeOwned,
404{
405 T::deserialize(value).map_err(|error| format!("{DISCRIMINANT_CODE}: {error}"))
406}
407
408#[cfg(feature = "schema")]
409impl schemars::JsonSchema for ErrorKind {
410 fn schema_name() -> std::borrow::Cow<'static, str> {
411 "ErrorKind".into()
412 }
413 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
414 serde_json::Map::from_iter([
415 ("type".into(), serde_json::json!("string")),
416 (
417 "enum".into(),
418 serde_json::json!([
419 "invalid_input",
420 "not_found",
421 "conflict",
422 "unavailable",
423 "internal"
424 ]),
425 ),
426 ])
427 .into()
428 }
429}
430#[cfg(feature = "schema")]
431impl utoipa::PartialSchema for ErrorKind {
432 fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
433 use utoipa::openapi::schema::{ObjectBuilder, Type};
434 ObjectBuilder::new()
435 .schema_type(Type::String)
436 .enum_values(Some([
437 "invalid_input",
438 "not_found",
439 "conflict",
440 "unavailable",
441 "internal",
442 ]))
443 .into()
444 }
445}
446#[cfg(feature = "schema")]
447impl utoipa::ToSchema for ErrorKind {}
448#[cfg(feature = "schema")]
449impl schemars::JsonSchema for OpError {
450 fn schema_name() -> std::borrow::Cow<'static, str> {
451 "OpError".into()
452 }
453 fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
454 serde_json::Map::from_iter([
455 ("type".into(), serde_json::json!("object")),
456 ("additionalProperties".into(), serde_json::json!(false)),
457 ("required".into(), serde_json::json!(["kind","code","message","details","suggested_action"])),
458 ("properties".into(), serde_json::json!({
459 "kind":ErrorKind::json_schema(generator),"code":{"type":"string"},"message":{"type":"string"},
460 "details":{"type":["object","array","string","number","boolean","null"]},
461 "suggested_action":{"type":["string","null"]}
462 }))
463 ]).into()
464 }
465}
466#[cfg(feature = "schema")]
467impl utoipa::PartialSchema for OpError {
468 fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
469 use utoipa::openapi::schema::{AdditionalProperties, ObjectBuilder, SchemaType, Type};
470 ObjectBuilder::new()
471 .schema_type(Type::Object)
472 .additional_properties(Some(AdditionalProperties::FreeForm(false)))
473 .required("kind")
474 .required("code")
475 .required("message")
476 .required("details")
477 .required("suggested_action")
478 .property("kind", ErrorKind::schema())
479 .property("code", ObjectBuilder::new().schema_type(Type::String))
480 .property("message", ObjectBuilder::new().schema_type(Type::String))
481 .property(
482 "details",
483 ObjectBuilder::new().schema_type(SchemaType::Array(vec![
484 Type::Object,
485 Type::Array,
486 Type::String,
487 Type::Number,
488 Type::Boolean,
489 Type::Null,
490 ])),
491 )
492 .property(
493 "suggested_action",
494 ObjectBuilder::new().schema_type(SchemaType::Array(vec![Type::String, Type::Null])),
495 )
496 .into()
497 }
498}
499#[cfg(feature = "schema")]
500impl utoipa::ToSchema for OpError {}