nomoreide_remote_protocol/errors.rs
1//! The frozen error codes, and the one thing a caller is allowed to conclude
2//! from each: whether retrying is safe.
3//!
4//! Codes are `SCREAMING_SNAKE_CASE` strings on the wire rather than integers,
5//! because the first reader of a relay error is a person looking at a log line
6//! and the second is a phone deciding whether to offer a retry button. Neither
7//! is helped by `4007`.
8//!
9//! **`retryable` is a property of the code, not of the situation.** A caller
10//! must never decide for itself that a refusal looks safe to repeat: the whole
11//! danger of remote machine control is the mutation that ran, answered nothing,
12//! and gets sent again. So the table below is the only authority, and it is
13//! deliberately pessimistic — anything that *might* have executed is not
14//! retryable, even when it usually did not.
15
16use serde::{Deserialize, Serialize};
17
18/// Why a frame was refused, or why the operation behind it did not happen.
19///
20/// Non-exhaustive is deliberate on the *reading* side only: a peer that meets a
21/// code it does not know treats it as [`ErrorCode::InternalError`] rather than
22/// failing to parse, so adding a code in a later minor revision cannot break an
23/// older client. The serde representation below is what makes that work.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
25#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
26pub enum ErrorCode {
27 /// The frame named a major version this peer cannot speak at all.
28 UnsupportedProtocolVersion,
29 /// The bytes were not a valid envelope: bad JSON, missing field, unknown
30 /// field, wrong type.
31 MalformedFrame,
32 /// The envelope parsed, but `type` is not a name this peer accepts **in
33 /// this direction**. A response type arriving at the daemon is this, not
34 /// [`ErrorCode::MalformedFrame`].
35 UnknownCommand,
36 /// The frame was larger than [`super::limits::MAX_FRAME_BYTES`]. Answered
37 /// only when the frame was small enough to identify; past that the socket
38 /// simply closes.
39 FrameTooLarge,
40 /// A field inside a well-formed frame exceeded its own limit — a prompt
41 /// over 16 KiB, say.
42 PayloadTooLarge,
43 /// `sentAt` is outside the window in [`super::limits`]. The sender has
44 /// almost certainly stopped waiting.
45 StaleRequest,
46 /// This request id was already seen inside the dedup window and the
47 /// operation is not one that may run twice.
48 DuplicateRequest,
49 /// The device already has [`super::limits::MAX_PENDING_COMMANDS`] requests
50 /// in flight.
51 TooManyPending,
52 /// The credential is revoked, belongs to another device, or the caller does
53 /// not own this device.
54 NotAuthorized,
55 /// No daemon socket is currently attached to this device. Never queued —
56 /// presence fails closed.
57 DeviceOffline,
58 /// No service by that exact name is registered. The remote surface never
59 /// takes a pattern, so this is always an exact-name miss.
60 UnknownService,
61 /// The daemon tried the action and it failed. The only code here that
62 /// carries a message written by the local runtime rather than the protocol.
63 ServiceActionFailed,
64 /// No agent run by that id, or one that has already finished.
65 UnknownRun,
66 /// No pending approval by that id on that run.
67 UnknownApproval,
68 /// The approval was answered by its own expiry before the human answered
69 /// it. Always a deny, never a silent drop.
70 ApprovalExpired,
71 /// The operation was still running when its deadline passed. **Not**
72 /// retryable: the daemon may yet finish it.
73 Timeout,
74 /// A per-IP, per-user or per-device rate limit refused the frame.
75 RateLimited,
76 /// The peer speaks this protocol version but not this capability — an older
77 /// daemon meeting a feature that shipped after it. Rendered to a user as
78 /// "your machine needs updating", never as a failure.
79 CapabilityUnavailable,
80 /// Anything else, and anything a peer does not recognise.
81 #[serde(other)]
82 InternalError,
83}
84
85impl ErrorCode {
86 /// Whether the *same request id* may be sent again by an automatic retry.
87 ///
88 /// True only where the operation provably did not start. Everything
89 /// ambiguous — a timeout above all — is false, and is a decision for a
90 /// human looking at the machine's real state.
91 pub const fn retryable(self) -> bool {
92 match self {
93 // Refused before anything ran.
94 Self::TooManyPending | Self::RateLimited | Self::DeviceOffline => true,
95 // Either the frame is wrong, the caller is wrong, or the outcome is
96 // unknown. None of those get better by sending it again.
97 Self::UnsupportedProtocolVersion
98 | Self::MalformedFrame
99 | Self::UnknownCommand
100 | Self::FrameTooLarge
101 | Self::PayloadTooLarge
102 | Self::StaleRequest
103 | Self::DuplicateRequest
104 | Self::NotAuthorized
105 | Self::UnknownService
106 | Self::ServiceActionFailed
107 | Self::UnknownRun
108 | Self::UnknownApproval
109 | Self::ApprovalExpired
110 | Self::Timeout
111 | Self::CapabilityUnavailable
112 | Self::InternalError => false,
113 }
114 }
115
116 /// Whether meeting this code should close the device socket.
117 ///
118 /// A daemon that cannot be trusted to frame correctly is not a daemon whose
119 /// next frame should be believed.
120 pub const fn fatal_to_session(self) -> bool {
121 matches!(
122 self,
123 Self::UnsupportedProtocolVersion
124 | Self::MalformedFrame
125 | Self::FrameTooLarge
126 | Self::NotAuthorized
127 )
128 }
129
130 /// The wire spelling. Kept as a method as well as a serde attribute so the
131 /// fixture tests can assert the exact string without a round trip.
132 pub const fn as_str(self) -> &'static str {
133 match self {
134 Self::UnsupportedProtocolVersion => "UNSUPPORTED_PROTOCOL_VERSION",
135 Self::MalformedFrame => "MALFORMED_FRAME",
136 Self::UnknownCommand => "UNKNOWN_COMMAND",
137 Self::FrameTooLarge => "FRAME_TOO_LARGE",
138 Self::PayloadTooLarge => "PAYLOAD_TOO_LARGE",
139 Self::StaleRequest => "STALE_REQUEST",
140 Self::DuplicateRequest => "DUPLICATE_REQUEST",
141 Self::TooManyPending => "TOO_MANY_PENDING",
142 Self::NotAuthorized => "NOT_AUTHORIZED",
143 Self::DeviceOffline => "DEVICE_OFFLINE",
144 Self::UnknownService => "UNKNOWN_SERVICE",
145 Self::ServiceActionFailed => "SERVICE_ACTION_FAILED",
146 Self::UnknownRun => "UNKNOWN_RUN",
147 Self::UnknownApproval => "UNKNOWN_APPROVAL",
148 Self::ApprovalExpired => "APPROVAL_EXPIRED",
149 Self::Timeout => "TIMEOUT",
150 Self::RateLimited => "RATE_LIMITED",
151 Self::CapabilityUnavailable => "CAPABILITY_UNAVAILABLE",
152 Self::InternalError => "INTERNAL_ERROR",
153 }
154 }
155
156 /// Every code, in wire order. The exhaustiveness tests and the generated
157 /// documentation both read this, so a new variant that is not listed here
158 /// fails the build's own tests rather than shipping undocumented.
159 pub const ALL: &'static [Self] = &[
160 Self::UnsupportedProtocolVersion,
161 Self::MalformedFrame,
162 Self::UnknownCommand,
163 Self::FrameTooLarge,
164 Self::PayloadTooLarge,
165 Self::StaleRequest,
166 Self::DuplicateRequest,
167 Self::TooManyPending,
168 Self::NotAuthorized,
169 Self::DeviceOffline,
170 Self::UnknownService,
171 Self::ServiceActionFailed,
172 Self::UnknownRun,
173 Self::UnknownApproval,
174 Self::ApprovalExpired,
175 Self::Timeout,
176 Self::RateLimited,
177 Self::CapabilityUnavailable,
178 Self::InternalError,
179 ];
180}
181
182/// A refusal on the wire.
183///
184/// `message` is prose for a human and is never parsed. `detail` carries the one
185/// machine-readable hint a caller legitimately needs — which service was
186/// unknown, which limit was exceeded — and is deliberately a flat string rather
187/// than an open object, so an error can never become a second payload channel
188/// carrying local state off the machine.
189#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
190#[serde(rename_all = "camelCase", deny_unknown_fields)]
191pub struct ProtocolError {
192 pub code: ErrorCode,
193 pub message: String,
194 #[serde(default, skip_serializing_if = "Option::is_none")]
195 pub detail: Option<String>,
196 /// Repeated from [`ErrorCode::retryable`] so a peer that does not know the
197 /// code still knows what to do with it. A peer that *does* know the code
198 /// must trust its own table over this field — otherwise a hostile relay
199 /// could mark a timeout retryable and drive a double mutation.
200 pub retryable: bool,
201}
202
203impl ProtocolError {
204 pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
205 Self {
206 code,
207 message: message.into(),
208 detail: None,
209 retryable: code.retryable(),
210 }
211 }
212
213 pub fn with_detail(mut self, detail: impl Into<String>) -> Self {
214 self.detail = Some(detail.into());
215 self
216 }
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222
223 #[test]
224 fn every_code_serialises_to_its_documented_spelling() {
225 for code in ErrorCode::ALL {
226 let json = serde_json::to_string(code).expect("serialise");
227 assert_eq!(json, format!("\"{}\"", code.as_str()));
228 }
229 }
230
231 /// `ALL` is what the tests and the spec iterate. A variant missing from it
232 /// would be invisible to both.
233 #[test]
234 fn all_lists_every_variant_once() {
235 let mut seen = std::collections::HashSet::new();
236 for code in ErrorCode::ALL {
237 assert!(seen.insert(code.as_str()), "duplicated {}", code.as_str());
238 }
239 // Bump this deliberately alongside the spec document.
240 assert_eq!(seen.len(), 19);
241 }
242
243 /// An unknown code from a newer peer must degrade, not explode. This is the
244 /// difference between an old phone meeting a new relay and an old phone
245 /// meeting a brick wall.
246 #[test]
247 fn an_unknown_code_reads_as_internal_error() {
248 let parsed: ErrorCode =
249 serde_json::from_str("\"SOMETHING_INVENTED_LATER\"").expect("parse");
250 assert_eq!(parsed, ErrorCode::InternalError);
251 }
252
253 /// The pessimistic half of the table is the one that matters. If any of
254 /// these ever becomes retryable it is a double-mutation bug.
255 #[test]
256 fn ambiguous_outcomes_are_never_retryable() {
257 for code in [
258 ErrorCode::Timeout,
259 ErrorCode::ServiceActionFailed,
260 ErrorCode::DuplicateRequest,
261 ErrorCode::InternalError,
262 ] {
263 assert!(!code.retryable(), "{} must not be retryable", code.as_str());
264 }
265 }
266
267 #[test]
268 fn retryable_is_derived_not_supplied() {
269 let error = ProtocolError::new(ErrorCode::Timeout, "took too long");
270 assert!(!error.retryable);
271 let error = ProtocolError::new(ErrorCode::RateLimited, "slow down");
272 assert!(error.retryable);
273 }
274
275 #[test]
276 fn an_error_rejects_fields_it_does_not_define() {
277 let refused = serde_json::from_str::<ProtocolError>(
278 r#"{"code":"TIMEOUT","message":"x","retryable":false,"stack":"..."}"#,
279 );
280 assert!(refused.is_err());
281 }
282}