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 code this build has not been taught.
84    ///
85    /// Only ever produced by decoding: an unrecognized string falls through
86    /// to this variant via `#[serde(other)]` instead of failing the whole
87    /// frame. Nothing constructs one to send, which is a call-site
88    /// invariant rather than a type-level one: `#[serde(other)]` governs
89    /// decoding only, so serializing this would emit `"unrecognized"`.
90    #[serde(other)]
91    Unrecognized,
92}
93
94impl RpcErrorCode {
95    /// Every variant, for code that needs to iterate them all.
96    ///
97    /// `#[non_exhaustive]` forces a `_` arm on any match written outside this
98    /// crate, which would swallow a variant added here and never updated
99    /// there (`crates/shep-cli/src/exit.rs` maps every code to an exit
100    /// status).
101    pub const ALL: [Self; 7] = [
102        Self::NotFound,
103        Self::InvalidConfig,
104        Self::SpawnFailed,
105        Self::ProtocolMismatch,
106        Self::Internal,
107        Self::DeadlineExceeded,
108        Self::Unsupported,
109    ];
110
111    /// The [`crate::exit`] code a process stopping on this refusal exits
112    /// with. A code this build has not been taught is [`crate::exit::INTERNAL`].
113    #[must_use]
114    pub const fn exit_code(self) -> u8 {
115        use crate::exit;
116        match self {
117            Self::NotFound => exit::NOT_FOUND,
118            Self::InvalidConfig => exit::INVALID_CONFIG,
119            Self::SpawnFailed => exit::SPAWN_FAILED,
120            Self::ProtocolMismatch => exit::PROTOCOL_MISMATCH,
121            Self::Internal | Self::Unrecognized => exit::INTERNAL,
122            Self::DeadlineExceeded => exit::DEADLINE_EXCEEDED,
123            Self::Unsupported => exit::UNSUPPORTED,
124        }
125    }
126
127    /// Never called; exists so this crate fails to build if a variant is
128    /// added to [`RpcErrorCode`] without also being added to [`Self::ALL`].
129    ///
130    /// A match here is still checked for exhaustiveness, and each arm indexes
131    /// a fixed literal position into [`Self::ALL`], so growing the enum
132    /// without growing the array is an out-of-bounds constant index.
133    #[allow(dead_code)]
134    const fn assert_all_lists_every_variant(code: Self) -> Self {
135        match code {
136            Self::NotFound => Self::ALL[0],
137            Self::InvalidConfig => Self::ALL[1],
138            Self::SpawnFailed => Self::ALL[2],
139            Self::ProtocolMismatch => Self::ALL[3],
140            Self::Internal => Self::ALL[4],
141            Self::DeadlineExceeded => Self::ALL[5],
142            Self::Unsupported => Self::ALL[6],
143            // `Unrecognized` is decode-only and never appears in `ALL`;
144            // treat it as `Internal` would be treated.
145            Self::Unrecognized => Self::ALL[4],
146        }
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    /// A code this build has never heard of must decode, not fail. Without
155    /// this, adding any error code is a breaking change for every peer.
156    #[test]
157    fn an_unknown_error_code_decodes_as_unrecognized() {
158        assert_eq!(
159            serde_json::from_str::<RpcErrorCode>(r#""invented_next_year""#).unwrap(),
160            RpcErrorCode::Unrecognized
161        );
162    }
163
164    #[test]
165    fn every_known_error_code_still_round_trips() {
166        for code in [
167            RpcErrorCode::NotFound,
168            RpcErrorCode::InvalidConfig,
169            RpcErrorCode::SpawnFailed,
170            RpcErrorCode::ProtocolMismatch,
171            RpcErrorCode::Internal,
172            RpcErrorCode::DeadlineExceeded,
173            RpcErrorCode::Unsupported,
174        ] {
175            let json = serde_json::to_string(&code).unwrap();
176            assert_eq!(serde_json::from_str::<RpcErrorCode>(&json).unwrap(), code);
177        }
178    }
179
180    /// The fallback absorbs an unknown STRING, not an unknown TYPE. A number
181    /// where a code belongs is still a defect worth reporting.
182    #[test]
183    fn a_non_string_error_code_is_still_an_error() {
184        assert!(serde_json::from_str::<RpcErrorCode>("42").is_err());
185    }
186
187    /// The id has to survive a body this build cannot name, or the daemon
188    /// has nothing to address a refusal to.
189    #[test]
190    fn an_unknown_request_kind_keeps_the_envelope_id() {
191        let envelope: Envelope = serde_json::from_str(
192            r#"{"id":42,"deadline_ms":null,"body":{"kind":"from_the_future","extra":{"a":1}}}"#,
193        )
194        .unwrap();
195        assert_eq!(envelope.id, 42);
196        assert_eq!(envelope.body, Request::Unrecognized);
197    }
198
199    #[test]
200    fn v1_reply_fixture_still_deserializes() {
201        // Committed byte fixture, protocol v1.
202        let ok = r#"{"id":1,"result":{"Ok":{"kind":"pong"}}}"#;
203        let reply: Reply = serde_json::from_str(ok).unwrap();
204        assert!(matches!(reply.result, Ok(Response::Pong)));
205        let err = r#"{"id":2,"result":{"Err":{"code":"not_found","message":"no sheep"}}}"#;
206        let reply: Reply = serde_json::from_str(err).unwrap();
207        assert_eq!(reply.result.unwrap_err().code, RpcErrorCode::NotFound);
208    }
209
210    #[test]
211    fn an_rpc_error_without_a_daemon_version_serializes_exactly_as_before() {
212        // `skip_serializing_if` is what makes the field free: no
213        // `"daemon_version":null` key for an older client to ignore.
214        let plain = RpcError {
215            code: RpcErrorCode::NotFound,
216            message: "no sheep".to_string(),
217            daemon_version: None,
218        };
219        assert_eq!(
220            serde_json::to_string(&plain).unwrap(),
221            r#"{"code":"not_found","message":"no sheep"}"#
222        );
223    }
224
225    #[test]
226    fn a_v1_rpc_error_fixture_deserializes_with_no_daemon_version() {
227        let fixture =
228            r#"{"code":"protocol_mismatch","message":"daemon speaks protocol 1, client sent 2"}"#;
229        let err: RpcError = serde_json::from_str(fixture).unwrap();
230        assert_eq!(err.code, RpcErrorCode::ProtocolMismatch);
231        assert_eq!(err.daemon_version, None);
232    }
233
234    #[test]
235    fn an_old_client_ignores_an_rpc_error_field_it_has_never_seen() {
236        // `RpcError` carries no `deny_unknown_fields`, so an optional field
237        // may be added without moving `PROTOCOL_VERSION`.
238        #[derive(Deserialize)]
239        struct OldRpcError {
240            code: RpcErrorCode,
241            message: String,
242        }
243
244        let current = serde_json::to_string(&RpcError {
245            code: RpcErrorCode::ProtocolMismatch,
246            message: "daemon speaks protocol 1, client sent 2".to_string(),
247            daemon_version: Some("0.1.16".to_string()),
248        })
249        .unwrap();
250        let old: OldRpcError = serde_json::from_str(&current).expect("must tolerate");
251        assert_eq!(old.code, RpcErrorCode::ProtocolMismatch);
252        assert_eq!(old.message, "daemon speaks protocol 1, client sent 2");
253    }
254
255    #[test]
256    fn deadline_exceeded_code_serializes_snake_case() {
257        assert_eq!(
258            serde_json::to_string(&RpcErrorCode::DeadlineExceeded).unwrap(),
259            "\"deadline_exceeded\""
260        );
261        assert_eq!(
262            serde_json::from_str::<RpcErrorCode>("\"deadline_exceeded\"").unwrap(),
263            RpcErrorCode::DeadlineExceeded
264        );
265    }
266}