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    /// Never called; exists so this crate fails to build if a variant is
112    /// added to [`RpcErrorCode`] without also being added to [`Self::ALL`].
113    ///
114    /// A match here is still checked for exhaustiveness, and each arm indexes
115    /// a fixed literal position into [`Self::ALL`], so growing the enum
116    /// without growing the array is an out-of-bounds constant index.
117    #[allow(dead_code)]
118    const fn assert_all_lists_every_variant(code: Self) -> Self {
119        match code {
120            Self::NotFound => Self::ALL[0],
121            Self::InvalidConfig => Self::ALL[1],
122            Self::SpawnFailed => Self::ALL[2],
123            Self::ProtocolMismatch => Self::ALL[3],
124            Self::Internal => Self::ALL[4],
125            Self::DeadlineExceeded => Self::ALL[5],
126            Self::Unsupported => Self::ALL[6],
127            // `Unrecognized` is decode-only and never appears in `ALL`;
128            // treat it as `Internal` would be treated.
129            Self::Unrecognized => Self::ALL[4],
130        }
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    /// A code this build has never heard of must decode, not fail. Without
139    /// this, adding any error code is a breaking change for every peer.
140    #[test]
141    fn an_unknown_error_code_decodes_as_unrecognized() {
142        assert_eq!(
143            serde_json::from_str::<RpcErrorCode>(r#""invented_next_year""#).unwrap(),
144            RpcErrorCode::Unrecognized
145        );
146    }
147
148    #[test]
149    fn every_known_error_code_still_round_trips() {
150        for code in [
151            RpcErrorCode::NotFound,
152            RpcErrorCode::InvalidConfig,
153            RpcErrorCode::SpawnFailed,
154            RpcErrorCode::ProtocolMismatch,
155            RpcErrorCode::Internal,
156            RpcErrorCode::DeadlineExceeded,
157            RpcErrorCode::Unsupported,
158        ] {
159            let json = serde_json::to_string(&code).unwrap();
160            assert_eq!(serde_json::from_str::<RpcErrorCode>(&json).unwrap(), code);
161        }
162    }
163
164    /// The fallback absorbs an unknown STRING, not an unknown TYPE. A number
165    /// where a code belongs is still a defect worth reporting.
166    #[test]
167    fn a_non_string_error_code_is_still_an_error() {
168        assert!(serde_json::from_str::<RpcErrorCode>("42").is_err());
169    }
170
171    /// The id has to survive a body this build cannot name, or the daemon
172    /// has nothing to address a refusal to.
173    #[test]
174    fn an_unknown_request_kind_keeps_the_envelope_id() {
175        let envelope: Envelope = serde_json::from_str(
176            r#"{"id":42,"deadline_ms":null,"body":{"kind":"from_the_future","extra":{"a":1}}}"#,
177        )
178        .unwrap();
179        assert_eq!(envelope.id, 42);
180        assert_eq!(envelope.body, Request::Unrecognized);
181    }
182
183    #[test]
184    fn v1_reply_fixture_still_deserializes() {
185        // Committed byte fixture, protocol v1.
186        let ok = r#"{"id":1,"result":{"Ok":{"kind":"pong"}}}"#;
187        let reply: Reply = serde_json::from_str(ok).unwrap();
188        assert!(matches!(reply.result, Ok(Response::Pong)));
189        let err = r#"{"id":2,"result":{"Err":{"code":"not_found","message":"no sheep"}}}"#;
190        let reply: Reply = serde_json::from_str(err).unwrap();
191        assert_eq!(reply.result.unwrap_err().code, RpcErrorCode::NotFound);
192    }
193
194    #[test]
195    fn an_rpc_error_without_a_daemon_version_serializes_exactly_as_before() {
196        // `skip_serializing_if` is what makes the field free: no
197        // `"daemon_version":null` key for an older client to ignore.
198        let plain = RpcError {
199            code: RpcErrorCode::NotFound,
200            message: "no sheep".to_string(),
201            daemon_version: None,
202        };
203        assert_eq!(
204            serde_json::to_string(&plain).unwrap(),
205            r#"{"code":"not_found","message":"no sheep"}"#
206        );
207    }
208
209    #[test]
210    fn a_v1_rpc_error_fixture_deserializes_with_no_daemon_version() {
211        let fixture =
212            r#"{"code":"protocol_mismatch","message":"daemon speaks protocol 1, client sent 2"}"#;
213        let err: RpcError = serde_json::from_str(fixture).unwrap();
214        assert_eq!(err.code, RpcErrorCode::ProtocolMismatch);
215        assert_eq!(err.daemon_version, None);
216    }
217
218    #[test]
219    fn an_old_client_ignores_an_rpc_error_field_it_has_never_seen() {
220        // `RpcError` carries no `deny_unknown_fields`, so an optional field
221        // may be added without moving `PROTOCOL_VERSION`.
222        #[derive(Deserialize)]
223        struct OldRpcError {
224            code: RpcErrorCode,
225            message: String,
226        }
227
228        let current = serde_json::to_string(&RpcError {
229            code: RpcErrorCode::ProtocolMismatch,
230            message: "daemon speaks protocol 1, client sent 2".to_string(),
231            daemon_version: Some("0.1.16".to_string()),
232        })
233        .unwrap();
234        let old: OldRpcError = serde_json::from_str(&current).expect("must tolerate");
235        assert_eq!(old.code, RpcErrorCode::ProtocolMismatch);
236        assert_eq!(old.message, "daemon speaks protocol 1, client sent 2");
237    }
238
239    #[test]
240    fn deadline_exceeded_code_serializes_snake_case() {
241        assert_eq!(
242            serde_json::to_string(&RpcErrorCode::DeadlineExceeded).unwrap(),
243            "\"deadline_exceeded\""
244        );
245        assert_eq!(
246            serde_json::from_str::<RpcErrorCode>("\"deadline_exceeded\"").unwrap(),
247            RpcErrorCode::DeadlineExceeded
248        );
249    }
250}