1use api::heddle::api::v1alpha1::{
30 ConflictDetail, CursorFailure, StreamFailure, cursor_failure::Reason as CursorReason,
31};
32
33use crate::exit::HeddleExitCode;
34
35#[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 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#[derive(Debug, Clone, PartialEq)]
72pub enum HostedTypedError {
73 Conflict(ConflictDetail),
75 Cursor(CursorFailure),
77 Stream(StreamFailure),
79}
80
81impl HostedTypedError {
82 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 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 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 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 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 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
244fn 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 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 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 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 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 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); 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}