1717//! | `UNAVAILABLE` | Server temporarily unavailable | Yes |
1818//! | `DEADLINE_EXCEEDED` | Request timed out | Yes |
1919
20+ use std:: collections:: HashMap ;
2021use 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 ) ]
2427pub 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