This is an automated email from the ASF dual-hosted git repository.

dongjoon-hyun pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/spark-connect-swift.git


The following commit(s) were added to refs/heads/main by this push:
     new 71f2d3b  [SPARK-58563] Support server-side error chain in 
`SparkConnectError` via `FetchErrorDetails`
71f2d3b is described below

commit 71f2d3b82107efe2f01a790541d245449cb77fc9
Author: Dongjoon Hyun <[email protected]>
AuthorDate: Tue Aug 4 09:29:40 2026 -0700

    [SPARK-58563] Support server-side error chain in `SparkConnectError` via 
`FetchErrorDetails`
    
    ### What changes were proposed in this pull request?
    
    This PR improves `SparkConnectError` to carry the server-side error chain 
by calling the
    `FetchErrorDetails` RPC, like the PySpark (`_fetch_enriched_error`) and 
Scala
    (`GrpcExceptionConverter.fetchEnrichedError`) clients.
    
    1. A new `SparkConnectError.ServerError` struct carries the un-truncated 
error message, the
       exception class hierarchy, the server-side stack trace frames, and the 
per-error
       `errorClass`/`sqlState`/`messageParameters`. `SparkConnectError.Details` 
gains a `serverErrors`
       array (the thrown error first, followed by the cause chain flattened via 
`causeIdx`) and a
       `serverStackTrace` convenience property that renders the chain in the JVM
       `Caused by:`/`at ...` style.
    2. When the `google.rpc.ErrorInfo` metadata contains an `errorId`, 
`GrpcErrorConverter` sends one
       `FetchErrorDetails` request over the already-open gRPC connection. The 
call is best-effort
       at-most-once without retries, and any failure silently falls back to the 
previous
       `ErrorInfo`-only conversion so that the original error is never masked.
    3. The `errorClass` and `sqlState` from `ErrorInfo` take precedence to keep 
the error
       classification stable, while the truncated `message` and 
`messageParameters` are replaced by
       the un-truncated values of the root error.
    
    The server-side stack trace is populated only when the SQL configuration
    `spark.sql.connect.serverStacktrace.enabled` is true, like the other 
clients.
    
    ```swift
    } catch SparkConnectError.tableOrViewNotFound(let details) {
      print(details.serverErrors.first?.errorTypeHierarchy ?? [])
      // ["org.apache.spark.sql.catalyst.ExtendedAnalysisException",
      //  "org.apache.spark.sql.AnalysisException", ...]
      print(details.serverStackTrace ?? "")
    }
    ```
    
    ### Why are the changes needed?
    
    Users could not access the server-side stack trace, the cause chain, or the 
un-truncated error
    message from a caught error although the server provides them via the 
`FetchErrorDetails` RPC.
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes, `SparkConnectError` additionally provides the server-side error chain. 
The existing code
    continues to compile and behave the same because the new 
`Details.serverErrors` field has a
    default value and the error classification is unchanged.
    
    ### How was this patch tested?
    
    Pass the CIs with the newly added `serverErrors` and `serverStackTrace` 
tests in
    `SparkConnectErrorTests`. All existing test suites pass unmodified.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Fable 5
    
    Closes #479 from dongjoon-hyun/SPARK-58563.
    
    Authored-by: Dongjoon Hyun <[email protected]>
    Signed-off-by: Dongjoon Hyun <[email protected]>
---
 Sources/SparkConnect/DataFrame.swift               |   4 +-
 Sources/SparkConnect/GrpcErrorConverter.swift      | 124 +++++++++++++++++++--
 Sources/SparkConnect/SparkConnectClient.swift      |   4 +-
 Sources/SparkConnect/SparkConnectError.swift       |  65 ++++++++++-
 Sources/SparkConnect/TypeAliases.swift             |   2 +
 .../SparkConnectTests/SparkConnectErrorTests.swift |  32 ++++++
 6 files changed, 216 insertions(+), 15 deletions(-)

diff --git a/Sources/SparkConnect/DataFrame.swift 
b/Sources/SparkConnect/DataFrame.swift
index 26ee5cd..1dfa024 100644
--- a/Sources/SparkConnect/DataFrame.swift
+++ b/Sources/SparkConnect/DataFrame.swift
@@ -318,7 +318,9 @@ public actor DataFrame: Sendable {
         do {
           return try await f(client)
         } catch let error as RPCError where error.code == .internalError {
-          throw GrpcErrorConverter.convert(error) ?? error
+          throw await GrpcErrorConverter.convert(
+            error, fetchingDetailsWith: client, sessionID: 
spark.client.sessionID,
+            userContext: spark.client.userContext, clientType: 
spark.client.clientType) ?? error
         }
       }
     }
diff --git a/Sources/SparkConnect/GrpcErrorConverter.swift 
b/Sources/SparkConnect/GrpcErrorConverter.swift
index b6cb2f4..0421a1d 100644
--- a/Sources/SparkConnect/GrpcErrorConverter.swift
+++ b/Sources/SparkConnect/GrpcErrorConverter.swift
@@ -30,28 +30,128 @@ import GRPCProtobuf
 /// It parses the `google.rpc.ErrorInfo` from the gRPC status details to 
classify errors by
 /// the Spark error class and to enrich 
``SparkConnectError/Details-swift.struct`` with
 /// the error class, SQLSTATE, and message parameters. When the status details 
are missing,
-/// it falls back to classifying by the error message.
+/// it falls back to classifying by the error message. When the `ErrorInfo` 
provides an
+/// `errorId`, it additionally fetches the server-side error chain with the 
un-truncated
+/// messages and optional stack traces by the `FetchErrorDetails` RPC.
 enum GrpcErrorConverter {
-  /// Convert an ``RPCError`` into a ``SparkConnectError``.
-  /// - Parameter error: An ``RPCError`` from the server.
+  /// Convert an ``RPCError`` into a ``SparkConnectError`` after enriching it 
with the
+  /// server-side error chain fetched by an additional `FetchErrorDetails` RPC
+  /// like 
`org.apache.spark.sql.connect.client.GrpcExceptionConverter.fetchEnrichedError`.
+  /// The RPC call is best-effort at-most-once without retries, and any 
failure silently
+  /// falls back to the un-enriched conversion in order not to mask the 
original error.
+  /// - Parameters:
+  ///   - error: An ``RPCError`` from the server.
+  ///   - client: A connected gRPC client to send the `FetchErrorDetails` 
request.
+  ///   - sessionID: A session ID string. The enrichment is skipped if nil.
+  ///   - userContext: A ``UserContext`` of the session.
+  ///   - clientType: A client type string.
   /// - Returns: A ``SparkConnectError`` or nil if unclassifiable.
-  static func convert(_ error: RPCError) -> SparkConnectError? {
-    var details = SparkConnectError.Details(message: error.message)
-    if let status = try? error.unpackGoogleRPCStatus(),
-      let info = status.details.compactMap({ $0.errorInfo }).first
+  static func convert(
+    _ error: RPCError,
+    fetchingDetailsWith client: GRPCClient<some ClientTransport>,
+    sessionID: String?,
+    userContext: UserContext,
+    clientType: String
+  ) async -> SparkConnectError? {
+    var (details, errorID) = parse(error)
+    if let errorID, let sessionID,
+      let response = await fetchErrorDetails(
+        errorID, with: client, sessionID: sessionID, userContext: userContext,
+        clientType: clientType)
     {
-      details = SparkConnectError.Details(
-        message: status.message.isEmpty ? error.message : status.message,
-        errorClass: info.metadata["errorClass"],
-        sqlState: info.metadata["sqlState"],
-        messageParameters: messageParameters(from: 
info.metadata["messageParameters"]))
+      details = enrich(details, with: response)
     }
+    return classify(details)
+  }
+
+  /// Build ``SparkConnectError/Details-swift.struct`` from the 
`google.rpc.ErrorInfo` of
+  /// the gRPC status details, together with the server-side `errorId` if 
provided.
+  private static func parse(_ error: RPCError) -> (SparkConnectError.Details, 
errorID: String?) {
+    guard let status = try? error.unpackGoogleRPCStatus(),
+      let info = status.details.compactMap({ $0.errorInfo }).first
+    else {
+      return (SparkConnectError.Details(message: error.message), nil)
+    }
+    let details = SparkConnectError.Details(
+      message: status.message.isEmpty ? error.message : status.message,
+      errorClass: info.metadata["errorClass"],
+      sqlState: info.metadata["sqlState"],
+      messageParameters: messageParameters(from: 
info.metadata["messageParameters"]))
+    return (details, info.metadata["errorId"])
+  }
+
+  /// Classify ``SparkConnectError/Details-swift.struct`` into a 
``SparkConnectError``
+  /// by the Spark error class first and by the error message as a fallback.
+  private static func classify(_ details: SparkConnectError.Details) -> 
SparkConnectError? {
     if let errorClass = details.errorClass, let converted = 
convert(errorClass, details) {
       return converted
     }
     return convert(byMessage: details.message, details)
   }
 
+  /// Send a `FetchErrorDetails` request, returning nil on any failure.
+  private static func fetchErrorDetails(
+    _ errorID: String,
+    with client: GRPCClient<some ClientTransport>,
+    sessionID: String,
+    userContext: UserContext,
+    clientType: String
+  ) async -> FetchErrorDetailsResponse? {
+    let service = SparkConnectService.Client(wrapping: client)
+    var request = FetchErrorDetailsRequest()
+    request.sessionID = sessionID
+    request.userContext = userContext
+    request.clientType = clientType
+    request.errorID = errorID
+    return try? await service.fetchErrorDetails(request)
+  }
+
+  /// Merge a `FetchErrorDetailsResponse` into 
``SparkConnectError/Details-swift.struct``.
+  /// The `errorClass` and `sqlState` from `ErrorInfo` take precedence to keep 
the
+  /// error classification stable, while the un-truncated message and the 
message
+  /// parameters of the root error replace the truncated ones.
+  private static func enrich(
+    _ details: SparkConnectError.Details, with response: 
FetchErrorDetailsResponse
+  ) -> SparkConnectError.Details {
+    guard response.hasRootErrorIdx else { return details }
+    let serverErrors = flatten(response)
+    guard let root = serverErrors.first else { return details }
+    return SparkConnectError.Details(
+      message: root.message.isEmpty ? details.message : root.message,
+      errorClass: details.errorClass ?? root.errorClass,
+      sqlState: details.sqlState ?? root.sqlState,
+      messageParameters: root.messageParameters.isEmpty
+        ? details.messageParameters : root.messageParameters,
+      serverErrors: serverErrors)
+  }
+
+  /// Flatten the `causeIdx`-linked errors of a `FetchErrorDetailsResponse` 
into an array
+  /// starting from the root error, guarding against out-of-range indices and 
cycles.
+  private static func flatten(
+    _ response: FetchErrorDetailsResponse
+  ) -> [SparkConnectError.ServerError] {
+    var serverErrors: [SparkConnectError.ServerError] = []
+    var visited = Set<Int>()
+    var index = Int(response.rootErrorIdx)
+    while response.errors.indices.contains(index), 
visited.insert(index).inserted {
+      let error = response.errors[index]
+      let throwable = error.hasSparkThrowable ? error.sparkThrowable : nil
+      serverErrors.append(
+        SparkConnectError.ServerError(
+          message: error.message,
+          errorTypeHierarchy: error.errorTypeHierarchy,
+          stackTrace: error.stackTrace.map {
+            
"\($0.declaringClass).\($0.methodName)(\($0.fileName):\($0.lineNumber))"
+          },
+          errorClass: throwable?.hasErrorClass == true ? throwable?.errorClass 
: nil,
+          sqlState: throwable?.hasSqlState == true ? throwable?.sqlState : nil,
+          messageParameters: throwable?.messageParameters ?? [:]))
+      guard error.hasCauseIdx else { break }
+      index = Int(error.causeIdx)
+    }
+    return serverErrors
+  }
+
   /// Classify by the Spark error class from `ErrorInfo`.
   private static func convert(
     _ errorClass: String, _ details: SparkConnectError.Details
diff --git a/Sources/SparkConnect/SparkConnectClient.swift 
b/Sources/SparkConnect/SparkConnectClient.swift
index 8449f4c..caf8ca9 100644
--- a/Sources/SparkConnect/SparkConnectClient.swift
+++ b/Sources/SparkConnect/SparkConnectClient.swift
@@ -147,7 +147,9 @@ public actor SparkConnectClient {
         do {
           return try await f(client)
         } catch let error as RPCError where error.code == .internalError {
-          throw GrpcErrorConverter.convert(error) ?? error
+          throw await GrpcErrorConverter.convert(
+            error, fetchingDetailsWith: client, sessionID: self.sessionID,
+            userContext: self.userContext, clientType: self.clientType) ?? 
error
         }
       }
     }
diff --git a/Sources/SparkConnect/SparkConnectError.swift 
b/Sources/SparkConnect/SparkConnectError.swift
index f20459b..5c62180 100644
--- a/Sources/SparkConnect/SparkConnectError.swift
+++ b/Sources/SparkConnect/SparkConnectError.swift
@@ -25,6 +25,40 @@
 /// Note that the equality of ``SparkConnectError`` compares only the error 
kind and
 /// ignores the attached ``SparkConnectError/Details-swift.struct``.
 public enum SparkConnectError: Error, Sendable, Equatable {
+  /// A server-side error fetched by the `FetchErrorDetails` RPC.
+  public struct ServerError: Sendable, Equatable {
+    /// The un-truncated error message.
+    public let message: String
+    /// The fully qualified names of the exception class and its parent 
classes.
+    public let errorTypeHierarchy: [String]
+    /// The server-side stack trace frames formatted as 
`declaringClass.methodName(fileName:line)`.
+    /// This is non-empty only if the SQL configuration
+    /// `spark.sql.connect.serverStacktrace.enabled` is true.
+    public let stackTrace: [String]
+    /// A Spark error class of this error if it is a `SparkThrowable`.
+    public let errorClass: String?
+    /// A SQLSTATE of this error if it is a `SparkThrowable`.
+    public let sqlState: String?
+    /// The message parameters of the error class.
+    public let messageParameters: [String: String]
+
+    public init(
+      message: String = "",
+      errorTypeHierarchy: [String] = [],
+      stackTrace: [String] = [],
+      errorClass: String? = nil,
+      sqlState: String? = nil,
+      messageParameters: [String: String] = [:]
+    ) {
+      self.message = message
+      self.errorTypeHierarchy = errorTypeHierarchy
+      self.stackTrace = stackTrace
+      self.errorClass = errorClass
+      self.sqlState = sqlState
+      self.messageParameters = messageParameters
+    }
+  }
+
   /// Detailed error information.
   public struct Details: Sendable, Equatable {
     /// An error message.
@@ -35,17 +69,40 @@ public enum SparkConnectError: Error, Sendable, Equatable {
     public let sqlState: String?
     /// The message parameters of the error class.
     public let messageParameters: [String: String]
+    /// The server-side error chain fetched by the `FetchErrorDetails` RPC.
+    /// The first element is the thrown error and the rest follow the cause 
chain in order.
+    /// This is empty if the server does not support the RPC or the fetch 
fails.
+    public let serverErrors: [ServerError]
 
     public init(
       message: String = "",
       errorClass: String? = nil,
       sqlState: String? = nil,
-      messageParameters: [String: String] = [:]
+      messageParameters: [String: String] = [:],
+      serverErrors: [ServerError] = []
     ) {
       self.message = message
       self.errorClass = errorClass
       self.sqlState = sqlState
       self.messageParameters = messageParameters
+      self.serverErrors = serverErrors
+    }
+
+    /// A multi-line string of the server-side error chain in the JVM stack 
trace style,
+    /// or nil if ``serverErrors`` is empty.
+    public var serverStackTrace: String? {
+      guard !serverErrors.isEmpty else { return nil }
+      var lines: [String] = []
+      for (index, error) in serverErrors.enumerated() {
+        let className = error.errorTypeHierarchy.first ?? ""
+        if index == 0 {
+          lines.append(className)
+        } else {
+          lines.append("Caused by: \(className): \(error.message)")
+        }
+        lines.append(contentsOf: error.stackTrace.map { "\tat \($0)" })
+      }
+      return lines.joined(separator: "\n")
     }
   }
 
@@ -119,6 +176,12 @@ public enum SparkConnectError: Error, Sendable, Equatable {
   /// The message parameters of the error class.
   public var messageParameters: [String: String] { details.messageParameters }
 
+  /// The server-side error chain fetched by the `FetchErrorDetails` RPC.
+  public var serverErrors: [ServerError] { details.serverErrors }
+
+  /// A multi-line string of the server-side error chain in the JVM stack 
trace style.
+  public var serverStackTrace: String? { details.serverStackTrace }
+
   private var caseName: String {
     switch self {
     case .catalogNotFound: return "catalogNotFound"
diff --git a/Sources/SparkConnect/TypeAliases.swift 
b/Sources/SparkConnect/TypeAliases.swift
index 1099931..712ba2c 100644
--- a/Sources/SparkConnect/TypeAliases.swift
+++ b/Sources/SparkConnect/TypeAliases.swift
@@ -32,6 +32,8 @@ typealias ExecutePlanResponse = 
Spark_Connect_ExecutePlanResponse
 typealias ExplainMode = AnalyzePlanRequest.Explain.ExplainMode
 typealias ExpressionLiteral = Spark_Connect_Expression.Literal
 typealias ExpressionString = Spark_Connect_Expression.ExpressionString
+typealias FetchErrorDetailsRequest = Spark_Connect_FetchErrorDetailsRequest
+typealias FetchErrorDetailsResponse = Spark_Connect_FetchErrorDetailsResponse
 typealias Filter = Spark_Connect_Filter
 typealias GroupType = Spark_Connect_Aggregate.GroupType
 typealias Join = Spark_Connect_Join
diff --git a/Tests/SparkConnectTests/SparkConnectErrorTests.swift 
b/Tests/SparkConnectTests/SparkConnectErrorTests.swift
index b5e3cce..cfeeaed 100644
--- a/Tests/SparkConnectTests/SparkConnectErrorTests.swift
+++ b/Tests/SparkConnectTests/SparkConnectErrorTests.swift
@@ -68,6 +68,38 @@ struct SparkConnectErrorTests {
     await spark.stop()
   }
 
+  @Test
+  func serverErrors() async throws {
+    let spark = try await SparkSession.builder.getOrCreate()
+    let error = try await #require(throws: SparkConnectError.self) {
+      try await spark.sql("SELECT * FROM 
nonexistent_table_for_error_tests").count()
+    }
+    #expect(error == .TableOrViewNotFound)
+    let root = try #require(error.serverErrors.first)
+    
#expect(root.errorTypeHierarchy.contains("org.apache.spark.sql.AnalysisException"))
+    #expect(root.message.contains("nonexistent_table_for_error_tests"))
+    #expect(root.errorClass == "TABLE_OR_VIEW_NOT_FOUND")
+    #expect(
+      
root.messageParameters["relationName"]?.contains("nonexistent_table_for_error_tests")
+        ?? false)
+    await spark.stop()
+  }
+
+  @Test
+  func serverStackTrace() async throws {
+    let spark = try await SparkSession.builder.getOrCreate()
+    try await spark.conf.set("spark.sql.connect.serverStacktrace.enabled", 
"true")
+    let error = try await #require(throws: SparkConnectError.self) {
+      try await spark.sql("SELECT * FROM 
nonexistent_table_for_error_tests").count()
+    }
+    let root = try #require(error.serverErrors.first)
+    #expect(!root.stackTrace.isEmpty)
+    let stackTrace = try #require(error.serverStackTrace)
+    #expect(stackTrace.contains("AnalysisException"))
+    #expect(stackTrace.contains("\tat org.apache.spark."))
+    await spark.stop()
+  }
+
   @Test
   func detailsPatternMatching() async throws {
     let spark = try await SparkSession.builder.getOrCreate()


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to