@@ -18,6 +18,7 @@ use iceberg::transaction::{ApplyTransactionAction, Transaction};
1818use iceberg:: { Catalog , ErrorKind , TableCreation , TableIdent } ;
1919use iceberg_catalog_rest:: { RestCatalog , RestCatalogConfig } ;
2020use itertools:: Itertools ;
21+ use regex:: Regex ;
2122use sha2:: { Digest , Sha256 } ;
2223use std:: collections:: HashMap ;
2324use std:: error:: Error ;
@@ -77,25 +78,57 @@ fn transaction_id(task_info: &TaskInfo, epoch: u32, table_uuid: Uuid) -> String
7778 format ! ( "tx-{}" , to_hex( short) )
7879}
7980
81+ fn extract_catalog_status ( display : & str ) -> Option < u16 > {
82+ let re = Regex :: new ( r"status: (\d{3})" ) . unwrap ( ) ;
83+ re. captures ( display) . and_then ( |c| c[ 1 ] . parse ( ) . ok ( ) )
84+ }
85+
86+ fn extract_catalog_message ( display : & str ) -> Option < String > {
87+ let re = Regex :: new ( r#""message"\s*:\s*"([^"]+)""# ) . unwrap ( ) ;
88+ re. captures ( display) . map ( |c| c[ 1 ] . to_string ( ) )
89+ }
90+
8091fn map_iceberg_error ( error : iceberg:: Error ) -> DataflowError {
8192 let ( domain, msg) = match error. kind ( ) {
8293 ErrorKind :: PreconditionFailed => ( ErrorDomain :: Internal , error. to_string ( ) ) ,
8394 ErrorKind :: DataInvalid | ErrorKind :: NamespaceNotFound | ErrorKind :: TableNotFound => {
8495 ( ErrorDomain :: User , error. to_string ( ) )
8596 }
8697 ErrorKind :: Unexpected => {
87- if let Some ( source) = error. source ( ) {
88- if let Some ( err) = source. downcast_ref :: < reqwest:: Error > ( ) {
89- match err. status ( ) . map ( |c| c. as_u16 ( ) ) {
90- Some ( 401 ) | Some ( 403 ) | Some ( 404 ) => ( ErrorDomain :: User , err. to_string ( ) ) ,
91- _ => ( ErrorDomain :: External , err. to_string ( ) ) ,
98+ // For Unexpected errors from iceberg-catalog-rest, the HTTP status and response
99+ // body are embedded in the error's Display output as context fields. Extract the
100+ // status to determine the domain, and try to pull out a clean error message.
101+ let display = error. to_string ( ) ;
102+ let status = extract_catalog_status ( & display) ;
103+
104+ let domain = match status {
105+ Some ( 400 ..=499 ) => ErrorDomain :: User ,
106+ Some ( _) => ErrorDomain :: External ,
107+ // No status found — could be a transport error or something else
108+ None => {
109+ if let Some ( source) = error. source ( ) {
110+ if let Some ( err) = source. downcast_ref :: < reqwest:: Error > ( ) {
111+ if err. status ( ) . map ( |c| c. is_client_error ( ) ) . unwrap_or ( false ) {
112+ ErrorDomain :: User
113+ } else {
114+ ErrorDomain :: External
115+ }
116+ } else {
117+ ErrorDomain :: External
118+ }
119+ } else {
120+ ErrorDomain :: External
92121 }
93- } else {
94- ( ErrorDomain :: External , source. to_string ( ) )
95122 }
123+ } ;
124+
125+ let msg = if let Some ( catalog_msg) = extract_catalog_message ( & display) {
126+ format ! ( "Iceberg catalog error: {catalog_msg}" )
96127 } else {
97- ( ErrorDomain :: External , error. to_string ( ) )
98- }
128+ format ! ( "Iceberg catalog error: {}" , error. message( ) )
129+ } ;
130+
131+ ( domain, msg)
99132 }
100133 _ => ( ErrorDomain :: External , error. to_string ( ) ) ,
101134 } ;
@@ -349,3 +382,93 @@ impl IcebergTable {
349382 Ok ( ( ) )
350383 }
351384}
385+
386+ #[ cfg( test) ]
387+ mod tests {
388+ use super :: * ;
389+
390+ #[ test]
391+ fn test_map_iceberg_error_catalog_424 ( ) {
392+ let error = iceberg:: Error :: new (
393+ ErrorKind :: Unexpected ,
394+ "Received response with unexpected status code" ,
395+ )
396+ . with_context ( "status" , "424 Failed Dependency" )
397+ . with_context ( "headers" , r#"{"date": "Tue, 24 Feb 2026 17:27:50 GMT"}"# )
398+ . with_context (
399+ "json" ,
400+ r#"{"error":{"message":"Failed to list files in location. Please check the storage credentials.","type":"List","code":424}}"# ,
401+ ) ;
402+
403+ match map_iceberg_error ( error) {
404+ DataflowError :: ConnectorError {
405+ domain, error : msg, ..
406+ } => {
407+ assert_eq ! ( domain, ErrorDomain :: User ) ;
408+ assert_eq ! (
409+ msg,
410+ "Iceberg catalog error: Failed to list files in location. Please check the storage credentials."
411+ ) ;
412+ }
413+ other => panic ! ( "expected ConnectorError, got: {other:?}" ) ,
414+ }
415+ }
416+
417+ #[ test]
418+ fn test_map_iceberg_error_catalog_403_bare_body ( ) {
419+ // Simulates a 403 from the catalog with a bare string body (no JSON structure)
420+ let error = iceberg:: Error :: new (
421+ ErrorKind :: Unexpected ,
422+ "Received response with unexpected status code" ,
423+ )
424+ . with_context ( "status" , "403 Forbidden" )
425+ . with_context ( "headers" , r#"{"date": "Wed, 25 Feb 2026 16:16:50 GMT"}"# )
426+ . with_context ( "json" , "Unauthenticated" ) ;
427+
428+ match map_iceberg_error ( error) {
429+ DataflowError :: ConnectorError {
430+ domain, error : msg, ..
431+ } => {
432+ assert_eq ! ( domain, ErrorDomain :: User ) ;
433+ // Falls back to error.message() since no JSON "message" field
434+ assert ! (
435+ msg. contains( "Received response with unexpected status code" ) ,
436+ "{msg}"
437+ ) ;
438+ }
439+ other => panic ! ( "expected ConnectorError, got: {other:?}" ) ,
440+ }
441+ }
442+
443+ #[ test]
444+ fn test_map_iceberg_error_catalog_500 ( ) {
445+ let error = iceberg:: Error :: new (
446+ ErrorKind :: Unexpected ,
447+ "Received response with unexpected status code" ,
448+ )
449+ . with_context ( "status" , "500 Internal Server Error" )
450+ . with_context (
451+ "json" ,
452+ r#"{"error":{"message":"Internal server error","type":"ServerError","code":500}}"# ,
453+ ) ;
454+
455+ match map_iceberg_error ( error) {
456+ DataflowError :: ConnectorError { domain, .. } => {
457+ assert_eq ! ( domain, ErrorDomain :: External ) ;
458+ }
459+ other => panic ! ( "expected ConnectorError, got: {other:?}" ) ,
460+ }
461+ }
462+
463+ #[ test]
464+ fn test_map_iceberg_error_no_status_falls_back ( ) {
465+ let error = iceberg:: Error :: new ( ErrorKind :: Unexpected , "some opaque error" ) ;
466+
467+ match map_iceberg_error ( error) {
468+ DataflowError :: ConnectorError { domain, .. } => {
469+ assert_eq ! ( domain, ErrorDomain :: External ) ;
470+ }
471+ other => panic ! ( "expected ConnectorError, got: {other:?}" ) ,
472+ }
473+ }
474+ }
0 commit comments