feat: typed DataFusionException hierarchy across the JNI boundary (#81)
diff --git a/core/src/main/java/org/apache/datafusion/ConfigurationException.java b/core/src/main/java/org/apache/datafusion/ConfigurationException.java
new file mode 100644
index 0000000..3901f3d
--- /dev/null
+++ b/core/src/main/java/org/apache/datafusion/ConfigurationException.java
@@ -0,0 +1,37 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.datafusion;
+
+/**
+ * Invalid or unrecognised DataFusion configuration option. Surfaces upstream {@code
+ * DataFusionError::Configuration}.
+ */
+public class ConfigurationException extends DataFusionException {
+
+  private static final long serialVersionUID = 1L;
+
+  public ConfigurationException(String message) {
+    super(message);
+  }
+
+  public ConfigurationException(String message, Throwable cause) {
+    super(message, cause);
+  }
+}
diff --git a/core/src/main/java/org/apache/datafusion/DataFusionException.java b/core/src/main/java/org/apache/datafusion/DataFusionException.java
new file mode 100644
index 0000000..9ae7453
--- /dev/null
+++ b/core/src/main/java/org/apache/datafusion/DataFusionException.java
@@ -0,0 +1,45 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.datafusion;
+
+/**
+ * Base unchecked exception for every error surfaced from the native DataFusion side.
+ *
+ * <p>Concrete subclasses correspond to caller-relevant error categories (planning, execution, IO,
+ * resources, configuration, not-implemented). Variants of {@code DataFusionError} on the Rust side
+ * that don't fit a clean caller-facing category surface as the parent class itself.
+ *
+ * <p>All subclasses extend {@link RuntimeException} so existing callers that {@code catch
+ * (RuntimeException)} keep working unchanged. Callers that want to discriminate can {@code catch
+ * (PlanException)} / {@code catch (ResourcesExhaustedException)} etc., or {@code catch
+ * (DataFusionException)} for "anything from DataFusion".
+ */
+public class DataFusionException extends RuntimeException {
+
+  private static final long serialVersionUID = 1L;
+
+  public DataFusionException(String message) {
+    super(message);
+  }
+
+  public DataFusionException(String message, Throwable cause) {
+    super(message, cause);
+  }
+}
diff --git a/core/src/main/java/org/apache/datafusion/ExecutionException.java b/core/src/main/java/org/apache/datafusion/ExecutionException.java
new file mode 100644
index 0000000..a503eda
--- /dev/null
+++ b/core/src/main/java/org/apache/datafusion/ExecutionException.java
@@ -0,0 +1,42 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.datafusion;
+
+/**
+ * Runtime execution failure: a UDF threw, a join task panicked, an external (non-DataFusion) error
+ * propagated up, or an FFI-level failure surfaced. Surfaces upstream {@code
+ * DataFusionError::Execution}, {@code ExecutionJoin}, {@code External}, and {@code Ffi}.
+ *
+ * <p>Note: this is {@code org.apache.datafusion.ExecutionException}, distinct from {@code
+ * java.util.concurrent.ExecutionException}. A file that needs both should fully-qualify one or
+ * import them with care.
+ */
+public class ExecutionException extends DataFusionException {
+
+  private static final long serialVersionUID = 1L;
+
+  public ExecutionException(String message) {
+    super(message);
+  }
+
+  public ExecutionException(String message, Throwable cause) {
+    super(message, cause);
+  }
+}
diff --git a/core/src/main/java/org/apache/datafusion/IoException.java b/core/src/main/java/org/apache/datafusion/IoException.java
new file mode 100644
index 0000000..79f196e
--- /dev/null
+++ b/core/src/main/java/org/apache/datafusion/IoException.java
@@ -0,0 +1,44 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.datafusion;
+
+/**
+ * IO-shaped failure: a local filesystem read failed, an object store request failed, or a parquet /
+ * arrow / avro decoder reported a malformed file. Surfaces upstream {@code
+ * DataFusionError::IoError}, {@code ObjectStore}, {@code ArrowError}, {@code ParquetError}, and
+ * {@code AvroError}.
+ *
+ * <p>Note: this is {@code org.apache.datafusion.IoException} (lowercase {@code o}), distinct from
+ * {@code java.io.IOException}. The {@code IoException} spelling matches the orthography of the
+ * underlying {@code DataFusionError::IoError} variant; a file that needs both should fully-qualify
+ * one.
+ */
+public class IoException extends DataFusionException {
+
+  private static final long serialVersionUID = 1L;
+
+  public IoException(String message) {
+    super(message);
+  }
+
+  public IoException(String message, Throwable cause) {
+    super(message, cause);
+  }
+}
diff --git a/core/src/main/java/org/apache/datafusion/NotImplementedException.java b/core/src/main/java/org/apache/datafusion/NotImplementedException.java
new file mode 100644
index 0000000..b5d59c3
--- /dev/null
+++ b/core/src/main/java/org/apache/datafusion/NotImplementedException.java
@@ -0,0 +1,37 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.datafusion;
+
+/**
+ * The requested feature is recognised by DataFusion but not implemented yet. Surfaces upstream
+ * {@code DataFusionError::NotImplemented}.
+ */
+public class NotImplementedException extends DataFusionException {
+
+  private static final long serialVersionUID = 1L;
+
+  public NotImplementedException(String message) {
+    super(message);
+  }
+
+  public NotImplementedException(String message, Throwable cause) {
+    super(message, cause);
+  }
+}
diff --git a/core/src/main/java/org/apache/datafusion/PlanException.java b/core/src/main/java/org/apache/datafusion/PlanException.java
new file mode 100644
index 0000000..a4d9370
--- /dev/null
+++ b/core/src/main/java/org/apache/datafusion/PlanException.java
@@ -0,0 +1,37 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.datafusion;
+
+/**
+ * SQL parsing, logical planning, or schema-resolution failure. Surfaces upstream {@code
+ * DataFusionError::Plan}, {@code DataFusionError::SQL}, and {@code DataFusionError::SchemaError}.
+ */
+public class PlanException extends DataFusionException {
+
+  private static final long serialVersionUID = 1L;
+
+  public PlanException(String message) {
+    super(message);
+  }
+
+  public PlanException(String message, Throwable cause) {
+    super(message, cause);
+  }
+}
diff --git a/core/src/main/java/org/apache/datafusion/ResourcesExhaustedException.java b/core/src/main/java/org/apache/datafusion/ResourcesExhaustedException.java
new file mode 100644
index 0000000..04c91c3
--- /dev/null
+++ b/core/src/main/java/org/apache/datafusion/ResourcesExhaustedException.java
@@ -0,0 +1,40 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.datafusion;
+
+/**
+ * The DataFusion runtime exhausted a configured resource budget — typically the memory pool, but
+ * applies to any guard upstream surfaces as {@code DataFusionError::ResourcesExhausted}.
+ *
+ * <p>Distinguishing this from {@link ExecutionException} lets callers retry transient
+ * resource-pressure failures without retrying genuine query bugs.
+ */
+public class ResourcesExhaustedException extends DataFusionException {
+
+  private static final long serialVersionUID = 1L;
+
+  public ResourcesExhaustedException(String message) {
+    super(message);
+  }
+
+  public ResourcesExhaustedException(String message, Throwable cause) {
+    super(message, cause);
+  }
+}
diff --git a/core/src/test/java/org/apache/datafusion/DataFusionExceptionTest.java b/core/src/test/java/org/apache/datafusion/DataFusionExceptionTest.java
new file mode 100644
index 0000000..ca99211
--- /dev/null
+++ b/core/src/test/java/org/apache/datafusion/DataFusionExceptionTest.java
@@ -0,0 +1,84 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.datafusion;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertSame;
+
+import org.junit.jupiter.api.Test;
+
+class DataFusionExceptionTest {
+
+  @Test
+  void parentExtendsRuntimeException() {
+    DataFusionException e = new DataFusionException("x");
+    assertInstanceOf(RuntimeException.class, e);
+    assertEquals("x", e.getMessage());
+  }
+
+  @Test
+  void parentCauseConstructorPropagatesCause() {
+    Throwable cause = new IllegalStateException("inner");
+    DataFusionException e = new DataFusionException("outer", cause);
+    assertEquals("outer", e.getMessage());
+    assertSame(cause, e.getCause());
+  }
+
+  @Test
+  void allSubclassesExtendDataFusionException() {
+    // Each subclass must extend the parent so callers can `catch
+    // (DataFusionException)` for "anything from DataFusion" and `catch
+    // (RuntimeException)` keeps working unchanged.
+    assertInstanceOf(DataFusionException.class, new PlanException("x"));
+    assertInstanceOf(DataFusionException.class, new ExecutionException("x"));
+    assertInstanceOf(DataFusionException.class, new ResourcesExhaustedException("x"));
+    assertInstanceOf(DataFusionException.class, new IoException("x"));
+    assertInstanceOf(DataFusionException.class, new NotImplementedException("x"));
+    assertInstanceOf(DataFusionException.class, new ConfigurationException("x"));
+  }
+
+  @Test
+  void allSubclassesExtendRuntimeException() {
+    // Existing callers use `catch (RuntimeException)`. A typed-exception PR
+    // that breaks them is a regression even if the new types are correct.
+    assertInstanceOf(RuntimeException.class, new PlanException("x"));
+    assertInstanceOf(RuntimeException.class, new ExecutionException("x"));
+    assertInstanceOf(RuntimeException.class, new ResourcesExhaustedException("x"));
+    assertInstanceOf(RuntimeException.class, new IoException("x"));
+    assertInstanceOf(RuntimeException.class, new NotImplementedException("x"));
+    assertInstanceOf(RuntimeException.class, new ConfigurationException("x"));
+  }
+
+  @Test
+  void allSubclassesAcceptCauseConstructor() {
+    // The (String, Throwable) constructor is the seam upstream issue #55
+    // plugs into for Java-throwable propagation from JVM upcalls. v1 has no
+    // production caller for it, but it must exist on every subclass so #55
+    // doesn't have to add it later.
+    Throwable cause = new IllegalStateException("c");
+    assertSame(cause, new PlanException("m", cause).getCause());
+    assertSame(cause, new ExecutionException("m", cause).getCause());
+    assertSame(cause, new ResourcesExhaustedException("m", cause).getCause());
+    assertSame(cause, new IoException("m", cause).getCause());
+    assertSame(cause, new NotImplementedException("m", cause).getCause());
+    assertSame(cause, new ConfigurationException("m", cause).getCause());
+  }
+}
diff --git a/core/src/test/java/org/apache/datafusion/SessionContextTypedErrorTest.java b/core/src/test/java/org/apache/datafusion/SessionContextTypedErrorTest.java
new file mode 100644
index 0000000..3f6720e
--- /dev/null
+++ b/core/src/test/java/org/apache/datafusion/SessionContextTypedErrorTest.java
@@ -0,0 +1,114 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.datafusion;
+
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * End-to-end checks that real native call sites surface as the right typed Java exception. The
+ * assertions are on exception <em>type</em>, not message text — the whole point of the typed
+ * hierarchy is to free callers from message-string sniffing.
+ */
+class SessionContextTypedErrorTest {
+
+  @Test
+  void unknownTableInSqlIsPlanException() {
+    try (SessionContext ctx = new SessionContext()) {
+      DataFusionException e =
+          assertThrows(
+              DataFusionException.class, () -> ctx.sql("SELECT * FROM nonexistent_table_xyz_42"));
+      assertInstanceOf(PlanException.class, e);
+    }
+  }
+
+  @Test
+  void malformedParquetFileIsIoException(@org.junit.jupiter.api.io.TempDir java.nio.file.Path dir)
+      throws java.io.IOException {
+    // Triggering a clean DataFusionError::IoError / ParquetError requires a
+    // file that exists but isn't a valid parquet -- a missing path is
+    // classified as "no table paths" (Execution) rather than IoError.
+    java.nio.file.Path bogus = dir.resolve("not-actually-parquet.parquet");
+    java.nio.file.Files.writeString(bogus, "this is not a parquet file");
+    try (SessionContext ctx = new SessionContext()) {
+      DataFusionException e =
+          assertThrows(
+              DataFusionException.class,
+              () -> ctx.registerParquet("t", bogus.toAbsolutePath().toString()));
+      assertInstanceOf(IoException.class, e);
+    }
+  }
+
+  @Test
+  void unknownConfigKeyIsConfigurationException() {
+    // SessionContextBuilder.setOption defers config-key validation to build()
+    // time, where DataFusion's `ConfigOptions::set` returns
+    // DataFusionError::Configuration for unrecognised keys.
+    SessionContextBuilder b =
+        SessionContext.builder().setOption("datafusion.bogus_made_up_key_for_typed_test", "x");
+    DataFusionException e = assertThrows(DataFusionException.class, b::build);
+    assertInstanceOf(ConfigurationException.class, e);
+  }
+
+  @Test
+  void divideByZeroIsExecutionException() throws java.io.IOException {
+    // SELECT 1/0 surfaces as DataFusionError::ArrowError(ArrowError::DivideByZero)
+    // -- the case Codex flagged that previously routed to IoException because
+    // the outer arm matched *any* ArrowError. ExecutionException is the right
+    // typed class for an arithmetic failure produced during query execution.
+    try (SessionContext ctx = new SessionContext();
+        org.apache.arrow.memory.RootAllocator allocator =
+            new org.apache.arrow.memory.RootAllocator()) {
+      DataFusionException e =
+          assertThrows(
+              DataFusionException.class,
+              () -> {
+                try (DataFrame df = ctx.sql("SELECT 1/0")) {
+                  df.collect(allocator).close();
+                }
+              });
+      assertInstanceOf(ExecutionException.class, e);
+    }
+  }
+
+  @Test
+  void runtimeKeyRejectionIsParentDataFusionException() {
+    // The native-side guard against `datafusion.runtime.*` keys uses a
+    // stringly-typed `Err("...")?` (not a DataFusionError variant), so it
+    // surfaces as the parent DataFusionException — the catch-all path that
+    // protects callers from a wrong typed subclass when the underlying error
+    // doesn't carry a clean variant signal.
+    SessionContextBuilder b =
+        SessionContext.builder().setOption("datafusion.runtime.memory_limit", "2G");
+    DataFusionException e = assertThrows(DataFusionException.class, b::build);
+    // Strictly the parent class, not any subclass.
+    if (e instanceof PlanException
+        || e instanceof IoException
+        || e instanceof ConfigurationException
+        || e instanceof ExecutionException
+        || e instanceof ResourcesExhaustedException
+        || e instanceof NotImplementedException) {
+      throw new AssertionError(
+          "expected parent DataFusionException, got " + e.getClass().getName());
+    }
+  }
+}
diff --git a/native/Cargo.toml b/native/Cargo.toml
index 42fab85..1b7eb09 100644
--- a/native/Cargo.toml
+++ b/native/Cargo.toml
@@ -22,7 +22,10 @@
 publish = false
 
 [lib]
-crate-type = ["cdylib"]
+# `rlib` alongside `cdylib` so `cargo test` has a Rust-level harness for
+# native-only invariants (e.g. error-classification routing through wrapped
+# DataFusionError chains). The `cdylib` is still the artifact the JVM loads.
+crate-type = ["cdylib", "rlib"]
 
 [dependencies]
 arrow = { version = "58", features = ["ffi"] }
diff --git a/native/src/errors.rs b/native/src/errors.rs
index e779bb7..d926544 100644
--- a/native/src/errors.rs
+++ b/native/src/errors.rs
@@ -19,32 +19,146 @@
 use std::error::Error;
 use std::panic::{catch_unwind, AssertUnwindSafe};
 
+use datafusion::arrow::error::ArrowError;
+use datafusion::error::DataFusionError;
 use jni::JNIEnv;
 
 pub type JniResult<T> = Result<T, Box<dyn Error + Send + Sync>>;
 
+/// Run `f`, catching panics and translating its `Err` into the appropriate
+/// Java exception. When the boxed error is a [`DataFusionError`], the variant
+/// drives which Java exception class is thrown -- callers can `catch
+/// (PlanException)` / `catch (ResourcesExhaustedException)` etc. without
+/// scraping message strings. Anything else (including Rust panics) falls
+/// through to the parent [`DataFusionException`] class.
 pub fn try_unwrap_or_throw<T, F>(env: &mut JNIEnv, default: T, f: F) -> T
 where
     F: FnOnce(&mut JNIEnv) -> JniResult<T>,
 {
     match catch_unwind(AssertUnwindSafe(|| f(env))) {
         Ok(Ok(value)) => value,
-        Ok(Err(err)) => {
-            throw_runtime_exception(env, &err.to_string());
+        Ok(Err(boxed)) => {
+            // Try to recover the typed DataFusionError so we can pick a
+            // matching Java subclass. If the error came from elsewhere
+            // (jni::errors::Error, prost::DecodeError, a stringly-typed
+            // Err("...")?), it falls through to the parent class.
+            match boxed.downcast::<DataFusionError>() {
+                Ok(df_err) => {
+                    // Walk the cause chain so a ResourcesExhausted / Plan /
+                    // etc. wrapped in ArrowError::External (or any other
+                    // upstream-mandated wrapper) is classified by its real
+                    // root, not by the outer wrapper. Same shape as upstream's
+                    // `DataFusionError::find_root()`. The thrown message
+                    // preserves the outer error's full chain so wrapping
+                    // context isn't lost; only the *class* picks the inner
+                    // variant.
+                    let class = classify(df_err.find_root());
+                    throw(env, class, &df_err.to_string());
+                }
+                Err(other) => {
+                    throw(env, PARENT, &other.to_string());
+                }
+            }
             default
         }
         Err(panic) => {
-            throw_runtime_exception(env, &panic_message(&panic));
+            // Rust panics are upstream-bug-shaped, not caller-actionable.
+            // Surface as the parent class with a `panic: ` prefix so the
+            // caller can grep without inspecting a typed subclass.
+            throw(env, PARENT, &format!("panic: {}", panic_message(&panic)));
             default
         }
     }
 }
 
-fn throw_runtime_exception(env: &mut JNIEnv, message: &str) {
+const PARENT: &str = "org/apache/datafusion/DataFusionException";
+
+/// Map a [`DataFusionError`] variant onto the Java exception class to throw.
+/// Multiple Rust variants fold into a single Java class when they're the same
+/// kind of problem from the caller's standpoint -- the proposed mapping is
+/// the public Java contract; the underlying variant set is not.
+///
+/// Callers should pass the result of [`DataFusionError::find_root`] so a
+/// `ResourcesExhausted` / `Plan` / etc. buried inside a wrapping `ArrowError`
+/// or `Context(...)` is classified by its real root. Variants without a
+/// clean caller-facing category fall through to the parent class.
+fn classify(err: &DataFusionError) -> &'static str {
+    match err {
+        DataFusionError::Plan(_)
+        | DataFusionError::SQL(_, _)
+        | DataFusionError::SchemaError(_, _) => "org/apache/datafusion/PlanException",
+        DataFusionError::Execution(_)
+        | DataFusionError::ExecutionJoin(_)
+        | DataFusionError::External(_)
+        | DataFusionError::Ffi(_) => "org/apache/datafusion/ExecutionException",
+        DataFusionError::ResourcesExhausted(_) => {
+            "org/apache/datafusion/ResourcesExhaustedException"
+        }
+        DataFusionError::IoError(_)
+        | DataFusionError::ObjectStore(_)
+        | DataFusionError::ParquetError(_)
+        | DataFusionError::AvroError(_) => "org/apache/datafusion/IoException",
+        // ArrowError is a 21-variant grab bag -- only some of those variants
+        // are actually IO-shaped. DivideByZero / ArithmeticOverflow / Compute
+        // / Cast / InvalidArgument / Memory etc. are execution-time failures
+        // produced by a normal query (e.g. SELECT 1/0), not IO. Routing them
+        // through IoException would hide ordinary execution errors from
+        // callers catching ExecutionException.
+        DataFusionError::ArrowError(arrow_err, _) => classify_arrow(arrow_err),
+        DataFusionError::NotImplemented(_) => "org/apache/datafusion/NotImplementedException",
+        DataFusionError::Configuration(_) => "org/apache/datafusion/ConfigurationException",
+        // Variants with no clean caller-facing category -- Internal (upstream
+        // bug-shaped), Substrait, Collection (multi-error), and any new
+        // variants a future DataFusion bump introduces -- fall through to
+        // the parent. The catch-all is deliberate: "I don't know what to do
+        // with this" surfaces as the parent class rather than a wrong typed
+        // subclass. Wrapper variants like Context / Diagnostic / Shared also
+        // land here when find_root() can't dig past them, which is rare
+        // because find_root walks `source()`.
+        _ => PARENT,
+    }
+}
+
+/// Map an [`ArrowError`] variant onto the Java exception class to throw.
+/// Only the genuinely IO-shaped variants (`IoError`, `IpcError`) land on
+/// `IoException`; everything else is execution-time and routes through
+/// `ExecutionException`. Schema/parse-shaped variants route through
+/// `PlanException` so a malformed IPC schema or a parse error surfaces as a
+/// query problem rather than an execution failure.
+///
+/// Variants without a clean caller-facing category (`CDataInterface`, the
+/// various overflow/index-overflow markers) fall through to the parent.
+fn classify_arrow(err: &ArrowError) -> &'static str {
+    match err {
+        ArrowError::IoError(_, _) | ArrowError::IpcError(_) => "org/apache/datafusion/IoException",
+        ArrowError::SchemaError(_) | ArrowError::ParseError(_) => {
+            "org/apache/datafusion/PlanException"
+        }
+        ArrowError::DivideByZero
+        | ArrowError::ArithmeticOverflow(_)
+        | ArrowError::ComputeError(_)
+        | ArrowError::CastError(_)
+        | ArrowError::InvalidArgumentError(_)
+        | ArrowError::MemoryError(_)
+        | ArrowError::CsvError(_)
+        | ArrowError::JsonError(_)
+        | ArrowError::AvroError(_)
+        | ArrowError::ParquetError(_)
+        | ArrowError::ExternalError(_) => "org/apache/datafusion/ExecutionException",
+        ArrowError::NotYetImplemented(_) => "org/apache/datafusion/NotImplementedException",
+        // CDataInterface, DictionaryKeyOverflowError, RunEndIndexOverflowError,
+        // OffsetOverflowError, and any new variants in future Arrow bumps fall
+        // through to the parent -- "I don't know what to do with this" stays
+        // at the parent rather than getting routed to the wrong subclass.
+        _ => PARENT,
+    }
+}
+
+fn throw(env: &mut JNIEnv, class: &str, message: &str) {
     if env.exception_check().unwrap_or(false) {
         return;
     }
-    let _ = env.throw_new("java/lang/RuntimeException", message);
+    let _ = env.throw_new(class, message);
 }
 
 fn panic_message(panic: &Box<dyn Any + Send>) -> String {
@@ -56,3 +170,144 @@
         "rust panic with non-string payload".to_string()
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use datafusion::arrow::error::ArrowError;
+
+    #[test]
+    fn classify_unwrapped_variants_picks_typed_class() {
+        // Sanity check: the simple unwrapped cases land on the right classes.
+        assert_eq!(
+            classify(&DataFusionError::Plan("x".into())),
+            "org/apache/datafusion/PlanException"
+        );
+        assert_eq!(
+            classify(&DataFusionError::ResourcesExhausted("x".into())),
+            "org/apache/datafusion/ResourcesExhaustedException"
+        );
+        assert_eq!(
+            classify(&DataFusionError::Configuration("x".into())),
+            "org/apache/datafusion/ConfigurationException"
+        );
+    }
+
+    #[test]
+    fn classify_unrecognised_variant_falls_through_to_parent() {
+        // `Internal` has no clean caller-facing category; must surface as
+        // the parent class rather than getting routed to a wrong subclass.
+        assert_eq!(classify(&DataFusionError::Internal("x".into())), PARENT);
+    }
+
+    #[test]
+    fn classify_via_find_root_unwraps_nested_resources_exhausted() {
+        // Codex regression: a DataFusionError::ResourcesExhausted buried
+        // inside an ArrowError::ExternalError(Box<DataFusionError::Context>)
+        // wrapper would otherwise land on the outer ArrowError arm and be
+        // classified as IoException -- the wrong typed exception. Confirm
+        // that calling classify on `find_root` recovers the inner variant
+        // (the same shape upstream's own find_root() doctest pins).
+        let inner = DataFusionError::ResourcesExhausted("memory pool full".into());
+        let contexted = DataFusionError::Context("aggregate stream".into(), Box::new(inner));
+        let arrow_wrapped = ArrowError::ExternalError(Box::new(contexted));
+        let outer = DataFusionError::ArrowError(Box::new(arrow_wrapped), None);
+
+        // Without find_root, the outer would route to IoException; with it,
+        // the real root (ResourcesExhausted) wins.
+        assert_eq!(
+            classify(outer.find_root()),
+            "org/apache/datafusion/ResourcesExhaustedException"
+        );
+    }
+
+    #[test]
+    fn classify_via_find_root_unwraps_plan_through_context() {
+        // A Plan error wrapped only in DataFusionError::Context still has to
+        // reach PlanException, not the parent class.
+        let inner = DataFusionError::Plan("table 't' not found".into());
+        let contexted = DataFusionError::Context("logical planning".into(), Box::new(inner));
+
+        assert_eq!(
+            classify(contexted.find_root()),
+            "org/apache/datafusion/PlanException"
+        );
+    }
+
+    #[test]
+    fn arrow_divide_by_zero_routes_to_execution_not_io() {
+        // Codex regression: DataFusionError::ArrowError(ArrowError::DivideByZero)
+        // is the actual shape produced by `SELECT 1/0`. The 21-variant
+        // ArrowError grab bag means routing the whole ArrowError arm to
+        // IoException would put ordinary divide-by-zero / overflow / cast
+        // errors on the wrong typed exception. classify_arrow inspects the
+        // inner variant.
+        let outer = DataFusionError::ArrowError(Box::new(ArrowError::DivideByZero), None);
+        assert_eq!(
+            classify(outer.find_root()),
+            "org/apache/datafusion/ExecutionException"
+        );
+
+        let outer = DataFusionError::ArrowError(
+            Box::new(ArrowError::ArithmeticOverflow("i32 overflow".into())),
+            None,
+        );
+        assert_eq!(
+            classify(outer.find_root()),
+            "org/apache/datafusion/ExecutionException"
+        );
+
+        let outer = DataFusionError::ArrowError(
+            Box::new(ArrowError::CastError("Int32 -> Date32".into())),
+            None,
+        );
+        assert_eq!(
+            classify(outer.find_root()),
+            "org/apache/datafusion/ExecutionException"
+        );
+    }
+
+    #[test]
+    fn arrow_io_variants_still_route_to_io() {
+        // Genuinely IO-shaped ArrowError variants stay on IoException.
+        let io = std::io::Error::other("disk full");
+        let outer = DataFusionError::ArrowError(
+            Box::new(ArrowError::IoError("write failed".into(), io)),
+            None,
+        );
+        assert_eq!(
+            classify(outer.find_root()),
+            "org/apache/datafusion/IoException"
+        );
+
+        let outer =
+            DataFusionError::ArrowError(Box::new(ArrowError::IpcError("bad framing".into())), None);
+        assert_eq!(
+            classify(outer.find_root()),
+            "org/apache/datafusion/IoException"
+        );
+    }
+
+    #[test]
+    fn arrow_schema_and_parse_errors_route_to_plan() {
+        // SchemaError and ParseError are query-shaped problems (you wrote a
+        // bad query / supplied a bad schema), not execution failures.
+        let outer = DataFusionError::ArrowError(
+            Box::new(ArrowError::SchemaError("column 'foo' not found".into())),
+            None,
+        );
+        assert_eq!(
+            classify(outer.find_root()),
+            "org/apache/datafusion/PlanException"
+        );
+
+        let outer = DataFusionError::ArrowError(
+            Box::new(ArrowError::ParseError("expected int".into())),
+            None,
+        );
+        assert_eq!(
+            classify(outer.find_root()),
+            "org/apache/datafusion/PlanException"
+        );
+    }
+}