Skip to main content

shep_core/protocol/request/
envelope.rs

1//! Wire framing around a request and its reply, and the structured error a reply can hold.
2
3use serde::{Deserialize, Serialize};
4
5use super::{HelloAck, Request, Response};
6
7/// A request frame
8// wire format: changing this is a breaking change
9#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
10pub struct Envelope {
11    /// Per-connection request id
12    pub id: u64,
13    /// Client-imposed deadline (daemon aborts work past it)
14    pub deadline_ms: Option<u64>,
15    /// The request
16    pub body: Request,
17}
18
19/// A reply frame
20///
21/// `result` uses serde's stock `Result` representation: the wire carries
22/// `{"Ok": ...}` / `{"Err": ...}`, with capitalized keys, pinned by snapshot.
23// wire format: changing this is a breaking change
24#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
25pub struct Reply {
26    /// Echoes [`Envelope::id`]
27    pub id: u64,
28    /// The outcome
29    pub result: Result<Response, RpcError>,
30}
31
32/// Handshake outcome: `HelloAck` or a typed refusal, since version skew is
33/// an error rather than silence. Same `Ok`/`Err` wire shape as
34/// [`Reply::result`]; refusals use [`RpcErrorCode::ProtocolMismatch`].
35pub type HelloReply = Result<HelloAck, RpcError>;
36
37/// Structured RPC failure
38// wire format: changing this is a breaking change
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40pub struct RpcError {
41    /// Machine-readable code
42    pub code: RpcErrorCode,
43    /// Human-readable message (plain English, no theme)
44    pub message: String,
45    /// The daemon's own crate version, when it chose to name it.
46    ///
47    /// Set on a [`RpcErrorCode::ProtocolMismatch`] refusal, the only place a
48    /// client can learn it, since [`HelloAck::daemon_version`] never arrives
49    /// there. `None` on every other error, and on a refusal from a daemon
50    /// built before the field existed, so a reader treats `None` as unknown
51    /// and takes the conservative path.
52    ///
53    /// Absent on the wire rather than `null`, so
54    /// [`crate::protocol::PROTOCOL_VERSION`] does not move for it.
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub daemon_version: Option<String>,
57}
58
59/// Machine-readable RPC error codes
60// wire format: changing existing variants is a breaking change
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(rename_all = "snake_case")]
63#[non_exhaustive]
64pub enum RpcErrorCode {
65    /// Selector matched nothing
66    NotFound,
67    /// Config failed validation daemon-side
68    InvalidConfig,
69    /// Spawn failed (exec error, permissions)
70    SpawnFailed,
71    /// Handshake protocol version mismatch
72    ProtocolMismatch,
73    /// Unexpected daemon-side failure
74    Internal,
75    /// The request's deadline expired before the daemon finished it
76    DeadlineExceeded,
77    /// The peer asked for something this build does not implement.
78    ///
79    /// Distinct from `NotFound`, which means a selector matched nothing.
80    /// This means the verb itself is unknown here, and the remedy is a
81    /// newer shepherd rather than a different selector.
82    Unsupported,
83    /// A spawn was refused because the app is disabled.
84    ///
85    /// The remedy is a different verb, `shep enable`, so the message names
86    /// it. A client built before this code decodes it as
87    /// [`Self::Unrecognized`].
88    Disabled,
89    /// A code this build has not been taught.
90    ///
91    /// Only ever produced by decoding: an unrecognized string falls through
92    /// to this variant via `#[serde(other)]` instead of failing the whole
93    /// frame. Nothing constructs one to send, which is a call-site
94    /// invariant rather than a type-level one: `#[serde(other)]` governs
95    /// decoding only, so serializing this would emit `"unrecognized"`.
96    #[serde(other)]
97    Unrecognized,
98}
99
100impl RpcErrorCode {
101    /// Every variant, for code that needs to iterate them all.
102    ///
103    /// `#[non_exhaustive]` forces a `_` arm on any match written outside this
104    /// crate, which would swallow a variant added here and never updated
105    /// there (`crates/shep-cli/src/exit.rs` maps every code to an exit
106    /// status).
107    pub const ALL: [Self; 8] = [
108        Self::NotFound,
109        Self::InvalidConfig,
110        Self::SpawnFailed,
111        Self::ProtocolMismatch,
112        Self::Internal,
113        Self::DeadlineExceeded,
114        Self::Unsupported,
115        Self::Disabled,
116    ];
117
118    /// The [`crate::exit`] code a process stopping on this refusal exits
119    /// with. A code this build has not been taught is [`crate::exit::INTERNAL`].
120    #[must_use]
121    pub const fn exit_code(self) -> u8 {
122        use crate::exit;
123        match self {
124            Self::NotFound => exit::NOT_FOUND,
125            Self::InvalidConfig => exit::INVALID_CONFIG,
126            Self::SpawnFailed => exit::SPAWN_FAILED,
127            Self::ProtocolMismatch => exit::PROTOCOL_MISMATCH,
128            Self::Internal | Self::Unrecognized => exit::INTERNAL,
129            Self::DeadlineExceeded => exit::DEADLINE_EXCEEDED,
130            Self::Unsupported => exit::UNSUPPORTED,
131            Self::Disabled => exit::USAGE,
132        }
133    }
134
135    /// Never called; exists so this crate fails to build if a variant is
136    /// added to [`RpcErrorCode`] without also being added to [`Self::ALL`].
137    ///
138    /// A match here is still checked for exhaustiveness, and each arm indexes
139    /// a fixed literal position into [`Self::ALL`], so growing the enum
140    /// without growing the array is an out-of-bounds constant index.
141    #[allow(dead_code)]
142    const fn assert_all_lists_every_variant(code: Self) -> Self {
143        match code {
144            Self::NotFound => Self::ALL[0],
145            Self::InvalidConfig => Self::ALL[1],
146            Self::SpawnFailed => Self::ALL[2],
147            Self::ProtocolMismatch => Self::ALL[3],
148            Self::Internal => Self::ALL[4],
149            Self::DeadlineExceeded => Self::ALL[5],
150            Self::Unsupported => Self::ALL[6],
151            Self::Disabled => Self::ALL[7],
152            // `Unrecognized` is decode-only and never appears in `ALL`;
153            // treat it as `Internal` would be treated.
154            Self::Unrecognized => Self::ALL[4],
155        }
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    /// A code this build has never heard of must decode, not fail. Without
164    /// this, adding any error code is a breaking change for every peer.
165    #[test]
166    fn an_unknown_error_code_decodes_as_unrecognized() {
167        assert_eq!(
168            serde_json::from_str::<RpcErrorCode>(r#""invented_next_year""#).unwrap(),
169            RpcErrorCode::Unrecognized
170        );
171    }
172
173    #[test]
174    fn every_known_error_code_still_round_trips() {
175        for code in [
176            RpcErrorCode::NotFound,
177            RpcErrorCode::InvalidConfig,
178            RpcErrorCode::SpawnFailed,
179            RpcErrorCode::ProtocolMismatch,
180            RpcErrorCode::Internal,
181            RpcErrorCode::DeadlineExceeded,
182            RpcErrorCode::Unsupported,
183            RpcErrorCode::Disabled,
184        ] {
185            let json = serde_json::to_string(&code).unwrap();
186            assert_eq!(serde_json::from_str::<RpcErrorCode>(&json).unwrap(), code);
187        }
188    }
189
190    /// This CLI reads the fix as a different verb, so the refusal is a usage
191    /// error rather than the `Internal` an older client gives an unknown code.
192    #[test]
193    fn a_disabled_refusal_exits_usage() {
194        assert_eq!(RpcErrorCode::Disabled.exit_code(), crate::exit::USAGE);
195        assert_eq!(
196            serde_json::to_string(&RpcErrorCode::Disabled).unwrap(),
197            r#""disabled""#
198        );
199    }
200
201    /// The fallback absorbs an unknown STRING, not an unknown TYPE. A number
202    /// where a code belongs is still a defect worth reporting.
203    #[test]
204    fn a_non_string_error_code_is_still_an_error() {
205        assert!(serde_json::from_str::<RpcErrorCode>("42").is_err());
206    }
207
208    /// The id has to survive a body this build cannot name, or the daemon
209    /// has nothing to address a refusal to.
210    #[test]
211    fn an_unknown_request_kind_keeps_the_envelope_id() {
212        let envelope: Envelope = serde_json::from_str(
213            r#"{"id":42,"deadline_ms":null,"body":{"kind":"from_the_future","extra":{"a":1}}}"#,
214        )
215        .unwrap();
216        assert_eq!(envelope.id, 42);
217        assert_eq!(envelope.body, Request::Unrecognized);
218    }
219
220    #[test]
221    fn v1_reply_fixture_still_deserializes() {
222        // Committed byte fixture, protocol v1.
223        let ok = r#"{"id":1,"result":{"Ok":{"kind":"pong"}}}"#;
224        let reply: Reply = serde_json::from_str(ok).unwrap();
225        assert!(matches!(reply.result, Ok(Response::Pong)));
226        let err = r#"{"id":2,"result":{"Err":{"code":"not_found","message":"no sheep"}}}"#;
227        let reply: Reply = serde_json::from_str(err).unwrap();
228        assert_eq!(reply.result.unwrap_err().code, RpcErrorCode::NotFound);
229    }
230
231    #[test]
232    fn an_rpc_error_without_a_daemon_version_serializes_exactly_as_before() {
233        // `skip_serializing_if` is what makes the field free: no
234        // `"daemon_version":null` key for an older client to ignore.
235        let plain = RpcError {
236            code: RpcErrorCode::NotFound,
237            message: "no sheep".to_string(),
238            daemon_version: None,
239        };
240        assert_eq!(
241            serde_json::to_string(&plain).unwrap(),
242            r#"{"code":"not_found","message":"no sheep"}"#
243        );
244    }
245
246    #[test]
247    fn a_v1_rpc_error_fixture_deserializes_with_no_daemon_version() {
248        let fixture =
249            r#"{"code":"protocol_mismatch","message":"daemon speaks protocol 1, client sent 2"}"#;
250        let err: RpcError = serde_json::from_str(fixture).unwrap();
251        assert_eq!(err.code, RpcErrorCode::ProtocolMismatch);
252        assert_eq!(err.daemon_version, None);
253    }
254
255    #[test]
256    fn an_old_client_ignores_an_rpc_error_field_it_has_never_seen() {
257        // `RpcError` carries no `deny_unknown_fields`, so an optional field
258        // may be added without moving `PROTOCOL_VERSION`.
259        #[derive(Deserialize)]
260        struct OldRpcError {
261            code: RpcErrorCode,
262            message: String,
263        }
264
265        let current = serde_json::to_string(&RpcError {
266            code: RpcErrorCode::ProtocolMismatch,
267            message: "daemon speaks protocol 1, client sent 2".to_string(),
268            daemon_version: Some("0.1.16".to_string()),
269        })
270        .unwrap();
271        let old: OldRpcError = serde_json::from_str(&current).expect("must tolerate");
272        assert_eq!(old.code, RpcErrorCode::ProtocolMismatch);
273        assert_eq!(old.message, "daemon speaks protocol 1, client sent 2");
274    }
275
276    #[test]
277    fn deadline_exceeded_code_serializes_snake_case() {
278        assert_eq!(
279            serde_json::to_string(&RpcErrorCode::DeadlineExceeded).unwrap(),
280            "\"deadline_exceeded\""
281        );
282        assert_eq!(
283            serde_json::from_str::<RpcErrorCode>("\"deadline_exceeded\"").unwrap(),
284            RpcErrorCode::DeadlineExceeded
285        );
286    }
287}