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(¤t).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}