use crate::state::StoreError;
use crate::watch::{GapReason, TriggerError, TriggerKindLabel};
pub type Result<T> = std::result::Result<T, ClientError>;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum ClientError {
#[error("transport error: {0}")]
Transport(#[from] reqwest::Error),
#[error("http {status} (request_id={request_id:?}): {body}")]
Http {
status: u16,
body: String,
request_id: Option<String>,
},
#[error("auth: {0}")]
Auth(String),
#[error("decode: {0}")]
Decode(#[from] serde_json::Error),
#[error("malformed CloudEvent id: {0}")]
MalformedEvent(String),
#[error("history gap: {reason:?}")]
HistoryGap {
reason: GapReason,
},
#[error("stream protocol error (request_id={request_id:?}): {message}")]
StreamProtocol {
message: String,
request_id: Option<String>,
},
#[error("config: {0}")]
Config(String),
#[error("state store: {0}")]
StateStore(#[from] StoreError),
#[error("trigger {kind} failed: {source}")]
TriggerFailed {
kind: TriggerKindLabel,
#[source]
source: TriggerError,
},
}
impl ClientError {
#[must_use]
pub fn request_id(&self) -> Option<&str> {
match self {
Self::Http { request_id, .. } | Self::StreamProtocol { request_id, .. } => {
request_id.as_deref()
}
_ => None,
}
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
reason = "test code: expect and panic on unexpected variant are the standard test diagnostics"
)]
mod tests {
use super::ClientError;
use crate::watch::GapReason;
#[test]
fn request_id_is_some_for_http_with_header() {
let err = ClientError::Http {
status: 400,
body: "bad".into(),
request_id: Some("req-123".into()),
};
assert_eq!(err.request_id(), Some("req-123"));
}
#[test]
fn request_id_is_none_for_http_without_header() {
let err = ClientError::Http {
status: 500,
body: "oops".into(),
request_id: None,
};
assert_eq!(err.request_id(), None);
}
#[test]
fn request_id_is_none_for_non_http_variants() {
let auth_err = ClientError::Auth("no token".into());
assert_eq!(auth_err.request_id(), None);
let config_err = ClientError::Config("missing".into());
assert_eq!(config_err.request_id(), None);
}
#[test]
fn request_id_is_some_for_stream_protocol_with_correlation() {
let err = ClientError::StreamProtocol {
message: "stream_processing_failed".into(),
request_id: Some("req-xyz".into()),
};
assert_eq!(err.request_id(), Some("req-xyz"));
}
#[test]
fn request_id_is_none_for_stream_protocol_without_correlation() {
let err = ClientError::StreamProtocol {
message: "stream_processing_failed".into(),
request_id: None,
};
assert_eq!(err.request_id(), None);
}
#[test]
fn history_gap_carries_reason_and_has_no_request_id() {
let reason = GapReason::SequenceJump {
expected: 5,
observed: 7,
};
let err = ClientError::HistoryGap { reason };
assert_eq!(err.request_id(), None);
let rendered = err.to_string();
assert!(rendered.contains("SequenceJump"), "got: {rendered}");
}
#[test]
fn stream_protocol_display_includes_message_and_request_id() {
let err = ClientError::StreamProtocol {
message: "boom".into(),
request_id: Some("r-1".into()),
};
let rendered = err.to_string();
assert!(rendered.contains("boom"), "got: {rendered}");
assert!(rendered.contains("r-1"), "got: {rendered}");
}
#[test]
fn state_store_from_io_error_preserves_chain() {
use crate::state::StoreError;
use std::error::Error as _;
let io = std::io::Error::other("disk full");
let store_err: StoreError = io.into();
let client_err: ClientError = store_err.into();
let rendered = client_err.to_string();
assert!(rendered.starts_with("state store:"), "got: {rendered}");
let source = client_err.source();
assert!(
source.is_some(),
"ClientError::StateStore must expose its inner StoreError via source()"
);
}
#[test]
fn state_store_variant_has_no_request_id() {
use crate::state::StoreError;
let store_err: StoreError = std::io::Error::other("boom").into();
let err: ClientError = store_err.into();
assert_eq!(err.request_id(), None);
}
#[test]
fn trigger_failed_display_includes_kind_and_source() {
use crate::watch::{TriggerError, TriggerKindLabel};
let inner: TriggerError = std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"permission denied: /var/log/aviso.log",
)
.into();
let err = ClientError::TriggerFailed {
kind: TriggerKindLabel::Log {
path: std::path::PathBuf::from("/var/log/aviso.log"),
},
source: inner,
};
let rendered = err.to_string();
assert!(
rendered.contains("trigger log(/var/log/aviso.log) failed"),
"got: {rendered}"
);
assert!(rendered.contains("permission denied"), "got: {rendered}");
}
#[test]
fn trigger_failed_request_id_is_none() {
use crate::watch::{TriggerError, TriggerKindLabel};
let err = ClientError::TriggerFailed {
kind: TriggerKindLabel::Echo,
source: TriggerError::Io(std::io::Error::other("broken pipe")),
};
assert_eq!(err.request_id(), None);
}
#[test]
fn trigger_failed_source_chain_exposes_inner_io_error() {
use crate::watch::{TriggerError, TriggerKindLabel};
use std::error::Error as _;
let err = ClientError::TriggerFailed {
kind: TriggerKindLabel::Echo,
source: TriggerError::Io(std::io::Error::other("disk full")),
};
let source = err.source().expect("TriggerFailed must expose source");
let chain = source.to_string();
assert!(chain.contains("io:"), "got: {chain}");
}
}