Skip to main content

rightkit_daemon/
error.rs

1use crate::host_identity::HostIdentityError;
2use serde_json::{json, Value};
3use std::fmt;
4
5/// The host is handing the service to a higher-ranked host. Retryable: re-read
6/// discovery and resubmit the whole request.
7pub const ERROR_HOST_HANDOFF: &str = "host_handoff";
8/// The process behind the endpoint is not the host the caller expected
9/// ([`Client::connect_expecting`](crate::Client::connect_expecting)). Not retryable: reconnecting
10/// reaches the same impostor, and no application byte was sent to it.
11pub const ERROR_HOST_IDENTITY: &str = "host_identity";
12/// The suite root could not be resolved, or a [`ClientConfig`](crate::ClientConfig) that ignores
13/// environment overrides was handed a suite that came from the environment. Not retryable.
14pub const ERROR_SUITE_UNRESOLVED: &str = "suite_unresolved";
15/// The calling app may not call this method (or is not on the allowlist).
16pub const ERROR_NOT_ALLOWED: &str = "request_not_allowed";
17/// The handler's result did not fit the host's frame limit, so it was not sent. Not retryable:
18/// the same request produces the same result. Ask for less (a page, a smaller body).
19pub const ERROR_RESPONSE_TOO_LARGE: &str = "response_too_large";
20/// A request is larger than the host will assemble from chunks
21/// ([`HostOptions::max_message`](crate::HostOptions::max_message)), or than one frame when the
22/// host does not chunk. Not retryable. Ask for less.
23pub const ERROR_REQUEST_TOO_LARGE: &str = "request_too_large";
24/// A chunk frame (`rk.upload`, `rk.fetch`) was malformed, out of order, repeated or for an upload
25/// or result that is gone. Not retryable as sent: start the whole transfer again.
26pub const ERROR_BAD_CHUNK: &str = "bad_chunk";
27/// The connection dropped before a response arrived. Retryable.
28pub const ERROR_CONNECTION_CLOSED: &str = "connection_closed";
29/// The per-request deadline (connect + write + read) expired and the
30/// connection was aborted. Not retryable: the request may already have run on
31/// the host, so it is never replayed automatically.
32pub const ERROR_TIMEOUT: &str = "request_timeout";
33/// No live host for the service. Retryable.
34pub const ERROR_UNAVAILABLE: &str = "service_unavailable";
35
36/// The caller presented no valid credential (a device that is unknown, or whose signature failed).
37pub const ERROR_UNAUTHENTICATED: &str = "unauthenticated";
38/// The caller is known but lacks the scope for this method.
39pub const ERROR_FORBIDDEN: &str = "forbidden";
40/// A write named a stale revision.
41pub const ERROR_REVISION_CONFLICT: &str = "revision_conflict";
42/// Too many failed authentications from this connection, or from all callers together, inside
43/// the failure window ([`crate::fail_limit`]). Not retryable on the same connection; the block
44/// lapses on its own, so wait before reconnecting.
45pub const ERROR_RATE_LIMITED: &str = "rate_limited";
46
47/// Typed wire error. `retryable` means resubmitting the request can succeed.
48///
49/// Equality compares `code`, `message`, `retryable` and `details`.
50#[derive(Debug, Clone, PartialEq)]
51pub struct ServiceError {
52    pub code: String,
53    pub message: String,
54    pub retryable: bool,
55    /// Optional structured data (e.g. the platform of a `needs_auth`). Absent
56    /// or `null` on the wire reads as `None`; written only when present, so
57    /// older peers see the frame they always saw. Set with
58    /// [`ServiceError::with_details`], read with [`ServiceError::details`].
59    pub details: Option<Value>,
60}
61
62impl Eq for ServiceError {}
63
64impl ServiceError {
65    pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
66        Self {
67            code: code.into(),
68            message: message.into(),
69            retryable: false,
70            details: None,
71        }
72    }
73    pub fn retryable(code: impl Into<String>, message: impl Into<String>) -> Self {
74        Self {
75            code: code.into(),
76            message: message.into(),
77            retryable: true,
78            details: None,
79        }
80    }
81    /// Attaches structured `details`; `Value::Null` clears them.
82    pub fn with_details(mut self, details: Value) -> Self {
83        self.details = if details.is_null() {
84            None
85        } else {
86            Some(details)
87        };
88        self
89    }
90    /// Structured data attached with [`ServiceError::with_details`], or `None`.
91    pub fn details(&self) -> Option<&Value> {
92        self.details.as_ref()
93    }
94    pub fn handoff() -> Self {
95        Self::retryable(
96            ERROR_HOST_HANDOFF,
97            "host is handing the service to a higher-ranked host; retry",
98        )
99    }
100    pub fn to_value(&self) -> Value {
101        let mut v =
102            json!({"code": self.code, "message": self.message, "retryable": self.retryable});
103        if let (Some(d), Some(o)) = (&self.details, v.as_object_mut()) {
104            o.insert("details".to_string(), d.clone());
105        }
106        v
107    }
108    pub fn from_value(v: &Value) -> Self {
109        Self {
110            code: v["code"].as_str().unwrap_or("error").into(),
111            message: v["message"].as_str().unwrap_or("").into(),
112            retryable: v["retryable"].as_bool().unwrap_or(false),
113            details: v.get("details").filter(|d| !d.is_null()).cloned(),
114        }
115    }
116}
117
118impl fmt::Display for ServiceError {
119    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
120        write!(f, "{}: {}", self.code, self.message)
121    }
122}
123impl std::error::Error for ServiceError {}
124impl From<std::io::Error> for ServiceError {
125    fn from(e: std::io::Error) -> Self {
126        Self::new("io", e.to_string())
127    }
128}
129impl From<HostIdentityError> for ServiceError {
130    /// The wire-style form of a host identity refusal: code [`ERROR_HOST_IDENTITY`], not retryable.
131    fn from(e: HostIdentityError) -> Self {
132        Self::new(ERROR_HOST_IDENTITY, e.to_string())
133    }
134}
135
136/// Why [`Client::connect_expecting`](crate::Client::connect_expecting) did not return a client.
137///
138/// The two variants are different facts. [`ConnectError::Service`] is the ordinary connection
139/// story (no record, host unreachable, protocol mismatch, hello refused, timeout) and keeps its
140/// `retryable` flag. [`ConnectError::HostIdentity`] means something answered at the endpoint and
141/// the OS says it is not the host the caller named; no application byte was sent to it.
142#[derive(Debug, Clone, PartialEq, Eq)]
143#[non_exhaustive]
144pub enum ConnectError {
145    Service(ServiceError),
146    HostIdentity(HostIdentityError),
147}
148
149impl ConnectError {
150    /// Collapse into the single wire-style error. A host identity refusal becomes code
151    /// [`ERROR_HOST_IDENTITY`], not retryable, with the typed detail in the message.
152    pub fn into_service_error(self) -> ServiceError {
153        match self {
154            ConnectError::Service(e) => e,
155            ConnectError::HostIdentity(e) => e.into(),
156        }
157    }
158}
159
160impl fmt::Display for ConnectError {
161    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
162        match self {
163            ConnectError::Service(e) => fmt::Display::fmt(e, f),
164            ConnectError::HostIdentity(e) => fmt::Display::fmt(e, f),
165        }
166    }
167}
168
169impl std::error::Error for ConnectError {
170    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
171        match self {
172            ConnectError::Service(e) => Some(e),
173            ConnectError::HostIdentity(e) => Some(e),
174        }
175    }
176}
177
178impl From<ServiceError> for ConnectError {
179    fn from(e: ServiceError) -> Self {
180        ConnectError::Service(e)
181    }
182}
183
184impl From<HostIdentityError> for ConnectError {
185    fn from(e: HostIdentityError) -> Self {
186        ConnectError::HostIdentity(e)
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193
194    #[test]
195    fn details_roundtrip_and_absent_or_null_read_as_none() {
196        let plain = ServiceError::new("nope", "x");
197        assert!(plain.details().is_none());
198        assert!(plain.to_value().get("details").is_none());
199        assert_eq!(ServiceError::from_value(&plain.to_value()), plain);
200        let nulled = json!({"code": "nope", "message": "x", "retryable": false, "details": null});
201        assert_eq!(ServiceError::from_value(&nulled), plain);
202        let detailed = ServiceError::retryable("needs_auth", "sign in")
203            .with_details(json!({"platform": "x", "nested": {"a": [1, 2]}}));
204        assert_eq!(detailed.to_value()["details"]["nested"]["a"], json!([1, 2]));
205        assert_eq!(ServiceError::from_value(&detailed.to_value()), detailed);
206        assert!(detailed.with_details(Value::Null).details().is_none());
207    }
208}