From 931c120c5423475b59f2d40e22c99c064df857ae Mon Sep 17 00:00:00 2001 From: Andrew Shao Date: Fri, 21 Aug 2026 10:41:03 -0700 Subject: [PATCH 1/6] basic exceptions --- include/radex/client_base.hpp | 18 +++--- include/radex/exceptions.hpp | 60 ++++++++++++++++++++ src/cpp/client.cpp | 5 +- src/cpp/dragon.cpp | 3 +- tests/python/dragon/test_put_and_get_item.py | 12 ++++ 5 files changed, 84 insertions(+), 14 deletions(-) create mode 100644 include/radex/exceptions.hpp diff --git a/include/radex/client_base.hpp b/include/radex/client_base.hpp index bed7f85..b76d471 100644 --- a/include/radex/client_base.hpp +++ b/include/radex/client_base.hpp @@ -15,6 +15,7 @@ #include #include +#include "radex/exceptions.hpp" #include "radex/handles.hpp" namespace radex { @@ -302,13 +303,11 @@ class IClient { typename std::enable_if::value, T>::type assemble_scalar(const detail::ItemInfo &info) { if (info.metadata().n_dims() != 0) { - // TODO: Better error type/message here - throw std::runtime_error( + throw RankMismatchError( "Attempted to retrieve scalar at a key with a vector"); } if (info.metadata().type() != data::encode_type()) { - // TODO: Better error type/message here - throw std::runtime_error( + throw DTypeMismatchError( "Attempted to retrieve scalar of mismatched type"); } T converted; @@ -321,13 +320,11 @@ class IClient { TensorInfo>::type assemble_tensor(const detail::ItemInfo &info) { if (info.metadata().n_dims() == 0) { - // TODO: Better error type/message here - throw std::runtime_error( + throw RankMismatchError( "Attempted to retrieve vector at a key with a scalar"); } if (info.metadata().type() != data::encode_type()) { - // TODO: Better error type/message here - throw std::runtime_error( + throw DTypeMismatchError( "Attempted to retrieve vector of mismatched type"); } @@ -340,7 +337,8 @@ class IClient { namespace unsupported_backend { /// Placeholder `IClient` used when a backend was disabled at build time; every -/// method throws `std::runtime_error` explaining how to rebuild with it enabled. +/// method throws `radex::BackendUnavailableError` explaining how to rebuild +/// with it enabled. class Client : public IClient { private: std::string backend_name; @@ -348,7 +346,7 @@ class Client : public IClient { protected: [[noreturn]] void throw_backend_unavailable() const { - throw std::runtime_error( + throw BackendUnavailableError( "RaDex was built without " + backend_name + " backend support. " "Rebuild with -D" + enable_option + "=ON to enable this client." ); diff --git a/include/radex/exceptions.hpp b/include/radex/exceptions.hpp new file mode 100644 index 0000000..d6549c4 --- /dev/null +++ b/include/radex/exceptions.hpp @@ -0,0 +1,60 @@ +#ifndef __RADEX_EXCEPTIONS_HPP__ +#define __RADEX_EXCEPTIONS_HPP__ + +#include + +namespace radex { + +/// Base class for every error raised by radex. +class Error : public std::runtime_error { + public: + using std::runtime_error::runtime_error; +}; + +/// A key was requested that is not present in the store. +class KeyNotFoundError : public Error { + public: + using Error::Error; +}; + +/// A key did not appear in the store before timing out. +class TimeoutError : public Error { + public: + using Error::Error; +}; + +/// The stored item does not match the item type requested. +class TypeMismatchError : public Error { + public: + using Error::Error; +}; + +/// A scalar was requested at a tensor key, or a tensor at a scalar key. +class RankMismatchError : public TypeMismatchError { + public: + using TypeMismatchError::TypeMismatchError; +}; + +/// The stored element type differs from the requested one. Distinct from +/// `RankMismatchError` so that callers can retry with another element type +/// without also retrying a request that asked for the wrong shape entirely. +class DTypeMismatchError : public TypeMismatchError { + public: + using TypeMismatchError::TypeMismatchError; +}; + +/// An item's metadata record could not be decoded. +class MetadataError : public Error { + public: + using Error::Error; +}; + +/// A client was requested for a backend that was disabled at build time. +class BackendUnavailableError : public Error { + public: + using Error::Error; +}; + +} // namespace radex + +#endif // __RADEX_EXCEPTIONS_HPP__ diff --git a/src/cpp/client.cpp b/src/cpp/client.cpp index cc18e08..830281a 100644 --- a/src/cpp/client.cpp +++ b/src/cpp/client.cpp @@ -37,7 +37,7 @@ MetaData MetaData::from_buffer(BytesBuffer buffer) { MetaInt expected_size = (meta.n_dims() + Index::END_OF_HEADER) * sizeof(MetaInt); if (meta.size() != expected_size) { - throw std::runtime_error("Malformed item metadata buffer received"); + throw radex::MetadataError("Malformed item metadata buffer received"); } return meta; } @@ -74,8 +74,7 @@ detail::BytesBuffer IClient::wait_for_bytes(std::string_view key, std::ostringstream msg; msg << "Failed to find key `" << key << "` before timeout"; - // TODO: Better error type here - throw std::runtime_error(msg.str()); + throw radex::TimeoutError(msg.str()); } std::this_thread::sleep_for(poll_interval); } diff --git a/src/cpp/dragon.cpp b/src/cpp/dragon.cpp index 0c4188d..a76e718 100644 --- a/src/cpp/dragon.cpp +++ b/src/cpp/dragon.cpp @@ -103,7 +103,8 @@ radex::detail::BytesBuffer Client::get_bytes(std::string_view key) { const std::chrono::milliseconds fast_timeout{1}; // TODO: Check if/when Dragon can support bypassing wait for keys if (!contains(key)) { - std::runtime_error("Key does not exist in the DDict: " + std::string(key)); + throw radex::KeyNotFoundError("Key does not exist in the DDict: " + + std::string(key)); } return wait_for_bytes(key, fast_timeout); } diff --git a/tests/python/dragon/test_put_and_get_item.py b/tests/python/dragon/test_put_and_get_item.py index 1ccc030..9e477b3 100644 --- a/tests/python/dragon/test_put_and_get_item.py +++ b/tests/python/dragon/test_put_and_get_item.py @@ -31,6 +31,18 @@ def test_put_and_get_tensor(client, random_np_tensor, np_dtype): assert (random_np_tensor == ret_tensor).all() +def test_get_missing_key_raises(client): + # Regression: a get on an absent key used to block on the key instead + key = "no-such-key" + assert not client.contains(key) + + with pytest.raises(RuntimeError): + client.get_scalar(IncomingHandle(key)) + + with pytest.raises(RuntimeError): + client.get_tensor(IncomingHandle(key)) + + @pytest.mark.parametrize("size", [pytest.param(int(1e6), id="size-1MB")]) @pytest.mark.parametrize( "n_dims", [pytest.param(n, id=f"shape-{n}D") for n in range(1, 5)] From 4de873e560f5aab4b93432abca1fc0a6bed150a3 Mon Sep 17 00:00:00 2001 From: Andrew Shao Date: Fri, 21 Aug 2026 10:42:32 -0700 Subject: [PATCH 2/6] Make header modifications discoverable on rebuild --- src/CMakeLists.txt | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 41bc813..a4750dc 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -40,9 +40,21 @@ if(ENABLE_CXX) endif() set(RADEX_GENERATED_INCLUDE_DIR ${CMAKE_CURRENT_BINARY_DIR}/generated/include) - file(COPY ${CMAKE_CURRENT_SOURCE_DIR}/../include/ - DESTINATION ${RADEX_GENERATED_INCLUDE_DIR} + set(RADEX_PUBLIC_INCLUDE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../include) + + file(GLOB_RECURSE RADEX_PUBLIC_HEADERS + RELATIVE ${RADEX_PUBLIC_INCLUDE_DIR} + ${RADEX_PUBLIC_INCLUDE_DIR}/*.hpp ) + foreach(header IN LISTS RADEX_PUBLIC_HEADERS) + configure_file( + ${RADEX_PUBLIC_INCLUDE_DIR}/${header} + ${RADEX_GENERATED_INCLUDE_DIR}/${header} + COPYONLY + ) + endforeach() + + # Overwrite the build_config.hpp configure_file( ${CMAKE_CURRENT_SOURCE_DIR}/cmake/radex-build-config.hpp.in ${RADEX_GENERATED_INCLUDE_DIR}/radex/build_config.hpp From 779b0b0091797b0c6f5b7a546cfa893e4485690e Mon Sep 17 00:00:00 2001 From: Andrew Shao Date: Fri, 21 Aug 2026 10:54:44 -0700 Subject: [PATCH 3/6] Wire in exceptions up to Python client --- src/python/setup.py | 7 +- src/python/src/radex/clients/core.pxd | 17 ++--- src/python/src/radex/clients/dragon.pxd | 5 +- src/python/src/radex/clients/redis.pxd | 5 +- src/python/src/radex/exceptions.py | 45 ++++++++++++ src/python/src/radex/utils/data.pxd | 10 +-- .../src/radex/utils/exception_translation.hpp | 71 +++++++++++++++++++ src/python/src/radex/utils/exceptions.pxd | 2 + tests/python/dragon/test_put_and_get_item.py | 19 ++++- 9 files changed, 162 insertions(+), 19 deletions(-) create mode 100644 src/python/src/radex/exceptions.py create mode 100644 src/python/src/radex/utils/exception_translation.hpp create mode 100644 src/python/src/radex/utils/exceptions.pxd diff --git a/src/python/setup.py b/src/python/setup.py index cff2713..d202856 100644 --- a/src/python/setup.py +++ b/src/python/setup.py @@ -48,7 +48,12 @@ def _required_env(name, backend): def make_extensions(): - include_dirs = [os.fspath(RADEX_INCLUDE_DIR), numpy.get_include()] + # PY_SRC provides radex/utils/exception_translation.hpp + include_dirs = [ + os.fspath(RADEX_INCLUDE_DIR), + os.fspath(PY_SRC), + numpy.get_include(), + ] library_dirs = [os.fspath(RADEX_LIB_DIR)] libraries = ["radex"] runtime_library_dirs = [os.fspath(RADEX_LIB_DIR)] diff --git a/src/python/src/radex/clients/core.pxd b/src/python/src/radex/clients/core.pxd index 1f33d3a..9ca37d9 100644 --- a/src/python/src/radex/clients/core.pxd +++ b/src/python/src/radex/clients/core.pxd @@ -13,6 +13,7 @@ from radex.handles.handles cimport ( CXXIncomingHandle as IncomingHandle, CXXOutgoingHandle as OutgoingHandle, ) +from radex.utils.exceptions cimport raise_py_error cimport numpy as np np.import_array() @@ -20,17 +21,17 @@ np.import_array() cdef extern from "radex/client.hpp" namespace "radex": cdef cppclass IClient: # >>> Start Virtual Methods >>> - bint contains(string_view) except + - void put_bytes(string_view, const void*, size_t) except + - BytesBuffer get_bytes(string_view) except + - BytesBuffer wait_for_bytes(string_view, milliseconds) except + + bint contains(string_view) except +raise_py_error + void put_bytes(string_view, const void*, size_t) except +raise_py_error + BytesBuffer get_bytes(string_view) except +raise_py_error + BytesBuffer wait_for_bytes(string_view, milliseconds) except +raise_py_error # <<< End Virtual Methods <<< - void put_scalar[T](const OutgoingHandle&, T) except + + void put_scalar[T](const OutgoingHandle&, T) except +raise_py_error void put_tensor[T](const OutgoingHandle&, const size_t*, size_t, - const T*, size_t) except + + const T*, size_t) except +raise_py_error - unique_ptr[ItemInfo] get_item_info_ptr(const IncomingHandle&) except + + unique_ptr[ItemInfo] get_item_info_ptr(const IncomingHandle&) except +raise_py_error unique_ptr[ItemInfo] wait_for_item_info_ptr( - const IncomingHandle&, milliseconds) except + + const IncomingHandle&, milliseconds) except +raise_py_error diff --git a/src/python/src/radex/clients/dragon.pxd b/src/python/src/radex/clients/dragon.pxd index ce38550..832c184 100644 --- a/src/python/src/radex/clients/dragon.pxd +++ b/src/python/src/radex/clients/dragon.pxd @@ -1,9 +1,10 @@ from libc.time cimport timespec from radex.clients.core cimport IClient +from radex.utils.exceptions cimport raise_py_error cdef extern from "radex/dragon.hpp" namespace "radex::drg::ddict": cdef cppclass Client(IClient): - Client() except + - Client(const char*, const timespec*) except + + Client() except +raise_py_error + Client(const char*, const timespec*) except +raise_py_error diff --git a/src/python/src/radex/clients/redis.pxd b/src/python/src/radex/clients/redis.pxd index ece0219..2d34547 100644 --- a/src/python/src/radex/clients/redis.pxd +++ b/src/python/src/radex/clients/redis.pxd @@ -1,9 +1,10 @@ from libcpp.string_view cimport string_view from radex.clients.core cimport IClient +from radex.utils.exceptions cimport raise_py_error cdef extern from "radex/smartredis.hpp" namespace "radex::redis::smartredis": cdef cppclass Client(IClient): - Client() except + - Client(string_view) except + + Client() except +raise_py_error + Client(string_view) except +raise_py_error diff --git a/src/python/src/radex/exceptions.py b/src/python/src/radex/exceptions.py new file mode 100644 index 0000000..c05f5c3 --- /dev/null +++ b/src/python/src/radex/exceptions.py @@ -0,0 +1,45 @@ +"""Python mirror of the C++ exception hierarchy in ``radex/exceptions.hpp``. +""" + +__all__ = [ + "RadexError", + "KeyNotFoundError", + "TimeoutError", + "TypeMismatchError", + "RankMismatchError", + "DTypeMismatchError", + "MetadataError", + "BackendUnavailableError", +] + + +class RadexError(RuntimeError): + """Base class for every error raised by radex.""" + + +class KeyNotFoundError(RadexError): + """A key was requested that is not present in the store.""" + + +class TimeoutError(RadexError): + """A key did not appear in the store before the timeout elapsed.""" + + +class TypeMismatchError(RadexError): + """The stored item does not match the description it was requested with.""" + + +class RankMismatchError(TypeMismatchError): + """A scalar was requested at a tensor key, or a tensor at a scalar key.""" + + +class DTypeMismatchError(TypeMismatchError): + """The stored element type differs from the requested one.""" + + +class MetadataError(RadexError): + """An item's metadata record could not be decoded.""" + + +class BackendUnavailableError(RadexError): + """A client was requested for a backend that was disabled at build time.""" diff --git a/src/python/src/radex/utils/data.pxd b/src/python/src/radex/utils/data.pxd index 2f035bc..71f2c5a 100644 --- a/src/python/src/radex/utils/data.pxd +++ b/src/python/src/radex/utils/data.pxd @@ -70,8 +70,9 @@ cdef inline np.number coerce_py_objects_to_np_numbers(object value): cdef inline construct_scalar(const ItemInfo &info): if info.metadata().n_dims() != 0: - # TODO: Better error type/msg here - raise ValueError("Attempted to retrieve scalar at a key with a vector") + from radex.exceptions import RankMismatchError + raise RankMismatchError( + "Attempted to retrieve scalar at a key with a vector") cdef DType type_ = info.metadata().type() return make_ndarray(type_, info.data(), 1)[0] @@ -80,8 +81,9 @@ cdef inline construct_scalar(const ItemInfo &info): cdef inline construct_tensor(const ItemInfo &info): cdef MetaInt n_dims = info.metadata().n_dims() if n_dims == 0: - # TODO: Better error type/msg here - raise ValueError("Attempted to retrieve vector at a key with a scalar") + from radex.exceptions import RankMismatchError + raise RankMismatchError( + "Attempted to retrieve vector at a key with a scalar") cdef const MetaInt[:] dims = info.metadata().dims_ptr() cdef n_elements = info.metadata().n_elements() diff --git a/src/python/src/radex/utils/exception_translation.hpp b/src/python/src/radex/utils/exception_translation.hpp new file mode 100644 index 0000000..b5d9b50 --- /dev/null +++ b/src/python/src/radex/utils/exception_translation.hpp @@ -0,0 +1,71 @@ +#ifndef __RADEX_PY_EXCEPTION_TRANSLATION_HPP__ +#define __RADEX_PY_EXCEPTION_TRANSLATION_HPP__ + +#include + +#include +#include + +#include "radex/exceptions.hpp" + +namespace radex_py { + +namespace detail { + +inline void set_error(const char *name, const char *what) { + PyObject *module = PyImport_ImportModule("radex.exceptions"); + if (module == nullptr) { + return; // ImportError is already set + } + + PyObject *exc_type = PyObject_GetAttrString(module, name); + if (exc_type != nullptr) { + PyErr_SetString(exc_type, what); + Py_DECREF(exc_type); + } + + Py_DECREF(module); +} + +} // namespace detail + +/// Translate the in-flight C++ exception into the matching Python one. +/// +/// Only valid inside a catch block, which is where Cython's +/// `except +raise_py_error` invokes it. Derived types must be caught before +/// their bases or the subclass information is lost. +inline void raise_py_error() { + try { + throw; + } catch (const radex::KeyNotFoundError &e) { + detail::set_error("KeyNotFoundError", e.what()); + } catch (const radex::TimeoutError &e) { + detail::set_error("TimeoutError", e.what()); + } catch (const radex::RankMismatchError &e) { + detail::set_error("RankMismatchError", e.what()); + } catch (const radex::DTypeMismatchError &e) { + detail::set_error("DTypeMismatchError", e.what()); + } catch (const radex::TypeMismatchError &e) { + detail::set_error("TypeMismatchError", e.what()); + } catch (const radex::MetadataError &e) { + detail::set_error("MetadataError", e.what()); + } catch (const radex::BackendUnavailableError &e) { + detail::set_error("BackendUnavailableError", e.what()); + } catch (const radex::Error &e) { + detail::set_error("RadexError", e.what()); + } catch (const std::invalid_argument &e) { + PyErr_SetString(PyExc_ValueError, e.what()); + } catch (const std::out_of_range &e) { + PyErr_SetString(PyExc_IndexError, e.what()); + } catch (const std::bad_alloc &e) { + PyErr_SetString(PyExc_MemoryError, e.what()); + } catch (const std::exception &e) { + PyErr_SetString(PyExc_RuntimeError, e.what()); + } catch (...) { + PyErr_SetString(PyExc_RuntimeError, "Unknown C++ exception"); + } +} + +} // namespace radex_py + +#endif // __RADEX_PY_EXCEPTION_TRANSLATION_HPP__ diff --git a/src/python/src/radex/utils/exceptions.pxd b/src/python/src/radex/utils/exceptions.pxd new file mode 100644 index 0000000..c340610 --- /dev/null +++ b/src/python/src/radex/utils/exceptions.pxd @@ -0,0 +1,2 @@ +cdef extern from "radex/utils/exception_translation.hpp" namespace "radex_py": + void raise_py_error() diff --git a/tests/python/dragon/test_put_and_get_item.py b/tests/python/dragon/test_put_and_get_item.py index 9e477b3..d01a2c9 100644 --- a/tests/python/dragon/test_put_and_get_item.py +++ b/tests/python/dragon/test_put_and_get_item.py @@ -3,6 +3,7 @@ import numpy as np import pytest +import radex.exceptions as ex from radex.handles.handles import IncomingHandle, OutgoingHandle @@ -36,13 +37,27 @@ def test_get_missing_key_raises(client): key = "no-such-key" assert not client.contains(key) - with pytest.raises(RuntimeError): + with pytest.raises(ex.KeyNotFoundError): client.get_scalar(IncomingHandle(key)) - with pytest.raises(RuntimeError): + with pytest.raises(ex.KeyNotFoundError): client.get_tensor(IncomingHandle(key)) +def test_reading_a_tensor_as_a_scalar_raises(client): + key = "a-tensor" + client.put_tensor(OutgoingHandle(key), np.arange(4, dtype=np.float64)) + + with pytest.raises(ex.RankMismatchError): + client.get_scalar(IncomingHandle(key)) + + +def test_radex_errors_remain_runtime_errors(client): + # Callers written before the exception hierarchy caught RuntimeError + with pytest.raises(RuntimeError): + client.get_scalar(IncomingHandle("still-no-such-key")) + + @pytest.mark.parametrize("size", [pytest.param(int(1e6), id="size-1MB")]) @pytest.mark.parametrize( "n_dims", [pytest.param(n, id=f"shape-{n}D") for n in range(1, 5)] From 7d2f2a9f4b3b0d737a403aaa96b7fdd921d5aa94 Mon Sep 17 00:00:00 2001 From: Andrew Shao Date: Fri, 21 Aug 2026 14:48:49 -0700 Subject: [PATCH 4/6] Update src/python/setup.py Co-authored-by: Matt Drozt --- src/python/setup.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/python/setup.py b/src/python/setup.py index d202856..607761e 100644 --- a/src/python/setup.py +++ b/src/python/setup.py @@ -48,7 +48,6 @@ def _required_env(name, backend): def make_extensions(): - # PY_SRC provides radex/utils/exception_translation.hpp include_dirs = [ os.fspath(RADEX_INCLUDE_DIR), os.fspath(PY_SRC), From 1af7e822a44f0a94562a5b8a2a14745e7cdfd664 Mon Sep 17 00:00:00 2001 From: Andrew Shao Date: Fri, 21 Aug 2026 14:52:23 -0700 Subject: [PATCH 5/6] Move import up --- src/python/src/radex/utils/data.pxd | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/python/src/radex/utils/data.pxd b/src/python/src/radex/utils/data.pxd index 71f2c5a..cba6de9 100644 --- a/src/python/src/radex/utils/data.pxd +++ b/src/python/src/radex/utils/data.pxd @@ -6,6 +6,8 @@ cimport numpy as np import numpy as np np.import_array() +from radex.exceptions import RankMismatchError + ctypedef fused SupportedType: int32_t int64_t @@ -70,7 +72,6 @@ cdef inline np.number coerce_py_objects_to_np_numbers(object value): cdef inline construct_scalar(const ItemInfo &info): if info.metadata().n_dims() != 0: - from radex.exceptions import RankMismatchError raise RankMismatchError( "Attempted to retrieve scalar at a key with a vector") From d99717687a2d390ad39e610f0f716c4aa5f28ce8 Mon Sep 17 00:00:00 2001 From: Andrew Shao Date: Fri, 21 Aug 2026 15:10:36 -0700 Subject: [PATCH 6/6] Revert "Move import up" This reverts commit 1af7e822a44f0a94562a5b8a2a14745e7cdfd664. --- src/python/src/radex/utils/data.pxd | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/python/src/radex/utils/data.pxd b/src/python/src/radex/utils/data.pxd index cba6de9..71f2c5a 100644 --- a/src/python/src/radex/utils/data.pxd +++ b/src/python/src/radex/utils/data.pxd @@ -6,8 +6,6 @@ cimport numpy as np import numpy as np np.import_array() -from radex.exceptions import RankMismatchError - ctypedef fused SupportedType: int32_t int64_t @@ -72,6 +70,7 @@ cdef inline np.number coerce_py_objects_to_np_numbers(object value): cdef inline construct_scalar(const ItemInfo &info): if info.metadata().n_dims() != 0: + from radex.exceptions import RankMismatchError raise RankMismatchError( "Attempted to retrieve scalar at a key with a vector")