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