Skip to main content

opcda_bridge/
error.rs

1//! The error type returned by every [`crate::Client`] method.
2
3/// Errors that can occur while talking to an opcda-bridge gateway.
4///
5/// The transport variants use `#[error(transparent)]`: `Display` and `source()`
6/// forward straight through to the wrapped `tonic` error with no added
7/// prefix or extra chain link. This matters because `opcda-bridge-client`'s
8/// CLI commands convert this type into an `anyhow::Error` with a bare `?`
9/// (the same way they converted a raw `tonic::Status`/
10/// `tonic::transport::Error` before this crate existed); transparency keeps
11/// that conversion rendering byte-for-byte the same error text the CLI
12/// printed before this crate existed, which is part of this crate's
13/// contract with its CLI consumer and is pinned down by
14/// `test_error_rpc_anyhow_debug_matches_bare_status` and
15/// `client::tests::test_connect_failure_anyhow_debug_matches_bare_transport_error`.
16///
17/// A dedicated `thiserror` enum (rather than reusing `anyhow::Error` here
18/// the way `opcda-bridge-client`'s own commands do) still lets a downstream
19/// consumer that does *not* want an `anyhow` dependency match on
20/// [`Error::Connect`] / [`Error::Rpc`] directly. [`Error::Protocol`] reports
21/// malformed or internally inconsistent responses from an incompatible gateway.
22#[derive(Debug, thiserror::Error)]
23pub enum Error {
24    /// Failed to establish the gRPC channel to the gateway (e.g. connection
25    /// refused, DNS failure, invalid address).
26    #[error(transparent)]
27    Connect(#[from] tonic::transport::Error),
28    /// The gateway returned a gRPC error for a `GetCapabilities`/`Browse`/
29    /// `CloseBrowseSession`/`Search`/`ListServers`/`Read`/`Write` call, or
30    /// for a search response-stream item.
31    #[error(transparent)]
32    Rpc(#[from] tonic::Status),
33    /// The connected gateway predates a required RPC.
34    #[error(
35        "gateway does not support {operation}; upgrade the gateway and client to compatible protocol versions"
36    )]
37    IncompatibleGateway { operation: &'static str },
38    /// The requested first-time namespace-index refresh named a ProgID that
39    /// gateway-side server discovery did not return.
40    #[error("OPC DA server {server:?} is not registered")]
41    UnknownIndexServer { server: String },
42    /// The requested namespace-index control operation requires a durable
43    /// enrollment that does not exist.
44    #[error("namespace index for OPC DA server {server:?} is not enrolled")]
45    IndexNotEnrolled { server: String },
46    /// The requested lifecycle operation conflicts with an in-progress deletion.
47    #[error("namespace index for OPC DA server {server:?} is being deleted")]
48    IndexDeleting { server: String },
49    /// The gateway returned a response that violates the negotiated protocol.
50    #[error("protocol error: {0}")]
51    Protocol(String),
52}
53
54/// A `Result` alias using [`Error`], mirroring the ergonomics of
55/// `anyhow::Result` (`opcda_bridge::Result<T>`) for a crate that
56/// intentionally does not depend on `anyhow` itself.
57pub type Result<T> = std::result::Result<T, Error>;
58
59#[cfg(test)]
60mod tests {
61    use super::*;
62
63    #[test]
64    fn test_error_rpc_display_is_transparent() {
65        let status = tonic::Status::internal("boom");
66        let expected = status.to_string();
67        let err: Error = status.into();
68        assert_eq!(err.to_string(), expected);
69    }
70
71    #[test]
72    fn test_error_rpc_matches_variant() {
73        let status = tonic::Status::not_found("missing");
74        let err: Error = status.into();
75        assert!(matches!(err, Error::Rpc(_)));
76    }
77
78    #[test]
79    fn test_error_rpc_debug_matches_variant_and_status() {
80        let status = tonic::Status::internal("boom");
81        let err: Error = status.clone().into();
82        assert_eq!(format!("{err:?}"), format!("Rpc({status:?})"));
83    }
84
85    #[test]
86    fn test_error_rpc_anyhow_debug_matches_bare_status() {
87        // `opcda-bridge-client`'s commands convert this crate's `Error` into
88        // `anyhow::Error` via a bare `?`; this must render identically to
89        // today's direct `tonic::Status` -> `anyhow::Error` conversion, or
90        // the CLI's printed error text would silently change.
91        let status = tonic::Status::internal("boom");
92        let bare = anyhow::Error::from(status.clone());
93        let wrapped = anyhow::Error::from(Error::from(status));
94        assert_eq!(format!("{bare:?}"), format!("{wrapped:?}"));
95        assert_eq!(bare.to_string(), wrapped.to_string());
96    }
97
98    #[test]
99    fn test_protocol_error_is_actionable() {
100        let err = Error::Protocol("unknown browse node kind".into());
101        assert_eq!(err.to_string(), "protocol error: unknown browse node kind");
102    }
103
104    #[test]
105    fn test_incompatible_gateway_error_is_actionable() {
106        let err = Error::IncompatibleGateway {
107            operation: "paged browse",
108        };
109        assert!(err.to_string().contains("upgrade the gateway and client"));
110    }
111
112    // `Error::Connect`'s transparency (both the plain and anyhow-wrapped
113    // rendering) is exercised in `client::tests`, since a real
114    // `tonic::transport::Error` can only be produced by an actual failed
115    // connection attempt, not constructed directly.
116}