Skip to content

Commit b66eeed

Browse files
committed
feat: decode SpiceDB error details from gRPC status metadata
Implements decoding of google.rpc.ErrorInfo, DebugInfo, and RetryInfo from the grpc-status-details-bin metadata header. Populates SpiceDbErrorDetails with error_reason, debug_message, retry_info, and metadata fields. Gracefully falls back to None when metadata is absent or malformed. Closes #3
1 parent 1be6dd8 commit b66eeed

2 files changed

Lines changed: 332 additions & 6 deletions

File tree

.claude/settings.local.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
{
22
"permissions": {
33
"allow": [
4-
"Bash(jj new:*)"
4+
"Bash(jj new:*)",
5+
"Bash(jj describe:*)"
56
]
67
}
78
}

src/error.rs

Lines changed: 330 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,17 +17,52 @@
1717
//! | `UNAVAILABLE` | Server temporarily unavailable | Yes |
1818
//! | `DEADLINE_EXCEEDED` | Request timed out | Yes |
1919
20+
use std::collections::HashMap;
2021
use std::time::Duration;
2122

23+
use prost::Message;
24+
2225
/// Details extracted from SpiceDB-specific gRPC error metadata.
23-
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
26+
#[derive(Debug, Clone, PartialEq, Eq)]
2427
pub struct SpiceDbErrorDetails {
25-
/// SpiceDB ErrorReason enum value, if present.
28+
/// SpiceDB ErrorReason enum value, if present (e.g. `"ERROR_REASON_SCHEMA_PARSE_ERROR"`).
2629
pub error_reason: Option<String>,
2730
/// Human-readable debug information from the server.
2831
pub debug_message: Option<String>,
2932
/// Suggested retry delay, if the server provided one.
3033
pub retry_info: Option<Duration>,
34+
/// Additional metadata key-value pairs from the ErrorInfo, if present.
35+
pub metadata: HashMap<String, String>,
36+
}
37+
38+
// Google RPC detail types for decoding from google.protobuf.Any.
39+
// These are not compiled from proto since we only have status.proto in stubs.
40+
41+
/// google.rpc.ErrorInfo
42+
#[derive(Clone, PartialEq, prost::Message)]
43+
struct ErrorInfo {
44+
#[prost(string, tag = "1")]
45+
reason: String,
46+
#[prost(string, tag = "2")]
47+
domain: String,
48+
#[prost(map = "string, string", tag = "3")]
49+
metadata: HashMap<String, String>,
50+
}
51+
52+
/// google.rpc.DebugInfo
53+
#[derive(Clone, PartialEq, prost::Message)]
54+
struct DebugInfo {
55+
#[prost(string, repeated, tag = "1")]
56+
stack_entries: Vec<String>,
57+
#[prost(string, tag = "2")]
58+
detail: String,
59+
}
60+
61+
/// google.rpc.RetryInfo
62+
#[derive(Clone, PartialEq, prost::Message)]
63+
struct RetryInfo {
64+
#[prost(message, optional, tag = "1")]
65+
retry_delay: Option<prost_types::Duration>,
3166
}
3267

3368
/// Errors returned by the Prescience SpiceDB client.
@@ -51,7 +86,7 @@ pub enum Error {
5186
/// Human-readable error message from the server.
5287
message: String,
5388
/// Decoded SpiceDB-specific error details, if available.
54-
details: Option<SpiceDbErrorDetails>,
89+
details: Option<Box<SpiceDbErrorDetails>>,
5590
},
5691

5792
/// Local validation failures before a request is sent.
@@ -99,11 +134,301 @@ impl Error {
99134
}
100135

101136
pub(crate) fn from_status(status: tonic::Status) -> Self {
102-
// TODO: decode SpiceDB-specific error details from status metadata
137+
let details = Self::decode_details(&status);
103138
Error::Status {
104139
code: status.code(),
105140
message: status.message().to_string(),
106-
details: None,
141+
details,
142+
}
143+
}
144+
145+
/// Attempts to decode SpiceDB error details from the `grpc-status-details-bin`
146+
/// metadata header. Returns `None` if the header is absent or cannot be decoded.
147+
fn decode_details(status: &tonic::Status) -> Option<Box<SpiceDbErrorDetails>> {
148+
let bin = status
149+
.metadata()
150+
.get_bin("grpc-status-details-bin")?
151+
.to_bytes()
152+
.ok()?;
153+
154+
let rpc_status = crate::proto::google::rpc::Status::decode(bin.as_ref()).ok()?;
155+
156+
let mut error_reason = None;
157+
let mut debug_message = None;
158+
let mut retry_info = None;
159+
let mut metadata = HashMap::new();
160+
161+
for any in &rpc_status.details {
162+
match any.type_url.as_str() {
163+
"type.googleapis.com/google.rpc.ErrorInfo" => {
164+
if let Ok(info) = ErrorInfo::decode(any.value.as_ref()) {
165+
if !info.reason.is_empty() {
166+
error_reason = Some(info.reason);
167+
}
168+
metadata = info.metadata;
169+
}
170+
}
171+
"type.googleapis.com/google.rpc.DebugInfo" => {
172+
if let Ok(info) = DebugInfo::decode(any.value.as_ref()) {
173+
if !info.detail.is_empty() {
174+
debug_message = Some(info.detail);
175+
}
176+
}
177+
}
178+
"type.googleapis.com/google.rpc.RetryInfo" => {
179+
if let Ok(info) = RetryInfo::decode(any.value.as_ref()) {
180+
if let Some(delay) = info.retry_delay {
181+
let duration = Duration::new(
182+
delay.seconds.max(0) as u64,
183+
delay.nanos.max(0) as u32,
184+
);
185+
if !duration.is_zero() {
186+
retry_info = Some(duration);
187+
}
188+
}
189+
}
190+
}
191+
_ => {} // ignore unknown detail types
192+
}
193+
}
194+
195+
// Only return Some if at least one field was populated
196+
if error_reason.is_some()
197+
|| debug_message.is_some()
198+
|| retry_info.is_some()
199+
|| !metadata.is_empty()
200+
{
201+
Some(Box::new(SpiceDbErrorDetails {
202+
error_reason,
203+
debug_message,
204+
retry_info,
205+
metadata,
206+
}))
207+
} else {
208+
None
209+
}
210+
}
211+
}
212+
213+
#[cfg(test)]
214+
mod tests {
215+
use super::*;
216+
217+
/// Helper: build a tonic::Status with encoded google.rpc.Status details.
218+
fn status_with_details(
219+
code: tonic::Code,
220+
message: &str,
221+
details: Vec<prost_types::Any>,
222+
) -> tonic::Status {
223+
let rpc_status = crate::proto::google::rpc::Status {
224+
code: code as i32,
225+
message: message.to_string(),
226+
details,
227+
};
228+
let mut buf = Vec::new();
229+
rpc_status.encode(&mut buf).unwrap();
230+
231+
let mut status = tonic::Status::new(code, message);
232+
status.metadata_mut().insert_bin(
233+
"grpc-status-details-bin",
234+
tonic::metadata::MetadataValue::from_bytes(&buf),
235+
);
236+
status
237+
}
238+
239+
fn encode_any<M: Message>(type_url: &str, msg: &M) -> prost_types::Any {
240+
prost_types::Any {
241+
type_url: type_url.to_string(),
242+
value: msg.encode_to_vec(),
243+
}
244+
}
245+
246+
#[test]
247+
fn from_status_without_details() {
248+
let status = tonic::Status::not_found("thing not found");
249+
let err = Error::from_status(status);
250+
match &err {
251+
Error::Status {
252+
code,
253+
message,
254+
details,
255+
} => {
256+
assert_eq!(*code, tonic::Code::NotFound);
257+
assert_eq!(message, "thing not found");
258+
assert!(details.is_none());
259+
}
260+
_ => panic!("expected Status variant"),
261+
}
262+
}
263+
264+
#[test]
265+
fn from_status_with_error_info() {
266+
let error_info = ErrorInfo {
267+
reason: "ERROR_REASON_SCHEMA_PARSE_ERROR".to_string(),
268+
domain: "authzed.com".to_string(),
269+
metadata: HashMap::from([
270+
("start_line_number".to_string(), "1".to_string()),
271+
("source_code".to_string(), "bad_def".to_string()),
272+
]),
273+
};
274+
let status = status_with_details(
275+
tonic::Code::InvalidArgument,
276+
"schema parse error",
277+
vec![encode_any(
278+
"type.googleapis.com/google.rpc.ErrorInfo",
279+
&error_info,
280+
)],
281+
);
282+
283+
let err = Error::from_status(status);
284+
match &err {
285+
Error::Status { details, .. } => {
286+
let d = details.as_ref().expect("should have details");
287+
assert_eq!(
288+
d.error_reason.as_deref(),
289+
Some("ERROR_REASON_SCHEMA_PARSE_ERROR")
290+
);
291+
assert_eq!(d.metadata.get("start_line_number").unwrap(), "1");
292+
assert_eq!(d.metadata.get("source_code").unwrap(), "bad_def");
293+
assert!(d.debug_message.is_none());
294+
assert!(d.retry_info.is_none());
295+
}
296+
_ => panic!("expected Status variant"),
297+
}
298+
}
299+
300+
#[test]
301+
fn from_status_with_debug_info() {
302+
let debug_info = DebugInfo {
303+
stack_entries: vec!["frame1".into()],
304+
detail: "something went wrong internally".to_string(),
305+
};
306+
let status = status_with_details(
307+
tonic::Code::Internal,
308+
"internal",
309+
vec![encode_any(
310+
"type.googleapis.com/google.rpc.DebugInfo",
311+
&debug_info,
312+
)],
313+
);
314+
315+
let err = Error::from_status(status);
316+
match &err {
317+
Error::Status { details, .. } => {
318+
let d = details.as_ref().expect("should have details");
319+
assert_eq!(
320+
d.debug_message.as_deref(),
321+
Some("something went wrong internally")
322+
);
323+
assert!(d.error_reason.is_none());
324+
}
325+
_ => panic!("expected Status variant"),
326+
}
327+
}
328+
329+
#[test]
330+
fn from_status_with_retry_info() {
331+
let retry_info = RetryInfo {
332+
retry_delay: Some(prost_types::Duration {
333+
seconds: 5,
334+
nanos: 500_000_000,
335+
}),
336+
};
337+
let status = status_with_details(
338+
tonic::Code::Unavailable,
339+
"temporarily unavailable",
340+
vec![encode_any(
341+
"type.googleapis.com/google.rpc.RetryInfo",
342+
&retry_info,
343+
)],
344+
);
345+
346+
let err = Error::from_status(status);
347+
match &err {
348+
Error::Status { details, .. } => {
349+
let d = details.as_ref().expect("should have details");
350+
assert_eq!(d.retry_info, Some(Duration::new(5, 500_000_000)));
351+
}
352+
_ => panic!("expected Status variant"),
353+
}
354+
}
355+
356+
#[test]
357+
fn from_status_with_all_detail_types() {
358+
let error_info = ErrorInfo {
359+
reason: "ERROR_REASON_UNKNOWN_DEFINITION".to_string(),
360+
domain: "authzed.com".to_string(),
361+
metadata: HashMap::from([("definition_name".to_string(), "user".to_string())]),
362+
};
363+
let debug_info = DebugInfo {
364+
stack_entries: vec![],
365+
detail: "debug trace".to_string(),
366+
};
367+
let retry_info = RetryInfo {
368+
retry_delay: Some(prost_types::Duration {
369+
seconds: 2,
370+
nanos: 0,
371+
}),
372+
};
373+
let status = status_with_details(
374+
tonic::Code::InvalidArgument,
375+
"bad request",
376+
vec![
377+
encode_any("type.googleapis.com/google.rpc.ErrorInfo", &error_info),
378+
encode_any("type.googleapis.com/google.rpc.DebugInfo", &debug_info),
379+
encode_any("type.googleapis.com/google.rpc.RetryInfo", &retry_info),
380+
],
381+
);
382+
383+
let err = Error::from_status(status);
384+
match &err {
385+
Error::Status { details, .. } => {
386+
let d = details.as_ref().expect("should have details");
387+
assert_eq!(
388+
d.error_reason.as_deref(),
389+
Some("ERROR_REASON_UNKNOWN_DEFINITION")
390+
);
391+
assert_eq!(d.debug_message.as_deref(), Some("debug trace"));
392+
assert_eq!(d.retry_info, Some(Duration::from_secs(2)));
393+
assert_eq!(d.metadata.get("definition_name").unwrap(), "user");
394+
}
395+
_ => panic!("expected Status variant"),
396+
}
397+
}
398+
399+
#[test]
400+
fn from_status_with_malformed_details_bin() {
401+
let mut status = tonic::Status::new(tonic::Code::Internal, "broken");
402+
status.metadata_mut().insert_bin(
403+
"grpc-status-details-bin",
404+
tonic::metadata::MetadataValue::from_bytes(b"not valid protobuf"),
405+
);
406+
let err = Error::from_status(status);
407+
match &err {
408+
Error::Status { details, .. } => {
409+
assert!(details.is_none(), "malformed bytes should yield None");
410+
}
411+
_ => panic!("expected Status variant"),
412+
}
413+
}
414+
415+
#[test]
416+
fn from_status_ignores_unknown_any_types() {
417+
let unknown = prost_types::Any {
418+
type_url: "type.googleapis.com/some.Unknown".to_string(),
419+
value: vec![1, 2, 3],
420+
};
421+
let status = status_with_details(
422+
tonic::Code::Internal,
423+
"with unknown",
424+
vec![unknown],
425+
);
426+
let err = Error::from_status(status);
427+
match &err {
428+
Error::Status { details, .. } => {
429+
assert!(details.is_none(), "unknown types only should yield None");
430+
}
431+
_ => panic!("expected Status variant"),
107432
}
108433
}
109434
}

0 commit comments

Comments
 (0)