Skip to main content

cli/
hosted_typed_error.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Surfacing for the hosted server's typed gRPC error vocabulary (AX H4).
3//!
4//! The hosted weft server attaches machine-readable error details to failing
5//! gRPC responses as `google.rpc.Status` details (the standard
6//! `grpc-status-details-bin` trailer) — see weft
7//! `crates/weft-server/src/server/typed_error.rs`. The vocabulary is defined in
8//! `api/proto/heddle/api/v1alpha1/errors.proto` and both sides consume it via
9//! the `heddle-api` crate.
10//!
11//! This module decodes the three conflict/pagination/stream details the CLI
12//! acts on — [`ConflictDetail`], [`CursorFailure`], [`StreamFailure`] — off a
13//! [`tonic::Status`] and projects them into:
14//!
15//! - the JSON/text **error envelope** (`cli::commands::error_envelope`): a
16//!   stable `kind`, a recovery `hint`, and structured `extra_json_fields` an
17//!   agent can branch on (the conflicting resource, the `restart_cursor`,
18//!   whether a stream is resumable), and
19//! - the **exit-code taxonomy** (`exit`): a cursor/stream failure is a
20//!   safe-retry (`TempFail` = 75, restart the pagination/stream); a conflict
21//!   needs a changed input (a fresh op-id or a fetch+retry) so it stays a
22//!   protocol-layer rejection (`Protocol` = 76).
23//!
24//! Decoding is dependency-light: rather than pull in `tonic-types`, we decode
25//! the `google.rpc.Status` envelope with a local prost mirror ([`RpcStatus`])
26//! and match the detail `Any` by its `type.googleapis.com/heddle.api.v1alpha1.*`
27//! type URL.
28
29use api::heddle::api::v1alpha1::{
30    ConflictDetail, CursorFailure, StreamFailure, cursor_failure::Reason as CursorReason,
31};
32
33use crate::exit::HeddleExitCode;
34
35/// Local prost mirror of `google.rpc.Status` (the shape tonic packs into the
36/// `grpc-status-details-bin` trailer). We only need to reach `details`; the
37/// duplicated `code`/`message` are ignored (the outer [`tonic::Status`] owns
38/// the authoritative pair).
39#[derive(Clone, PartialEq, prost::Message)]
40struct RpcStatus {
41    #[prost(int32, tag = "1")]
42    code: i32,
43    #[prost(string, tag = "2")]
44    message: prost::alloc::string::String,
45    #[prost(message, repeated, tag = "3")]
46    details: prost::alloc::vec::Vec<prost_types::Any>,
47}
48
49fn heddle_type_url(message_name: &str) -> String {
50    format!("type.googleapis.com/heddle.api.v1alpha1.{message_name}")
51}
52
53fn decode_first_detail<T: prost::Message + Default>(
54    status: &tonic::Status,
55    message_name: &str,
56) -> Option<T> {
57    // Fully-qualified so no `use prost::Message` import is needed (rustc
58    // versions disagree on whether the bare `RpcStatus::decode` path requires
59    // the trait in scope; the qualified form compiles cleanly on all of them).
60    let rpc = <RpcStatus as prost::Message>::decode(status.details()).ok()?;
61    let expected = heddle_type_url(message_name);
62    rpc.details
63        .into_iter()
64        .find(|any| any.type_url == expected)
65        .and_then(|any| <T as prost::Message>::decode(any.value.as_slice()).ok())
66}
67
68/// A hosted typed error the CLI knows how to render + classify. Built from a
69/// [`tonic::Status`] via [`HostedTypedError::from_status`]; the first matching
70/// detail wins (a status carries at most one of these in practice).
71#[derive(Debug, Clone, PartialEq)]
72pub enum HostedTypedError {
73    /// An operation-id reuse or ref compare-and-set conflict.
74    Conflict(ConflictDetail),
75    /// An invalid/stale/expired pagination or stream cursor.
76    Cursor(CursorFailure),
77    /// A streaming RPC failed mid-flight.
78    Stream(StreamFailure),
79}
80
81impl HostedTypedError {
82    /// Decode the first conflict/cursor/stream detail carried on `status`, if
83    /// any. Returns `None` for a plain status (string-only path unaffected).
84    pub fn from_status(status: &tonic::Status) -> Option<Self> {
85        if let Some(detail) = decode_first_detail::<ConflictDetail>(status, "ConflictDetail") {
86            return Some(Self::Conflict(detail));
87        }
88        if let Some(detail) = decode_first_detail::<CursorFailure>(status, "CursorFailure") {
89            return Some(Self::Cursor(detail));
90        }
91        if let Some(detail) = decode_first_detail::<StreamFailure>(status, "StreamFailure") {
92            return Some(Self::Stream(detail));
93        }
94        None
95    }
96
97    /// Stable envelope `kind` discriminator (keyed on, never the message text).
98    pub fn kind(&self) -> &'static str {
99        match self {
100            Self::Conflict(_) => "hosted_conflict",
101            Self::Cursor(_) => "cursor_invalid",
102            Self::Stream(_) => "stream_failure",
103        }
104    }
105
106    /// Exit-code override for this typed failure, or `None` to fall through to
107    /// the bare gRPC-code mapping.
108    ///
109    /// A cursor failure is a safe restart (`TempFail`); a conflict needs a
110    /// changed input (`Protocol`). A `StreamFailure`, however, wraps EVERY
111    /// mid-flight stream error — including a `PermissionDenied` / `NotFound` /
112    /// `InvalidArgument` surfaced from inside the stream — so it is only a
113    /// safe-retry when it is *genuinely* retryable (carries retry advice or a
114    /// restart cursor, or its underlying gRPC code is transient). Otherwise we
115    /// return `None` so the caller uses the bare code (PermissionDenied→NoPerm,
116    /// NotFound→NoInput, InvalidArgument→Protocol) rather than telling an agent
117    /// to hot-loop a permission/validation gate as "safe to retry".
118    pub fn exit_code(&self) -> Option<HeddleExitCode> {
119        match self {
120            Self::Conflict(_) => Some(HeddleExitCode::Protocol),
121            Self::Cursor(_) => Some(HeddleExitCode::TempFail),
122            Self::Stream(detail) if stream_is_retryable(detail) => Some(HeddleExitCode::TempFail),
123            Self::Stream(_) => None,
124        }
125    }
126
127    /// Human-readable, one-line recovery hint.
128    pub fn hint(&self) -> String {
129        match self {
130            Self::Conflict(detail) => {
131                let resource = if detail.resource.is_empty() {
132                    "the requested resource".to_string()
133                } else {
134                    format!("`{}`", detail.resource)
135                };
136                format!(
137                    "The server rejected a conflict on {resource}. If you supplied --op-id, retry \
138                     with a fresh operation id; for a ref conflict, fetch the latest state and retry."
139                )
140            }
141            Self::Cursor(detail) => {
142                if detail.restart_cursor.is_empty() {
143                    "The pagination cursor is invalid or expired; restart the listing from the \
144                     beginning (omit the cursor)."
145                        .to_string()
146                } else {
147                    format!(
148                        "The pagination cursor is invalid or expired; restart from the \
149                         `restart_cursor` value (`{}`).",
150                        detail.restart_cursor
151                    )
152                }
153            }
154            Self::Stream(detail) => {
155                if let Some(cursor) = detail
156                    .cursor
157                    .as_ref()
158                    .filter(|c| !c.restart_cursor.is_empty())
159                {
160                    format!(
161                        "The stream failed mid-flight; restart it from the `restart_cursor` value \
162                         (`{}`).",
163                        cursor.restart_cursor
164                    )
165                } else if let Some(secs) = stream_retry_after_secs(detail) {
166                    format!(
167                        "The stream failed mid-flight but is resumable; retry after {secs}s."
168                    )
169                } else if stream_is_retryable(detail) {
170                    "The stream failed mid-flight; restart it from the beginning.".to_string()
171                } else {
172                    "The stream failed mid-flight on a terminal error; do not blindly retry — \
173                     inspect the underlying status and fix the cause first."
174                        .to_string()
175                }
176            }
177        }
178    }
179
180    /// Structured fields to merge into the JSON envelope so an agent can branch
181    /// without parsing the hint prose.
182    pub fn extra_json_fields(&self) -> serde_json::Map<String, serde_json::Value> {
183        use serde_json::Value;
184        let mut fields = serde_json::Map::new();
185        match self {
186            Self::Conflict(detail) => {
187                fields.insert("conflict_resource".into(), Value::String(detail.resource.clone()));
188                if !detail.expected_version.is_empty() {
189                    fields.insert(
190                        "conflict_expected_version".into(),
191                        Value::String(detail.expected_version.clone()),
192                    );
193                }
194                if !detail.actual_version.is_empty() {
195                    fields.insert(
196                        "conflict_actual_version".into(),
197                        Value::String(detail.actual_version.clone()),
198                    );
199                }
200            }
201            Self::Cursor(detail) => {
202                fields.insert(
203                    "cursor_reason".into(),
204                    Value::String(cursor_reason_str(detail.reason).to_string()),
205                );
206                fields.insert(
207                    "restart_cursor".into(),
208                    Value::String(detail.restart_cursor.clone()),
209                );
210            }
211            Self::Stream(detail) => {
212                fields.insert("stream_grpc_code".into(), Value::Number(detail.grpc_code.into()));
213                let restart_cursor = detail
214                    .cursor
215                    .as_ref()
216                    .map(|c| c.restart_cursor.clone())
217                    .filter(|c| !c.is_empty());
218                let retry_secs = stream_retry_after_secs(detail);
219                // "resumable" = the server told us how to continue (a resume
220                // cursor or a retry delay); otherwise the client must restart.
221                let resumable = restart_cursor.is_some() || retry_secs.is_some();
222                fields.insert("stream_resumable".into(), Value::Bool(resumable));
223                if let Some(cursor) = restart_cursor {
224                    fields.insert("restart_cursor".into(), Value::String(cursor));
225                }
226                if let Some(secs) = retry_secs {
227                    fields.insert("retry_after_secs".into(), Value::Number(secs.into()));
228                }
229            }
230        }
231        fields
232    }
233}
234
235fn stream_retry_after_secs(detail: &StreamFailure) -> Option<i64> {
236    detail
237        .retry
238        .as_ref()
239        .and_then(|r| r.retry_after.as_ref())
240        .map(|d| d.seconds)
241        .filter(|secs| *secs > 0)
242}
243
244/// Is a `StreamFailure` genuinely safe to retry by restarting? True when it
245/// carries retry advice or a restart cursor, or its underlying gRPC code is a
246/// transient/retryable class. A stream that merely wraps a terminal
247/// PermissionDenied / NotFound / InvalidArgument is NOT safe-retry.
248fn stream_is_retryable(detail: &StreamFailure) -> bool {
249    use tonic::Code;
250    if detail.retry.is_some() {
251        return true;
252    }
253    if detail
254        .cursor
255        .as_ref()
256        .is_some_and(|c| !c.restart_cursor.is_empty())
257    {
258        return true;
259    }
260    matches!(
261        Code::from_i32(detail.grpc_code),
262        Code::Unavailable
263            | Code::DeadlineExceeded
264            | Code::ResourceExhausted
265            | Code::Aborted
266            | Code::Internal
267            | Code::Unknown
268            | Code::Cancelled
269    )
270}
271
272fn cursor_reason_str(reason: i32) -> &'static str {
273    match CursorReason::try_from(reason) {
274        Ok(CursorReason::Stale) => "stale",
275        Ok(CursorReason::Expired) => "expired",
276        _ => "unspecified",
277    }
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283    use api::heddle::api::v1alpha1::RetryAdvice;
284    use prost::Message as _;
285    use tonic::{Code, Status};
286
287    /// Build a `tonic::Status` carrying `detail` as a `google.rpc.Status`
288    /// detail, exactly as the weft server's `typed_error` builders do.
289    fn status_with_detail<T: prost::Message>(
290        code: Code,
291        message: &str,
292        message_name: &str,
293        detail: &T,
294    ) -> Status {
295        let any = prost_types::Any {
296            type_url: heddle_type_url(message_name),
297            value: detail.encode_to_vec(),
298        };
299        let rpc = RpcStatus {
300            code: code as i32,
301            message: message.to_string(),
302            details: vec![any],
303        };
304        Status::with_details(code, message, rpc.encode_to_vec().into())
305    }
306
307    #[test]
308    fn conflict_detail_decodes_and_classifies_as_protocol() {
309        let status = status_with_detail(
310            Code::AlreadyExists,
311            "thread 'refs/threads/main' already exists",
312            "ConflictDetail",
313            &ConflictDetail {
314                resource: "thread 'refs/threads/main' already exists".to_string(),
315                expected_version: String::new(),
316                actual_version: String::new(),
317            },
318        );
319        let typed = HostedTypedError::from_status(&status).expect("conflict detail decodes");
320        assert_eq!(typed.kind(), "hosted_conflict");
321        assert_eq!(typed.exit_code(), Some(HeddleExitCode::Protocol));
322        assert!(typed.hint().contains("conflict"));
323        let fields = typed.extra_json_fields();
324        assert!(fields["conflict_resource"].as_str().unwrap().contains("refs/threads/main"));
325    }
326
327    #[test]
328    fn cursor_failure_decodes_and_is_safe_retry() {
329        let status = status_with_detail(
330            Code::InvalidArgument,
331            "invalid cursor",
332            "CursorFailure",
333            &CursorFailure {
334                reason: CursorReason::Stale as i32,
335                expired_at: None,
336                restart_cursor: String::new(),
337            },
338        );
339        let typed = HostedTypedError::from_status(&status).expect("cursor detail decodes");
340        assert_eq!(typed.kind(), "cursor_invalid");
341        // A cursor restart is safe-retry — 75, NOT the InvalidArgument→Protocol
342        // default the bare status code would otherwise map to.
343        assert_eq!(typed.exit_code(), Some(HeddleExitCode::TempFail));
344        let fields = typed.extra_json_fields();
345        assert_eq!(fields["cursor_reason"], "stale");
346        assert_eq!(fields["restart_cursor"], "");
347    }
348
349    #[test]
350    fn stream_failure_resume_vs_restart() {
351        // No retry, no cursor → restart.
352        let restart = status_with_detail(
353            Code::Unavailable,
354            "pull stream aborted",
355            "StreamFailure",
356            &StreamFailure {
357                grpc_code: Code::Unavailable as i32,
358                message: "pull stream aborted".to_string(),
359                retry: None,
360                cursor: None,
361            },
362        );
363        let typed = HostedTypedError::from_status(&restart).expect("stream detail decodes");
364        assert_eq!(typed.kind(), "stream_failure");
365        assert_eq!(typed.exit_code(), Some(HeddleExitCode::TempFail));
366        assert!(typed.hint().contains("restart"));
367        assert_eq!(typed.extra_json_fields()["stream_resumable"], serde_json::Value::Bool(false));
368
369        // Retry advice present → resumable after N seconds.
370        let resumable = status_with_detail(
371            Code::Unavailable,
372            "pull stream aborted",
373            "StreamFailure",
374            &StreamFailure {
375                grpc_code: Code::Unavailable as i32,
376                message: "pull stream aborted".to_string(),
377                retry: Some(RetryAdvice {
378                    retry_after: Some(prost_types::Duration { seconds: 3, nanos: 0 }),
379                }),
380                cursor: None,
381            },
382        );
383        let typed = HostedTypedError::from_status(&resumable).expect("stream detail decodes");
384        assert!(typed.hint().contains("resumable"));
385        let fields = typed.extra_json_fields();
386        assert_eq!(fields["stream_resumable"], serde_json::Value::Bool(true));
387        assert_eq!(fields["retry_after_secs"], serde_json::Value::Number(3.into()));
388    }
389
390    #[test]
391    fn stream_failure_wrapping_terminal_error_is_not_safe_retry() {
392        // A StreamFailure wraps EVERY mid-stream Err, including a terminal
393        // PermissionDenied surfaced from inside the stream. It must NOT be
394        // reclassified as safe-retry (75) — exit_code() returns None so the
395        // caller falls through to the bare code (PermissionDenied→NoPerm), and
396        // the hint must not tell an agent to restart a policy gate.
397        let denied = status_with_detail(
398            Code::PermissionDenied,
399            "access denied",
400            "StreamFailure",
401            &StreamFailure {
402                grpc_code: Code::PermissionDenied as i32,
403                message: "access denied".to_string(),
404                retry: None,
405                cursor: None,
406            },
407        );
408        let typed = HostedTypedError::from_status(&denied).expect("stream detail decodes");
409        assert_eq!(typed.kind(), "stream_failure");
410        assert_eq!(typed.exit_code(), None); // fall through to bare PermissionDenied→NoPerm
411        assert!(!typed.hint().contains("restart"));
412        assert!(typed.hint().contains("do not blindly retry"));
413    }
414
415    #[test]
416    fn plain_status_has_no_typed_error() {
417        let status = Status::failed_precondition("no details here");
418        assert!(HostedTypedError::from_status(&status).is_none());
419    }
420}