Skip to main content

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}