Repository: OpenAssetIO/OpenAssetIO (language: python)

Issue:
Add new exception hierarchy
## What 

Redesign exception hierarchy to incorporate/replace `exceptions.py` in C++ with Python pseudo-bindings.

## Why

It is useful for hosts to be able to branch on specific types of exception in order to customise their mitigation. In particular, it is useful for hosts to know if an exception comes from the OpenAssetIO ecosystem.

The first step in enabling this is to add the exceptions, and use them wherever we're currently relying on standard C++/Python exceptions.

## ACs

* The following exceptions are added under an `errors` namespace / submodule

```c++
OpenAssetIOException : std::runtime_error
```
```c++
UnhandledException : OpenAssetIOException
```
```c++
InputValidationException : OpenAssetIOException
```
```c++
ConfigurationException : OpenAssetIOException
```
```c++
NotImplementedException : OpenAssetIOException
```
  These exceptions simply forward the constructor of the base class.
* Python bindings using current strategy (i.e. C++->Python translation only).
* Existing `raise` or `throw` invocations migrated to use the most appropriate exception from the above.
* Issues raised to update dependent projects if required.

Add new exception hierarchy
## What 

Redesign exception hierarchy to incorporate/replace `exceptions.py` in C++ with Python pseudo-bindings.

## Why

It is useful for hosts to be able to branch on specific types of exception in order to customise their mitigation. In particular, it is useful for hosts to know if an exception comes from the OpenAssetIO ecosystem.

The first step in enabling this is to add the exceptions, and use them wherever we're currently relying on standard C++/Python exceptions.

## ACs

* The following exceptions are added under an `errors` namespace / submodule

```c++
OpenAssetIOException : std::runtime_error
```
```c++
UnhandledException : OpenAssetIOException
```
```c++
InputValidationException : OpenAssetIOException
```
```c++
ConfigurationException : OpenAssetIOException
```
```c++
NotImplementedException : OpenAssetIOException
```
  These exceptions simply forward the constructor of the base class.
* Python bindings using current strategy (i.e. C++->Python translation only).
* Existing `raise` or `throw` invocations migrated to use the most appropriate exception from the above.
* Issues raised to update dependent projects if required.



Discussion / hints:

Fix patch:
diff --git a/.github/workflows/integrations.yml b/.github/workflows/integrations.yml
index db7a405b3..3a9e35949 100644
--- a/.github/workflows/integrations.yml
+++ b/.github/workflows/integrations.yml
@@ -67,21 +67,22 @@ jobs:
           repository: OpenAssetIO/OpenAssetIO-Manager-BAL
           path: external/BAL
 
-      - name: Test BAL
-        run: |
-          python -m pip install external/BAL
-          # We can't install BAL's requirements.txt as this includes
-          # openassetio... we really need to sort
-          # https://github.com/OpenAssetIO/OpenAssetIO-Manager-BAL/issues/72
-          python -m pip install openassetio-mediacreation
-          python -m pip install -r external/BAL/tests/requirements.txt
-          python -m pytest -v external/BAL/tests
-
-      - name: Test Simple Resolver
-        run: |
-          python -m pytest -v examples/host/simpleResolver
-        env:
-          OPENASSETIO_PLUGIN_PATH: ${{ github.workspace }}/external/BAL/plugin
+      # Disabled pending https://github.com/OpenAssetIO/OpenAssetIO/issues/997
+      # - name: Test BAL
+      #   run: |
+      #     python -m pip install external/BAL
+      #     # We can't install BAL's requirements.txt as this includes
+      #     # openassetio... we really need to sort
+      #     # https://github.com/OpenAssetIO/OpenAssetIO-Manager-BAL/issues/72
+      #     python -m pip install openassetio-mediacreation
+      #     python -m pip install -r external/BAL/tests/requirements.txt
+      #     python -m pytest -v external/BAL/tests
+      #
+      #- name: Test Simple Resolver
+      #  run: |
+      #    python -m pytest -v examples/host/simpleResolver
+      #  env:
+      #    OPENASSETIO_PLUGIN_PATH: ${{ github.workspace }}/external/BAL/plugin
 
       - name: Checkout TraitGen
         uses: actions/checkout@v4
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index 4837e34ba..c64dc8e5b 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -4,6 +4,12 @@ Release Notes
 v1.0.0-alpha.xx
 ---------------
 
+### New Features
+
+- Added new exception types, allowing all OpenAssetIO exceptions to
+  share a common base.
+  [#1071](https://github.com/OpenAssetIO/OpenAssetIO/issues/1071)
+
 ### Breaking changes
 
 - Attempting to retrieve a trait property with
@@ -21,6 +27,12 @@ v1.0.0-alpha.xx
   implementations to make use of private state for efficiency reasons.
   [#518](https://github.com/OpenAssetIO/OpenAssetIO/issues/518)
 
+- Moved `BatchElementError.hpp` into `errors` directory.
+  [#1071](https://github.com/OpenAssetIO/OpenAssetIO/issues/1071)
+
+- Removed python `exceptions` module in favour of new `errors` module.
+  [#1071](https://github.com/OpenAssetIO/OpenAssetIO/issues/1071)
+
 v1.0.0-alpha.14
 ---------------
 
diff --git a/examples/host/simpleResolver/simpleResolver.py b/examples/host/simpleResolver/simpleResolver.py
index 463a97714..b3b4fa02b 100755
--- a/examples/host/simpleResolver/simpleResolver.py
+++ b/examples/host/simpleResolver/simpleResolver.py
@@ -28,6 +28,7 @@
 from openassetio import BatchElementException
 from openassetio.access import ResolveAccess
 from openassetio.hostApi import HostInterface, ManagerFactory
+from openassetio.errors import ConfigurationException
 from openassetio.log import ConsoleLogger, SeverityFilter
 from openassetio.pluginSystem import PythonPluginSystemManagerImplementationFactory
 
@@ -160,7 +161,7 @@ def main():
     manager = ManagerFactory.defaultManagerForInterface(host_interface, impl_factory, logger)
 
     if not manager:
-        raise RuntimeError(
+        raise ConfigurationException(
             "No default manager configured, "
             f"check ${ManagerFactory.kDefaultManagerConfigEnvVarName}"
         )
diff --git a/src/openassetio-core-c/include/openassetio/c/errors.h b/src/openassetio-core-c/include/openassetio/c/errors.h
index 7382d0e3e..2d20be3d3 100644
--- a/src/openassetio-core-c/include/openassetio/c/errors.h
+++ b/src/openassetio-core-c/include/openassetio/c/errors.h
@@ -2,7 +2,7 @@
 // Copyright 2013-2022 The Foundry Visionmongers Ltd
 #pragma once
 
-#include <openassetio/errors.h>
+#include <openassetio/errors/errors.h>
 
 #include "./namespace.h"
 
diff --git a/src/openassetio-core-c/src/errors.hpp b/src/openassetio-core-c/src/errors.hpp
index 82b8bee13..37082d9bb 100644
--- a/src/openassetio-core-c/src/errors.hpp
+++ b/src/openassetio-core-c/src/errors.hpp
@@ -5,6 +5,7 @@
 #include <openassetio/c/StringView.h>
 #include <openassetio/c/errors.h>
 #include <openassetio/c/namespace.h>
+#include <openassetio/errors/exceptions.hpp>
 #include <openassetio/typedefs.hpp>
 
 #include "StringView.hpp"
@@ -16,7 +17,7 @@ namespace errors {
  * Throw the appropriate exception for given error code, if any.
  *
  * @todo Establish a mapping of code to exception type - currently
- *  everything is a `runtime_error`.
+ *  everything is the OpenAssetIO base exception.
  *
  * @param code Error code.
  * @param msg Error message to bundle in exception.
@@ -26,7 +27,7 @@ inline void throwIfError(const oa_ErrorCode code, [[maybe_unused]] const oa_Stri
     Str errorMessageWithCode = std::to_string(code);
     errorMessageWithCode += ": ";
     errorMessageWithCode += std::string_view{msg.data, msg.size};
-    throw std::runtime_error(errorMessageWithCode);
+    throw errors::OpenAssetIOException(errorMessageWithCode);
   }
 }
 
diff --git a/src/openassetio-core-c/src/managerApi/CManagerInterfaceAdapter.cpp b/src/openassetio-core-c/src/managerApi/CManagerInterfaceAdapter.cpp
index dfcad499e..380398040 100644
--- a/src/openassetio-core-c/src/managerApi/CManagerInterfaceAdapter.cpp
+++ b/src/openassetio-core-c/src/managerApi/CManagerInterfaceAdapter.cpp
@@ -6,6 +6,7 @@
 
 #include "CManagerInterfaceAdapter.hpp"
 
+#include <openassetio/errors/exceptions.hpp>
 #include "../errors.hpp"
 #include "../handles/InfoDictionary.hpp"
 
@@ -82,7 +83,7 @@ InfoDictionary CManagerInterfaceAdapter::info() {
 
 void CManagerInterfaceAdapter::initialize([[maybe_unused]] InfoDictionary managerSettings,
                                           [[maybe_unused]] const HostSessionPtr& hostSession) {
-  throw std::runtime_error{"Not implemented"};
+  throw errors::NotImplementedException{"Not implemented"};
 }
 
 trait::TraitsDatas CManagerInterfaceAdapter::managementPolicy(
@@ -90,12 +91,12 @@ trait::TraitsDatas CManagerInterfaceAdapter::managementPolicy(
     [[maybe_unused]] const access::PolicyAccess policyAccess,
     [[maybe_unused]] const ContextConstPtr& context,
     [[maybe_unused]] const HostSessionPtr& hostSession) {
-  throw std::runtime_error{"Not implemented"};
+  throw errors::NotImplementedException{"Not implemented"};
 }
 
 bool CManagerInterfaceAdapter::isEntityReferenceString(
     [[maybe_unused]] const Str& someString, [[maybe_unused]] const HostSessionPtr& hostSession) {
-  throw std::runtime_error{"Not implemented"};
+  throw errors::NotImplementedException{"Not implemented"};
 }
 
 void CManagerInterfaceAdapter::entityExists(
@@ -104,7 +105,7 @@ void CManagerInterfaceAdapter::entityExists(
     [[maybe_unused]] const HostSessionPtr& hostSession,
     [[maybe_unused]] const ManagerInterface::ExistsSuccessCallback& successCallback,
     [[maybe_unused]] const ManagerInterface::BatchElementErrorCallback& errorCallback) {
-  throw std::runtime_error{"Not implemented"};
+  throw errors::NotImplementedException{"Not implemented"};
 }
 
 void CManagerInterfaceAdapter::resolve(
@@ -115,7 +116,7 @@ void CManagerInterfaceAdapter::resolve(
     [[maybe_unused]] const HostSessionPtr& hostSession,
     [[maybe_unused]] const ManagerInterface::ResolveSuccessCallback& successCallback,
     [[maybe_unused]] const BatchElementErrorCallback& errorCallback) {
-  throw std::runtime_error{"Not implemented"};
+  throw errors::NotImplementedException{"Not implemented"};
 }
 
 void CManagerInterfaceAdapter::preflight(
@@ -126,7 +127,7 @@ void CManagerInterfaceAdapter::preflight(
     [[maybe_unused]] const HostSessionPtr& hostSession,
     [[maybe_unused]] const PreflightSuccessCallback& successCallback,
     [[maybe_unused]] const BatchElementErrorCallback& errorCallback) {
-  throw std::runtime_error{"Not implemented"};
+  throw errors::NotImplementedException{"Not implemented"};
 }
 
 void CManagerInterfaceAdapter::register_(
@@ -137,7 +138,7 @@ void CManagerInterfaceAdapter::register_(
     [[maybe_unused]] const HostSessionPtr& hostSession,
     [[maybe_unused]] const RegisterSuccessCallback& successCallback,
     [[maybe_unused]] const BatchElementErrorCallback& errorCallback) {
-  throw std::runtime_error{"Not implemented"};
+  throw errors::NotImplementedException{"Not implemented"};
 }
 
 }  // namespace managerApi
diff --git a/src/openassetio-core/include/openassetio/BatchElementError.hpp b/src/openassetio-core/include/openassetio/errors/BatchElementError.hpp
similarity index 99%
rename from src/openassetio-core/include/openassetio/BatchElementError.hpp
rename to src/openassetio-core/include/openassetio/errors/BatchElementError.hpp
index d3ea61374..262bb157a 100644
--- a/src/openassetio-core/include/openassetio/BatchElementError.hpp
+++ b/src/openassetio-core/include/openassetio/errors/BatchElementError.hpp
@@ -7,7 +7,7 @@
 #include <string>
 #include <utility>
 
-#include <openassetio/errors.h>
+#include <openassetio/errors/errors.h>
 #include <openassetio/export.h>
 #include <openassetio/typedefs.hpp>
 
diff --git a/src/openassetio-core/include/openassetio/errors.h b/src/openassetio-core/include/openassetio/errors/errors.h
similarity index 100%
rename from src/openassetio-core/include/openassetio/errors.h
rename to src/openassetio-core/include/openassetio/errors/errors.h
diff --git a/src/openassetio-core/include/openassetio/errors/exceptions.hpp b/src/openassetio-core/include/openassetio/errors/exceptions.hpp
new file mode 100644
index 000000000..225549410
--- /dev/null
+++ b/src/openassetio-core/include/openassetio/errors/exceptions.hpp
@@ -0,0 +1,73 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright 2022 The Foundry Visionmongers Ltd
+#pragma once
+
+#include <stdexcept>
+#include <string>
+
+#include <openassetio/export.h>
+#include <openassetio/typedefs.hpp>
+
+namespace openassetio {
+inline namespace OPENASSETIO_CORE_ABI_VERSION {
+namespace errors {
+/**
+ * @name OpenassetIO Exceptions
+ *
+ * @{
+ */
+
+/**
+ * Exception base for all OpenAssetIO exceptions.
+ *
+ * Should normally not be constructed directly, favour the more fully
+ * derived exceptions instead.
+ */
+struct OPENASSETIO_CORE_EXPORT OpenAssetIOException : std::runtime_error {
+  using std::runtime_error::runtime_error;
+};
+
+/**
+ * Thrown whenever the input to a public API function is invalid for the
+ * requested operation.
+ */
+struct OPENASSETIO_CORE_EXPORT InputValidationException : OpenAssetIOException {
+  using OpenAssetIOException::OpenAssetIOException;
+};
+
+/**
+ * A special case of InputValidationException for cases where the input
+ * comes from external config, rather than function arguments.
+ *
+ * Thrown whenever a procedure must abort due to misconfigured
+ * user-provided configuration, often relating to the plugin system.
+ */
+struct OPENASSETIO_CORE_EXPORT ConfigurationException : InputValidationException {
+  using InputValidationException::InputValidationException;
+};
+
+/**
+ * Thrown whenever a procedure must abort due to not being implemented.
+ * Many methods in OpenAssetIO are optionally implementable, and some
+ * may throw this exception to indicate that calling them constitutes
+ * an error.
+ */
+struct OPENASSETIO_CORE_EXPORT NotImplementedException : OpenAssetIOException {
+  using OpenAssetIOException::OpenAssetIOException;
+};
+
+/**
+ * Exceptions emitted from manager plugins that are not handled will
+ * be converted to this type and re-thrown when the exception passes
+ * through the OpenAssetIO middleware.
+ */
+struct OPENASSETIO_CORE_EXPORT UnhandledException : OpenAssetIOException {
+  using OpenAssetIOException::OpenAssetIOException;
+};
+
+/**
+ * @}
+ */
+}  // namespace errors
+}  // namespace OPENASSETIO_CORE_ABI_VERSION
+}  // namespace openassetio
diff --git a/src/openassetio-core/include/openassetio/hostApi/Manager.hpp b/src/openassetio-core/include/openassetio/hostApi/Manager.hpp
index 4ced340d1..ee2ec6079 100644
--- a/src/openassetio-core/include/openassetio/hostApi/Manager.hpp
+++ b/src/openassetio-core/include/openassetio/hostApi/Manager.hpp
@@ -9,10 +9,11 @@
 #include <vector>
 
 #include <openassetio/export.h>
-#include <openassetio/BatchElementError.hpp>
 #include <openassetio/EntityReference.hpp>
 #include <openassetio/InfoDictionary.hpp>
 #include <openassetio/access.hpp>
+#include <openassetio/errors/BatchElementError.hpp>
+#include <openassetio/errors/exceptions.hpp>
 #include <openassetio/hostApi/EntityReferencePager.hpp>
 #include <openassetio/trait/collection.hpp>
 #include <openassetio/typedefs.hpp>
@@ -524,8 +525,8 @@ class OPENASSETIO_CORE_EXPORT Manager final {
    *
    * @return Validated entity reference object.
    *
-   * @throw std::domain_error If the given string is not recognized as
-   * an entity reference by this manager.
+   * @throw errors::InputValidationException If the given string is not
+   * recognized as an entity reference by this manager.
    *
    * @todo Use a custom exception type rather than std::domain_error.
    */
diff --git a/src/openassetio-core/include/openassetio/managerApi/ManagerInterface.hpp b/src/openassetio-core/include/openassetio/managerApi/ManagerInterface.hpp
index 208f79352..1084a13f1 100644
--- a/src/openassetio-core/include/openassetio/managerApi/ManagerInterface.hpp
+++ b/src/openassetio-core/include/openassetio/managerApi/ManagerInterface.hpp
@@ -9,10 +9,10 @@
 #include <vector>
 
 #include <openassetio/export.h>
-#include <openassetio/BatchElementError.hpp>
 #include <openassetio/EntityReference.hpp>
 #include <openassetio/InfoDictionary.hpp>
 #include <openassetio/access.hpp>
+#include <openassetio/errors/BatchElementError.hpp>
 #include <openassetio/trait/collection.hpp>
 #include <openassetio/typedefs.hpp>
 
@@ -92,7 +92,7 @@ OPENASSETIO_DECLARE_PTR(ManagerInterface)
  * kind will be correctly passed across the plug-in C boundary,
  * and re-thrown. Other exceptions should not be used.
  *
- *  @see @ref openassetio.exceptions "exceptions"
+ *  @see @ref openassetio.errors "errors"
  *
  * Threading
  * ---------
diff --git a/src/openassetio-core/src/hostApi/Manager.cpp b/src/openassetio-core/src/hostApi/Manager.cpp
index 1c7595414..8ac666fa3 100644
--- a/src/openassetio-core/src/hostApi/Manager.cpp
+++ b/src/openassetio-core/src/hostApi/Manager.cpp
@@ -41,7 +41,8 @@ void throwFromBatchElementError(std::size_t index, BatchElementError error) {
       exceptionMessage += std::to_string(static_cast<int>(error.code));
       exceptionMessage += " Message: ";
       exceptionMessage += error.message;
-      throw std::out_of_range{exceptionMessage};
+      error.message = std::move(exceptionMessage);
+      throw UnknownBatchElementException{index, std::move(error)};
   }
 }
 
@@ -156,7 +157,8 @@ const Str kCreateEntityReferenceErrorMessage = "Invalid entity reference: ";
 
 EntityReference Manager::createEntityReference(Str entityReferenceString) {
   if (!isEntityReferenceString(entityReferenceString)) {
-    throw std::domain_error{kCreateEntityReferenceErrorMessage + entityReferenceString};
+    throw errors::InputValidationException{kCreateEntityReferenceErrorMessage +
+                                           entityReferenceString};
   }
   return EntityReference{std::move(entityReferenceString)};
 }
@@ -300,7 +302,7 @@ void Manager::getWithRelationshipPaged(
     const Manager::BatchElementErrorCallback &errorCallback,
     const trait::TraitSet &resultTraitSet) {
   if (pageSize == 0) {
-    throw std::out_of_range{"pageSize must be greater than zero."};
+    throw errors::InputValidationException{"pageSize must be greater than zero."};
   }
 
   /* The ManagerInterface signature provides an `EntityReferencePagerInterfacePtr`
@@ -327,7 +329,7 @@ void Manager::getWithRelationshipsPaged(
     const Manager::BatchElementErrorCallback &errorCallback,
     const trait::TraitSet &resultTraitSet) {
   if (pageSize == 0) {
-    throw std::out_of_range{"pageSize must be greater than zero."};
+    throw errors::InputValidationException{"pageSize must be greater than zero."};
   }
 
   /* The ManagerInterface signature provides an `EntityReferencePagerInterfacePtr`
@@ -359,7 +361,7 @@ void Manager::preflight(const EntityReferences &entityReferences,
     message += " entity references vs. ";
     message += std::to_string(traitsHints.size());
     message += " traits hints.";
-    throw std::out_of_range{message};
+    throw errors::InputValidationException{message};
   }
   managerInterface_->preflight(entityReferences, traitsHints, publishingAccess, context,
                                hostSession_, successCallback, errorCallback);
@@ -449,7 +451,7 @@ void Manager::register_(const EntityReferences &entityReferences,
     message += " entity references vs. ";
     message += std::to_string(entityTraitsDatas.size());
     message += " traits datas.";
-    throw std::out_of_range{message};
+    throw errors::InputValidationException{message};
   }
   return managerInterface_->register_(entityReferences, entityTraitsDatas, publishingAccess,
                                       context, hostSession_, successCallback, errorCallback);
diff --git a/src/openassetio-core/src/hostApi/ManagerFactory.cpp b/src/openassetio-core/src/hostApi/ManagerFactory.cpp
index c57d5911d..534f78cbf 100644
--- a/src/openassetio-core/src/hostApi/ManagerFactory.cpp
+++ b/src/openassetio-core/src/hostApi/ManagerFactory.cpp
@@ -6,6 +6,7 @@
 
 #include <toml++/toml.h>
 
+#include <openassetio/errors/exceptions.hpp>
 #include <openassetio/hostApi/HostInterface.hpp>
 #include <openassetio/hostApi/Manager.hpp>
 #include <openassetio/hostApi/ManagerFactory.hpp>
@@ -121,11 +122,17 @@ ManagerPtr ManagerFactory::defaultManagerForInterface(
     Str msg = "Could not load default manager config from '";
     msg += configPath;
     msg += "', file does not exist.";
-    throw std::runtime_error(msg);
+    throw errors::InputValidationException(msg);
   }
 
-  auto config = toml::parse_file(configPath);
-
+  toml::parse_result config;
+  try {
+    config = toml::parse_file(configPath);
+  } catch (const std::exception& exc) {
+    std::string msg = "Error parsing config file. ";
+    msg += exc.what();
+    throw errors::ConfigurationException{msg};
+  }
   const std::string_view identifier = config["manager"]["identifier"].value_or("");
 
   // Function to substitute ${config_dir} with the absolute,
@@ -161,7 +168,7 @@ ManagerPtr ManagerFactory::defaultManagerForInterface(
         Str msg = "Unsupported value type for '";
         msg += key.str();
         msg += "'.";
-        throw std::runtime_error(msg);
+        throw errors::ConfigurationException(msg);
       }
     }
   }
diff --git a/src/openassetio-core/src/managerApi/ManagerInterface.cpp b/src/openassetio-core/src/managerApi/ManagerInterface.cpp
index 47406c211..a920078c9 100644
--- a/src/openassetio-core/src/managerApi/ManagerInterface.cpp
+++ b/src/openassetio-core/src/managerApi/ManagerInterface.cpp
@@ -3,10 +3,10 @@
 #include <stdexcept>
 
 #include <openassetio/TraitsData.hpp>
+#include <openassetio/errors/exceptions.hpp>
 #include <openassetio/hostApi/EntityReferencePager.hpp>
 #include <openassetio/managerApi/EntityReferencePagerInterface.hpp>
 #include <openassetio/managerApi/ManagerInterface.hpp>
-
 namespace openassetio {
 inline namespace OPENASSETIO_CORE_ABI_VERSION {
 namespace managerApi {
@@ -34,20 +34,20 @@ ManagerStateBasePtr ManagerInterface::createState(
 ManagerStateBasePtr ManagerInterface::createChildState(
     [[maybe_unused]] const ManagerStateBasePtr& parentState,
     [[maybe_unused]] const HostSessionPtr& hostSession) {
-  throw std::runtime_error(
+  throw errors::NotImplementedException(
       "createChildState called on a manager that does not implement a custom state.");
 }
 
 Str ManagerInterface::persistenceTokenForState(
     [[maybe_unused]] const ManagerStateBasePtr& state,
     [[maybe_unused]] const HostSessionPtr& hostSession) {
-  throw std::runtime_error(
+  throw errors::NotImplementedException(
       "persistenceTokenForState called on a manager that does not implement a custom state.");
 }
 
 ManagerStateBasePtr ManagerInterface::stateFromPersistenceToken(
     [[maybe_unused]] const Str& token, [[maybe_unused]] const HostSessionPtr& hostSession) {
-  throw std::runtime_error(
+  throw errors::NotImplementedException(
       "stateFromPersistenceToken called on a manager that does not implement a custom state.");
 }
 
diff --git a/src/openassetio-python/bridge/src/python/converter.cpp b/src/openassetio-python/bridge/src/python/converter.cpp
index 7d7af0d53..f11b351f1 100644
--- a/src/openassetio-python/bridge/src/python/converter.cpp
+++ b/src/openassetio-python/bridge/src/python/converter.cpp
@@ -6,6 +6,7 @@
 
 #include <openassetio/Context.hpp>
 #include <openassetio/TraitsData.hpp>
+#include <openassetio/errors/exceptions.hpp>
 #include <openassetio/hostApi/HostInterface.hpp>
 #include <openassetio/hostApi/Manager.hpp>
 #include <openassetio/hostApi/ManagerFactory.hpp>
@@ -44,7 +45,7 @@ PyObject* castToPyObject(const T& objectPtr) {
   const py::error_scope castErrorState;
 
   if (castErrorState.value != nullptr) {
-    throw std::runtime_error(py::str(castErrorState.value));
+    throw errors::InputValidationException(py::str(castErrorState.value));
   }
 
   // `release()` to avoid decrementing the PyObject refcount on leaving
@@ -55,7 +56,7 @@ PyObject* castToPyObject(const T& objectPtr) {
 template <typename T>
 typename T::Ptr castFromPyObject(PyObject* pyObject) {
   if (pyObject == nullptr) {
-    throw std::invalid_argument(
+    throw errors::InputValidationException(
         "Attempting to cast a nullptr PyObject in "
         "openassetio::python::converter::castFromPyObject");
   }
@@ -72,7 +73,7 @@ typename T::Ptr castFromPyObject(PyObject* pyObject) {
     return pointers::createPyRetainingPtr<typename T::Ptr>(pyInstance, cppInstancePtr);
   } catch (const py::cast_error& castError) {
     // Rethrow here to avoid bleeding pybind exception dependencies.
-    throw std::invalid_argument(castError.what());
+    throw errors::InputValidationException(castError.what());
   }
 }
 
diff --git a/src/openassetio-python/cmodule/CMakeLists.txt b/src/openassetio-python/cmodule/CMakeLists.txt
index 63e7e7d3a..e0e884aa4 100644
--- a/src/openassetio-python/cmodule/CMakeLists.txt
+++ b/src/openassetio-python/cmodule/CMakeLists.txt
@@ -43,6 +43,7 @@ target_sources(
     src/_openassetio.cpp
     src/accessBinding.cpp
     src/constantsBinding.cpp
+    src/errorsBinding.cpp
     src/BatchElementErrorBinding.cpp
     src/ContextBinding.cpp
     src/EntityReferenceBinding.cpp
diff --git a/src/openassetio-python/cmodule/src/BatchElementErrorBinding.cpp b/src/openassetio-python/cmodule/src/BatchElementErrorBinding.cpp
index df39e9374..abb855eb8 100644
--- a/src/openassetio-python/cmodule/src/BatchElementErrorBinding.cpp
+++ b/src/openassetio-python/cmodule/src/BatchElementErrorBinding.cpp
@@ -6,7 +6,7 @@
 #include <pybind11/operators.h>
 #include <pybind11/pybind11.h>
 
-#include <openassetio/BatchElementError.hpp>
+#include <openassetio/errors/BatchElementError.hpp>
 
 #include "_openassetio.hpp"
 
diff --git a/src/openassetio-python/cmodule/src/_openassetio.cpp b/src/openassetio-python/cmodule/src/_openassetio.cpp
index d6cb870c0..fbb4f5b13 100644
--- a/src/openassetio-python/cmodule/src/_openassetio.cpp
+++ b/src/openassetio-python/cmodule/src/_openassetio.cpp
@@ -17,6 +17,7 @@ PYBIND11_MODULE(_openassetio, mod) {
   const py::module hostApi = mod.def_submodule("hostApi");
   const py::module log = mod.def_submodule("log");
   const py::module constants = mod.def_submodule("constants");
+  const py::module errors = mod.def_submodule("errors");
 
   registerAccess(access);
   registerConstants(constants);
@@ -27,6 +28,7 @@ PYBIND11_MODULE(_openassetio, mod) {
   registerManagerStateBase(managerApi);
   registerContext(mod);
   registerBatchElementError(mod);
+  registerErrors(errors);
   registerEntityReference(mod);
   registerHostInterface(hostApi);
   registerHost(managerApi);
diff --git a/src/openassetio-python/cmodule/src/_openassetio.hpp b/src/openassetio-python/cmodule/src/_openassetio.hpp
index 836d03a78..9f766b371 100644
--- a/src/openassetio-python/cmodule/src/_openassetio.hpp
+++ b/src/openassetio-python/cmodule/src/_openassetio.hpp
@@ -115,6 +115,9 @@ void registerEntityReference(const py::module& mod);
 /// Register the BatchElementError type with Python.
 void registerBatchElementError(py::module& mod);
 
+// Register errors, linking exceptions to/from Python.
+void registerErrors(const py::module& mod);
+
 /// Register the EntityReferencePager class with Python.
 void registerEntityReferencePager(const py::module& mod);
 
diff --git a/src/openassetio-python/cmodule/src/errorsBinding.cpp b/src/openassetio-python/cmodule/src/errorsBinding.cpp
new file mode 100644
index 000000000..211967832
--- /dev/null
+++ b/src/openassetio-python/cmodule/src/errorsBinding.cpp
@@ -0,0 +1,76 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright 2013-2023 The Foundry Visionmongers Ltd
+#include <string_view>
+
+#include <pybind11/pybind11.h>
+
+#include <openassetio/errors/exceptions.hpp>
+
+#include "_openassetio.hpp"
+
+void registerErrors(const py::module &mod) {
+  using openassetio::errors::ConfigurationException;
+  using openassetio::errors::InputValidationException;
+  using openassetio::errors::NotImplementedException;
+  using openassetio::errors::OpenAssetIOException;
+  using openassetio::errors::UnhandledException;
+
+  // Register a new exception type. Note that this is not sufficient to
+  // cause C++ exceptions to be translated. See
+  // `register_exception_translator` below.
+  const auto registerPyException = [&mod](const char *pyTypeName, const py::handle &base) {
+    return py::exception<void /* unused */>{mod, pyTypeName, base};
+  };
+
+  // Register our OpenAssetIO exception types. Since these exceptions
+  // simply take a string message and no other data, we can make use
+  // of pybind11's limited custom exception support.
+
+  const py::object pyOpenAssetIOException =
+      registerPyException("OpenAssetIOException", PyExc_RuntimeError);
+  // NOLINTNEXTLINE(bugprone-throw-keyword-missing)
+  registerPyException("UnhandledException", pyOpenAssetIOException);
+  // NOLINTNEXTLINE(bugprone-throw-keyword-missing)
+  registerPyException("NotImplementedException", pyOpenAssetIOException);
+  const py::object pyInputValidationException =
+      registerPyException("InputValidationException", pyOpenAssetIOException);
+  // NOLINTNEXTLINE(bugprone-throw-keyword-missing)
+  registerPyException("ConfigurationException", pyInputValidationException);
+
+  // Register a function that will translate our C++ exceptions to the
+  // appropriate Python exception type.
+  //
+  // Note that capturing lambdas are not allowed here, so we must
+  // `import` the exception type in the body of the function.
+  py::register_exception_translator([](std::exception_ptr pexc) {
+    if (!pexc) {
+      return;
+    }
+    const py::module_ pyModule = py::module_::import("openassetio._openassetio.errors");
+
+    // Use CPython's PyErr_SetObject to set a custom exception type as
+    // the currently active Python exception in this thread.
+    const auto setPyException = [&pyModule](const char *pyTypeName, const auto &exc) {
+      const py::object pyClass = pyModule.attr(pyTypeName);
+      const py::object pyInstance = pyClass(exc.what());
+      PyErr_SetObject(pyClass.ptr(), pyInstance.ptr());
+    };
+
+    // Handle the different possible C++ exceptions, creating the
+    // corresponding Python exception and setting it as the active
+    // exception in this thread.
+    try {
+      std::rethrow_exception(std::move(pexc));
+    } catch (const ConfigurationException &exc) {
+      setPyException("ConfigurationException", exc);
+    } catch (const InputValidationException &exc) {
+      setPyException("InputValidationException", exc);
+    } catch (const NotImplementedException &exc) {
+      setPyException("NotImplementedException", exc);
+    } catch (const UnhandledException &exc) {
+      setPyException("UnhandledException", exc);
+    } catch (const OpenAssetIOException &exc) {
+      setPyException("OpenAssetIOException", exc);
+    }
+  });
+}
diff --git a/src/openassetio-python/cmodule/src/hostApi/EntityReferencePagerBinding.cpp b/src/openassetio-python/cmodule/src/hostApi/EntityReferencePagerBinding.cpp
index b0fe723e4..43219f4f8 100644
--- a/src/openassetio-python/cmodule/src/hostApi/EntityReferencePagerBinding.cpp
+++ b/src/openassetio-python/cmodule/src/hostApi/EntityReferencePagerBinding.cpp
@@ -5,9 +5,9 @@
 #include <pybind11/functional.h>
 #include <pybind11/stl.h>
 
-#include <openassetio/BatchElementError.hpp>
 #include <openassetio/Context.hpp>
 #include <openassetio/TraitsData.hpp>
+#include <openassetio/errors/BatchElementError.hpp>
 #include <openassetio/hostApi/EntityReferencePager.hpp>
 #include <openassetio/managerApi/EntityReferencePagerInterface.hpp>
 #include <openassetio/managerApi/HostSession.hpp>
diff --git a/src/openassetio-python/cmodule/src/hostApi/ManagerBinding.cpp b/src/openassetio-python/cmodule/src/hostApi/ManagerBinding.cpp
index ea9b5f98c..a821f9bab 100644
--- a/src/openassetio-python/cmodule/src/hostApi/ManagerBinding.cpp
+++ b/src/openassetio-python/cmodule/src/hostApi/ManagerBinding.cpp
@@ -5,9 +5,10 @@
 #include <pybind11/functional.h>
 #include <pybind11/stl.h>
 
-#include <openassetio/BatchElementError.hpp>
 #include <openassetio/Context.hpp>
 #include <openassetio/TraitsData.hpp>
+#include <openassetio/errors/BatchElementError.hpp>
+#include <openassetio/errors/exceptions.hpp>
 #include <openassetio/hostApi/Manager.hpp>
 #include <openassetio/managerApi/HostSession.hpp>
 #include <openassetio/managerApi/ManagerInterface.hpp>
@@ -26,7 +27,7 @@ void validateTraitsDatas(const TraitsDatas& traitsDatas) {
   // does not contain any `None` elements, so we must add our
   // own check here.
   if (std::any_of(traitsDatas.begin(), traitsDatas.end(), std::logical_not<TraitsDataPtr>{})) {
-    throw pybind11::type_error{"Traits data cannot be None"};
+    throw openassetio::errors::InputValidationException{"Traits data cannot be None"};
   }
 }
 }  // namespace
diff --git a/src/openassetio-python/package/openassetio/errors.py b/src/openassetio-python/package/openassetio/errors.py
new file mode 100644
index 000000000..b7242ea71
--- /dev/null
+++ b/src/openassetio-python/package/openassetio/errors.py
@@ -0,0 +1,28 @@
+#
+#   Copyright 2013-2023 The Foundry Visionmongers Ltd
+#
+#   Licensed 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.
+#
+"""
+@namespace openassetio.errors
+Provides exception objects to facilitate error handling in OpenAssetIO
+"""
+
+from . import _openassetio  # pylint: disable=no-name-in-module
+
+
+OpenAssetIOException = _openassetio.errors.OpenAssetIOException
+InputValidationException = _openassetio.errors.InputValidationException
+ConfigurationException = _openassetio.errors.ConfigurationException
+NotImplementedException = _openassetio.errors.NotImplementedException
+UnhandledException = _openassetio.errors.UnhandledException
diff --git a/src/openassetio-python/package/openassetio/exceptions.py b/src/openassetio-python/package/openassetio/exceptions.py
deleted file mode 100644
index 28a5abe89..000000000
--- a/src/openassetio-python/package/openassetio/exceptions.py
+++ /dev/null
@@ -1,191 +0,0 @@
-#
-#   Copyright 2013-2021 The Foundry Visionmongers Ltd
-#
-#   Licensed 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.
-#
-"""
-@namespace openassetio.exceptions
-Defines exceptions used in the OpenAssetIO codebase.
-
-@todo [tc] Should these all live here, or in their respective homes
-eg: pluginSystem, ManagerInterface, etc.
-"""
-
-
-class OpenAssetIOException(RuntimeError):
-    """
-    The OpenAssetIOException class should be used for all exceptions raise by
-    Managers and any API-related exceptions raised in a Host. These
-    exceptions are guaranteed to properly bridge across the C plugin
-    divide...
-
-    @unstable
-    """
-
-
-class UserCanceled(OpenAssetIOException):
-    """
-    Thrown by the progress mechanism to interrupt execution whenever the
-    user cancels an action (perhaps using an on-screen button).
-
-    @unstable
-    """
-
-    def __str__(self):
-        return "Operation Canceled"
-
-
-##
-# @name Manager related Exceptions
-#
-## @{
-
-
-class ManagerException(OpenAssetIOException):
-    """
-    A base class for exceptions relating to, or raised by a manager.
-
-    @unstable
-    """
-
-
-class StateError(ManagerException):
-    """
-    Thrown by managers in error situations relating to the
-    managerState object.
-
-    @unstable
-    """
-
-
-class RetryableError(ManagerException):
-    """
-    Thrown by managers in error situations that can be safely retried
-    with idempotent behavior.
-
-    @unstable
-    """
-
-
-## @}
-
-
-##
-# @name Entity related Exceptions
-#
-## @{
-
-
-class BaseEntityException(ManagerException):
-    """
-    A base Exception for any @ref entity related errors to ensure
-    consistent presentation and encapsulation of the associated @ref
-    entity_reference.
-
-    @unstable
-    """
-
-    def __init__(self, message, entityReference=None):
-        """
-        @param message str, The message of the exception.
-
-        @param entityReference @fqref{EntityReference} "EntityReference"
-        The entity reference associated with the error. This should be
-        provided wherever known, and will be printed along with the
-        message in any traceback/etc... As such, there is no need to
-        embedded the entity reference in the message when using this
-        exception type.
-        """
-        super(BaseEntityException, self).__init__(message)
-        self.ref = entityReference
-
-    def __str__(self):
-        string = OpenAssetIOException.__str__(self)
-        return "%s (%s)" % (string, self.ref)
-
-
-class InvalidEntityReference(BaseEntityException):
-    """
-    Thrown whenever an Entity-based action is performed on an
-    unrecognized @ref entity_reference.
-
-    @unstable
-    """
-
-    def __init__(self, message="Invalid Entity Reference", entityReference=None):
-        super(InvalidEntityReference, self).__init__(message, entityReference)
-
-
-class MalformedEntityReference(BaseEntityException):
-    """
-    Thrown whenever an Entity-based action is performed on a
-    malformed @ref entity_reference.
-
-    @unstable
-    """
-
-    def __init__(self, message="Malformed Entity Reference", entityReference=None):
-        super(MalformedEntityReference, self).__init__(message, entityReference)
-
-
-class EntityResolutionError(BaseEntityException):
-    """
-    Thrown during @ref entity resolution,  if the Entity is valid, but
-    has no meaningful data to be resolved, or it can't be retrieved for
-    some other reason. It is also used during version finalisation and
-    any other entity-based operations on a valid @ref entity_reference
-    that fail for some reason.
-
-    @unstable
-    """
-
-    def __init__(self, message="Error resolving entity", entityReference=None):
-        super(EntityResolutionError, self).__init__(message, entityReference)
-
-
-class BaseEntityInteractionError(BaseEntityException):
-    """
-    A base class for errors relating to entity-centric actions.
-
-    @unstable
-    """
-
-
-class PreflightError(BaseEntityInteractionError):
-    """
-    Thrown to represent some error during pre-flight that isn't due to
-    any specific of the @ref entity_reference itself.
-
-    @unstable
-    """
-
-
-class RegistrationError(BaseEntityInteractionError):
-    """
-    Thrown to represent some error during registration that isn't due to
-    any specific of the @ref entity_reference itself.
-
-    @unstable
-    """
-
-
-## @}
-
-
-class PluginError(OpenAssetIOException):
-    """
-    Thrown by the plugin system in relation to errors encountered
-    during the loading/initialization of plugins.
-
-    @unstable
-    """
diff --git a/src/openassetio-python/package/openassetio/pluginSystem/PythonPluginSystem.py b/src/openassetio-python/package/openassetio/pluginSystem/PythonPluginSystem.py
index 550c01d12..26c8f6a68 100644
--- a/src/openassetio-python/package/openassetio/pluginSystem/PythonPluginSystem.py
+++ b/src/openassetio-python/package/openassetio/pluginSystem/PythonPluginSystem.py
@@ -24,7 +24,7 @@
 import sys
 import traceback
 
-from .. import exceptions
+from ..errors import InputValidationException
 
 
 __all__ = ["PythonPluginSystem"]
@@ -72,12 +72,10 @@ def scan(self, paths):
         self.__logger.debug(f"PythonPluginSystem: Searching {paths}")
 
         for path in paths.split(os.pathsep):
-
             if not os.path.isdir(path):
                 self.__logger.debug(f"PythonPluginSystem: Skipping as not a directory {path}")
 
             for item in os.listdir(path):
-
                 itemPath = os.path.join(path, item)
 
                 if os.path.isdir(itemPath):
@@ -138,7 +136,6 @@ def scan_entry_points(self, entryPointName):
         )
 
         for entryPoint in importlib_metadata.entry_points(group=entryPointName):
-
             self.__logger.debug(f"PythonPluginSystem: Found entry point in {entryPoint.name}")
             try:
                 module = entryPoint.load()
@@ -176,13 +173,13 @@ def plugin(self, identifier):
         @return @ref openassetio.pluginSystem.PythonPluginSystemPlugin
         "PythonPluginSystemPlugin"
 
-        @exception openassetio.exceptions.PluginError Raised if no
-        plugin provides the specified identifier.
+        @exception openassetio.errors.ConfigurationException Raised if
+        no plugin provides the specified identifier.
         """
 
         if identifier not in self.__map:
             msg = "PythonPluginSystem: No plug-in registered with the identifier '%s'" % identifier
-            raise exceptions.PluginError(msg)
+            raise InputValidationException(msg)
 
         return self.__map[identifier]
 
@@ -228,7 +225,6 @@ def __load(self, path):
         moduleName = hashlib.md5(path.encode("utf-8")).hexdigest()
 
         try:
-
             spec = importlib.util.spec_from_file_location(moduleName, path)
             if spec is None:
                 raise RuntimeError("Unable to determine module spec")
diff --git a/src/openassetio-python/package/openassetio/pluginSystem/PythonPluginSystemManagerPlugin.py b/src/openassetio-python/package/openassetio/pluginSystem/PythonPluginSystemManagerPlugin.py
index ddf25a594..d34f795b7 100644
--- a/src/openassetio-python/package/openassetio/pluginSystem/PythonPluginSystemManagerPlugin.py
+++ b/src/openassetio-python/package/openassetio/pluginSystem/PythonPluginSystemManagerPlugin.py
@@ -20,6 +20,7 @@
 """
 
 from .PythonPluginSystemPlugin import PythonPluginSystemPlugin
+from ..errors import NotImplementedException
 
 
 # As this is an abstract interface, these are expected
@@ -66,7 +67,7 @@ def identifier():
 
         @see @fqref{managerApi.ManagerInterface} "ManagerInterface"
         """
-        raise NotImplementedError
+        raise NotImplementedException("identifier not implemented")
 
     @classmethod
     def interface(cls):
@@ -86,7 +87,7 @@ def interface(cls):
 
         @return ManagerInterface instance
         """
-        raise NotImplementedError
+        raise NotImplementedException("interface not implemented")
 
     @classmethod
     def uiDelegate(cls, interfaceInstance):
diff --git a/src/openassetio-python/package/openassetio/pluginSystem/PythonPluginSystemPlugin.py b/src/openassetio-python/package/openassetio/pluginSystem/PythonPluginSystemPlugin.py
index 9766580a0..a27d2fe84 100644
--- a/src/openassetio-python/package/openassetio/pluginSystem/PythonPluginSystemPlugin.py
+++ b/src/openassetio-python/package/openassetio/pluginSystem/PythonPluginSystemPlugin.py
@@ -18,6 +18,8 @@
 A single-class module, providing the PythonPluginSystemPlugin class.
 """
 
+from ..errors import NotImplementedException
+
 
 class PythonPluginSystemPlugin(object):
     """
@@ -36,4 +38,4 @@ def identifier(cls):
         same identifier, the first one encountered will be used, and all
         others will be ignored.
         """
-        raise NotImplementedError
+        raise NotImplementedException("identifier not implemented")


Test patch:
diff --git a/src/openassetio-core/tests/BatchElementErrorTest.cpp b/src/openassetio-core/tests/BatchElementErrorTest.cpp
index 6374809ea..fb95ec11b 100644
--- a/src/openassetio-core/tests/BatchElementErrorTest.cpp
+++ b/src/openassetio-core/tests/BatchElementErrorTest.cpp
@@ -5,7 +5,7 @@
 
 #include <catch2/catch.hpp>
 
-#include <openassetio/BatchElementError.hpp>
+#include <openassetio/errors/BatchElementError.hpp>
 
 using openassetio::BatchElementError;
 
diff --git a/src/openassetio-python/package/openassetio/test/manager/harness.py b/src/openassetio-python/package/openassetio/test/manager/harness.py
index 4e15efef5..d4553340c 100644
--- a/src/openassetio-python/package/openassetio/test/manager/harness.py
+++ b/src/openassetio-python/package/openassetio/test/manager/harness.py
@@ -31,6 +31,7 @@
 import unittest
 
 from . import _implementation
+from ...errors import ConfigurationException, InputValidationException
 
 
 __all__ = ["executeSuite", "fixturesFromPyFile", "moduleFromFile", "FixtureAugmentedTestCase"]
@@ -116,7 +117,7 @@ def fixturesFromPyFile(path):
     """
     module = moduleFromFile(path)
     if not hasattr(module, "fixtures"):
-        raise RuntimeError(f"Missing top-level 'fixtures' variable in '{path}'")
+        raise ConfigurationException(f"Missing top-level 'fixtures' variable in '{path}'")
 
     return module.fixtures
 
@@ -138,7 +139,7 @@ def moduleFromFile(path):
     if not spec:
         # Standard stack-trace is unhelpful as it just says:
         #    'NoneType' has no attribute 'loader'
-        raise RuntimeError(f"Unable to parse '{path}'")
+        raise InputValidationException(f"Unable to parse '{path}'")
 
     module = importlib.util.module_from_spec(spec)
     # Without this, for package imports we get:
@@ -316,7 +317,7 @@ def collectRequiredFixtures(self, fixtureNames, skipTestIfMissing=False):
         to skip/fail a whole bunch of tests when fixtures are missing.
         """
         values = self.requireFixtures(fixtureNames, skipTestIfMissing)
-        for (name, value) in zip(fixtureNames, values):
+        for name, value in zip(fixtureNames, values):
             setattr(self, name, value)
 
     def requireFixture(self, fixtureName, skipTestIfMissing=False):
diff --git a/src/openassetio-python/tests/cmodule/resources/_openassetio_test/CMakeLists.txt b/src/openassetio-python/tests/cmodule/resources/_openassetio_test/CMakeLists.txt
index 5d42db22c..3345557a8 100644
--- a/src/openassetio-python/tests/cmodule/resources/_openassetio_test/CMakeLists.txt
+++ b/src/openassetio-python/tests/cmodule/resources/_openassetio_test/CMakeLists.txt
@@ -33,6 +33,7 @@ target_sources(
     PRIVATE
     _openassetio_test.cpp
     PyRetainingSharedPtrTest.cpp
+    errorsTest.cpp
 )
 
 # Give access to private headers.
diff --git a/src/openassetio-python/tests/cmodule/resources/_openassetio_test/_openassetio_test.cpp b/src/openassetio-python/tests/cmodule/resources/_openassetio_test/_openassetio_test.cpp
index 6a52269c7..d046341ff 100644
--- a/src/openassetio-python/tests/cmodule/resources/_openassetio_test/_openassetio_test.cpp
+++ b/src/openassetio-python/tests/cmodule/resources/_openassetio_test/_openassetio_test.cpp
@@ -5,5 +5,9 @@
 namespace py = pybind11;
 
 void registerPyRetainingSharedPtrTestTypes(py::module_&);
+void registerExceptionThrower(py::module_& mod);
 
-PYBIND11_MODULE(_openassetio_test, mod) { registerPyRetainingSharedPtrTestTypes(mod); }
+PYBIND11_MODULE(_openassetio_test, mod) {
+  registerPyRetainingSharedPtrTestTypes(mod);
+  registerExceptionThrower(mod);
+}
diff --git a/src/openassetio-python/tests/cmodule/resources/_openassetio_test/errorsTest.cpp b/src/openassetio-python/tests/cmodule/resources/_openassetio_test/errorsTest.cpp
new file mode 100644
index 000000000..cd8fb0a41
--- /dev/null
+++ b/src/openassetio-python/tests/cmodule/resources/_openassetio_test/errorsTest.cpp
@@ -0,0 +1,79 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright 2023 The Foundry Visionmongers Ltd
+/**
+ * Bindings used for testing errors behaviour.
+ * Specifically, the conversion from cpp -> python.
+ */
+
+#include <pybind11/pybind11.h>
+
+#include <openassetio/errors/exceptions.hpp>
+namespace py = pybind11;
+
+namespace {
+// NOLINTNEXTLINE(bugprone-easily-swappable-parameters)
+void throwException(const std::string& exceptionName, const std::string& msgData = "") {
+  if (exceptionName == "OpenAssetIOException") {
+    throw openassetio::errors::OpenAssetIOException(msgData);
+  }
+  if (exceptionName == "InputValidationException") {
+    throw openassetio::errors::InputValidationException(msgData);
+  }
+  if (exceptionName == "ConfigurationException") {
+    throw openassetio::errors::ConfigurationException(msgData);
+  }
+  if (exceptionName == "NotImplementedException") {
+    throw openassetio::errors::NotImplementedException(msgData);
+  }
+  if (exceptionName == "UnhandledException") {
+    throw openassetio::errors::UnhandledException(msgData);
+  }
+}
+}  // namespace
+
+void registerExceptionThrower(py::module_& mod) {
+  mod.def("throwException", [](const std::string& exceptionName, const std::string& msgData) {
+    throwException(exceptionName, msgData);
+  });
+  mod.def(
+      "isThrownExceptionCatchableAs",
+      [](const std::string& throwExceptionName, const std::string& catchExceptionName) {
+        if (catchExceptionName == "OpenAssetIOException") {
+          try {
+            throwException(throwExceptionName);
+          } catch (const openassetio::errors::OpenAssetIOException&) {
+            return true;
+          }
+        }
+        if (catchExceptionName == "InputValidationException") {
+          try {
+            throwException(throwExceptionName);
+          } catch (const openassetio::errors::InputValidationException&) {
+            return true;
+          }
+        }
+        if (catchExceptionName == "ConfigurationException") {
+          try {
+            throwException(throwExceptionName);
+          } catch (const openassetio::errors::ConfigurationException&) {
+            return true;
+          }
+        }
+        if (catchExceptionName == "NotImplementedException") {
+          try {
+            throwException(throwExceptionName);
+          } catch (const openassetio::errors::NotImplementedException&) {
+            return true;
+          }
+        }
+        if (catchExceptionName == "UnhandledException") {
+          try {
+            throwException(throwExceptionName);
+          } catch (const openassetio::errors::UnhandledException&) {
+            return true;
+          }
+        }
+        return false;
+      },
+      py::arg("throwExceptionName"), py::arg("catchExceptionName"));
+}
diff --git a/src/openassetio-python/tests/cmodule/test_errors.py b/src/openassetio-python/tests/cmodule/test_errors.py
new file mode 100644
index 000000000..9eba41c3e
--- /dev/null
+++ b/src/openassetio-python/tests/cmodule/test_errors.py
@@ -0,0 +1,69 @@
+#
+#   Copyright 2023 The Foundry Visionmongers Ltd
+#
+#   Licensed 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.
+#
+"""
+Tests of C++ binding utilities for exception types.
+"""
+
+# pylint: disable=no-self-use
+# pylint: disable=invalid-name,redefined-outer-name
+# pylint: disable=missing-class-docstring,missing-function-docstring
+from unittest import mock
+
+import pytest
+
+from openassetio import _openassetio_test  # pylint: disable=no-name-in-module
+from openassetio import errors
+
+
+class Test_errors:
+    def test_when_cpp_exception_thrown_then_can_be_caught_as_corresponding_python_exception(self):
+        exceptions = (
+            errors.OpenAssetIOException,
+            errors.ConfigurationException,
+            errors.InputValidationException,
+            errors.NotImplementedException,
+            errors.UnhandledException,
+        )
+
+        for exception in exceptions:
+            with pytest.raises(exception, match="Explosion!"):
+                _openassetio_test.throwException(exception.__name__, "Explosion!")
+
+    def test_when_cpp_exception_thrown_then_can_be_caught_as_OpenAssetIOException(self):
+        exceptions = (
+            errors.ConfigurationException,
+            errors.InputValidationException,
+            errors.NotImplementedException,
+            errors.UnhandledException,
+        )
+
+        for exception in exceptions:
+            assert _openassetio_test.isThrownExceptionCatchableAs(
+                throwExceptionName=exception.__name__,
+                catchExceptionName=errors.OpenAssetIOException.__name__,
+            )
+            with pytest.raises(errors.OpenAssetIOException, match="Explosion!"):
+                _openassetio_test.throwException(exception.__name__, "Explosion!")
+
+    def test_when_ConfigurationException_thrown_then_can_be_caught_as_InputValidationException(
+        self,
+    ):
+        assert _openassetio_test.isThrownExceptionCatchableAs(
+            throwExceptionName=errors.ConfigurationException.__name__,
+            catchExceptionName=errors.InputValidationException.__name__,
+        )
+        with pytest.raises(errors.InputValidationException, match="Explosion!"):
+            _openassetio_test.throwException(errors.ConfigurationException.__name__, "Explosion!")
diff --git a/src/openassetio-python/tests/package/hostApi/resources/unsupported_value_type.toml b/src/openassetio-python/tests/package/hostApi/resources/unsupported_value_type.toml
new file mode 100644
index 000000000..5009a1536
--- /dev/null
+++ b/src/openassetio-python/tests/package/hostApi/resources/unsupported_value_type.toml
@@ -0,0 +1,5 @@
+[manager]
+identifier = "identifier.from.toml.file"
+
+[manager.settings]
+a_dict = { key = "value" }
diff --git a/src/openassetio-python/tests/package/hostApi/test_manager.py b/src/openassetio-python/tests/package/hostApi/test_manager.py
index 801944bb1..ad6ac922b 100644
--- a/src/openassetio-python/tests/package/hostApi/test_manager.py
+++ b/src/openassetio-python/tests/package/hostApi/test_manager.py
@@ -42,6 +42,7 @@
 )
 from openassetio.hostApi import Manager, EntityReferencePager
 from openassetio.managerApi import EntityReferencePagerInterface
+from openassetio.errors import InputValidationException
 
 
 ## @todo Remove comments regarding Entity methods when splitting them from core API
@@ -300,12 +301,12 @@ def test_method_defined_in_cpp(self, method_introspector):
         assert not method_introspector.is_defined_in_python(Manager.createEntityReference)
         assert method_introspector.is_implemented_once(Manager, "createEntityReference")
 
-    def test_when_invalid_then_raises_ValueError(
+    def test_when_invalid_then_raises_InputValidationException(
         self, manager, mock_manager_interface, a_ref_string, a_host_session
     ):
         mock_manager_interface.mock.isEntityReferenceString.return_value = False
 
-        with pytest.raises(ValueError) as err:
+        with pytest.raises(InputValidationException) as err:
             manager.createEntityReference(a_ref_string)
 
         mock_manager_interface.mock.isEntityReferenceString.assert_called_once_with(
@@ -860,7 +861,7 @@ def call_callbacks(*_args):
         # > "EntityReferencePagerInterface::get"
         pagers[0].get()
 
-    def test_when_zero_pageSize_then_indexError_is_raised(
+    def test_when_zero_pageSize_then_InputValidationException_is_raised(
         self, manager, a_ref, an_empty_traitsdata, an_entity_trait_set, a_context
     ):
         two_refs = [a_ref, a_ref]
@@ -869,7 +870,7 @@ def test_when_zero_pageSize_then_indexError_is_raised(
         success_callback = mock.Mock()
         error_callback = mock.Mock()
 
-        with pytest.raises(IndexError):
+        with pytest.raises(InputValidationException):
             manager.getWithRelationshipPaged(
                 two_refs,
                 an_empty_traitsdata,
@@ -1014,7 +1015,7 @@ def call_callbacks(*_args):
         # > "EntityReferencePagerInterface::get"
         pagers[0].get()
 
-    def test_when_zero_pageSize_then_indexError_is_raised(
+    def test_when_zero_pageSize_then_InputValidationException_is_raised(
         self, manager, a_ref, an_empty_traitsdata, an_entity_trait_set, a_context
     ):
         two_datas = [an_empty_traitsdata, an_empty_traitsdata]
@@ -1023,7 +1024,7 @@ def test_when_zero_pageSize_then_indexError_is_raised(
         success_callback = mock.Mock()
         error_callback = mock.Mock()
 
-        with pytest.raises(IndexError):
+        with pytest.raises(InputValidationException):
             manager.getWithRelationshipsPaged(
                 a_ref,
                 two_datas,
@@ -1806,14 +1807,15 @@ def call_callbacks(*_args):
         success_callback.assert_called_once_with(123, some_refs[0])
         error_callback.assert_called_once_with(456, a_batch_element_error)
 
-    def test_when_called_with_mixed_array_lengths_then_IndexError_is_raised(
+    def test_when_called_with_mixed_array_lengths_then_InputValidationException_is_raised(
         self, manager, some_refs, some_entity_traitsdatas, a_context
     ):
         expected_message = (
             "Parameter lists must be of the same length: {} entity references vs. {} traits hints."
         )
         with pytest.raises(
-            IndexError, match=expected_message.format(len(some_refs) - 1, len(some_refs))
+            InputValidationException,
+            match=expected_message.format(len(some_refs) - 1, len(some_refs)),
         ):
             manager.preflight(
                 some_refs[1:],
@@ -1825,7 +1827,8 @@ def test_when_called_with_mixed_array_lengths_then_IndexError_is_raised(
             )
 
         with pytest.raises(
-            IndexError, match=expected_message.format(len(some_refs), len(some_refs) - 1)
+            InputValidationException,
+            match=expected_message.format(len(some_refs), len(some_refs) - 1),
         ):
             manager.preflight(
                 some_refs,
@@ -1836,12 +1839,12 @@ def test_when_called_with_mixed_array_lengths_then_IndexError_is_raised(
                 mock.Mock(),
             )
 
-    def test_when_traits_data_is_None_then_TypeError_is_raised(
+    def test_when_traits_data_is_None_then_InputValidationException_is_raised(
         self, manager, some_refs, some_entity_traitsdatas, a_context
     ):
         some_entity_traitsdatas[-1] = None
 
-        with pytest.raises(TypeError):
+        with pytest.raises(InputValidationException):
             manager.preflight(
                 some_refs,
                 some_entity_traitsdatas,
@@ -2129,11 +2132,11 @@ def call_callbacks(*_args):
 
 
 class Test_Manager_preflight_with_batch_default_overload:
-    def test_when_traits_data_is_None_then_TypeError_is_raised(
+    def test_when_traits_data_is_None_then_InputValidationException_is_raised(
         self, manager, some_refs, some_entity_traitsdatas, a_context
     ):
         some_entity_traitsdatas[-1] = None
-        with pytest.raises(TypeError):
+        with pytest.raises(InputValidationException):
             manager.preflight(
                 some_refs,
                 some_entity_traitsdatas,
@@ -2263,11 +2266,11 @@ def call_callbacks(*_args):
 
 
 class Test_Manager_preflight_with_batch_throwing_overload:
-    def test_when_traits_data_is_None_then_TypeError_is_raised(
+    def test_when_traits_data_is_None_then_InputValidationException_is_raised(
         self, manager, some_refs, some_entity_traitsdatas, a_context
     ):
         some_entity_traitsdatas[-1] = None
-        with pytest.raises(TypeError):
+        with pytest.raises(InputValidationException):
             manager.preflight(
                 some_refs,
                 some_entity_traitsdatas,
@@ -2407,11 +2410,11 @@ def call_callbacks(*_args):
 
 
 class Test_Manager_preflight_with_batch_variant_overload:
-    def test_when_traits_data_is_None_then_TypeError_is_raised(
+    def test_when_traits_data_is_None_then_InputValidationException_is_raised(
         self, manager, some_refs, some_entity_traitsdatas, a_context
     ):
         some_entity_traitsdatas[-1] = None
-        with pytest.raises(TypeError):
+        with pytest.raises(InputValidationException):
             manager.preflight(
                 some_refs,
                 some_entity_traitsdatas,
@@ -2574,14 +2577,15 @@ def call_callbacks(*_args):
         success_callback.assert_called_once_with(123, some_refs[0])
         error_callback.assert_called_once_with(456, a_batch_element_error)
 
-    def test_when_called_with_mixed_array_lengths_then_IndexError_is_raised(
+    def test_when_called_with_mixed_array_lengths_then_InputValidationException_is_raised(
         self, manager, some_refs, some_entity_traitsdatas, a_context
     ):
         expected_message = (
             "Parameter lists must be of the same length: {} entity references vs. {} traits datas."
         )
         with pytest.raises(
-            IndexError, match=expected_message.format(len(some_refs) - 1, len(some_refs))
+            InputValidationException,
+            match=expected_message.format(len(some_refs) - 1, len(some_refs)),
         ):
             manager.register(
                 some_refs[1:],
@@ -2593,7 +2597,8 @@ def test_when_called_with_mixed_array_lengths_then_IndexError_is_raised(
             )
 
         with pytest.raises(
-            IndexError, match=expected_message.format(len(some_refs), len(some_refs) - 1)
+            InputValidationException,
+            match=expected_message.format(len(some_refs), len(some_refs) - 1),
         ):
             manager.register(
                 some_refs,
@@ -2604,10 +2609,10 @@ def test_when_called_with_mixed_array_lengths_then_IndexError_is_raised(
                 mock.Mock(),
             )
 
-    def test_when_called_with_None_data_then_TypeError_is_raised(
+    def test_when_called_with_None_data_then_InputValidationException_is_raised(
         self, manager, some_refs, a_context, a_traitsdata
     ):
-        with pytest.raises(TypeError):
+        with pytest.raises(InputValidationException):
             manager.register(
                 some_refs,
                 [a_traitsdata, None],
diff --git a/src/openassetio-python/tests/package/hostApi/test_managerfactory.py b/src/openassetio-python/tests/package/hostApi/test_managerfactory.py
index 87ed76f74..9d224ca77 100644
--- a/src/openassetio-python/tests/package/hostApi/test_managerfactory.py
+++ b/src/openassetio-python/tests/package/hostApi/test_managerfactory.py
@@ -26,7 +26,7 @@
 
 import pytest
 
-from openassetio import _openassetio  # pylint: disable=no-name-in-module
+from openassetio import _openassetio, errors  # pylint: disable=no-name-in-module
 from openassetio.hostApi import ManagerFactory, Manager, ManagerImplementationFactoryInterface
 from openassetio.log import LoggerInterface
 
@@ -147,7 +147,7 @@ def test_when_var_not_set_then_returns_none(
         )
 
     @pytest.mark.parametrize("use_env_var_for_config_file", [True, False])
-    def test_when_non_existent_path_then_runtime_error_raised(
+    def test_when_non_existent_path_then_InputValidationException_raised(
         self,
         use_env_var_for_config_file,
         non_existent_manager_config,  # pylint: disable=unused-argument
@@ -155,7 +155,7 @@ def test_when_non_existent_path_then_runtime_error_raised(
         mock_host_interface,
         mock_logger,
     ):
-        with pytest.raises(RuntimeError) as exc:
+        with pytest.raises(errors.InputValidationException) as exc:
             if use_env_var_for_config_file:
                 ManagerFactory.defaultManagerForInterface(
                     mock_host_interface, mock_manager_implementation_factory, mock_logger
@@ -174,15 +174,21 @@ def test_when_non_existent_path_then_runtime_error_raised(
         )
 
     @pytest.mark.parametrize("use_env_var_for_config_file", [True, False])
-    def test_when_invalid_content_then_runtime_error_raised(
+    def test_when_invalid_format_then_RuntimeError_raised(
         self,
         use_env_var_for_config_file,
-        invalid_manager_config,  # pylint: disable=unused-argument
+        invalid_manager_config,
         mock_manager_implementation_factory,
         mock_host_interface,
         mock_logger,
     ):
-        with pytest.raises(RuntimeError):
+        with pytest.raises(
+            errors.ConfigurationException,
+            match=(
+                "Error parsing config file. Error while parsing key: multi-line strings are"
+                " prohibited in keys"
+            ),
+        ):
             if use_env_var_for_config_file:
                 ManagerFactory.defaultManagerForInterface(
                     mock_host_interface, mock_manager_implementation_factory, mock_logger
@@ -195,6 +201,30 @@ def test_when_invalid_content_then_runtime_error_raised(
                     mock_logger,
                 )
 
+    @pytest.mark.parametrize("use_env_var_for_config_file", [True, False])
+    def test_when_invalid_property_type_then_ConfigurationException_raised(
+        self,
+        use_env_var_for_config_file,
+        config_with_invalid_property,
+        mock_manager_implementation_factory,
+        mock_host_interface,
+        mock_logger,
+    ):
+        with pytest.raises(
+            errors.ConfigurationException, match="Unsupported value type for 'a_dict'."
+        ):
+            if use_env_var_for_config_file:
+                ManagerFactory.defaultManagerForInterface(
+                    mock_host_interface, mock_manager_implementation_factory, mock_logger
+                )
+            else:
+                ManagerFactory.defaultManagerForInterface(
+                    config_with_invalid_property,
+                    mock_host_interface,
+                    mock_manager_implementation_factory,
+                    mock_logger,
+                )
+
     @pytest.mark.parametrize("use_env_var_for_config_file", [True, False])
     def test_when_valid_path_then_expected_manager_returned_with_expected_settings(
         self,
@@ -598,6 +628,14 @@ def invalid_manager_config(monkeypatch, use_env_var_for_config_file):
     return toml_path
 
 
+@pytest.fixture
+def config_with_invalid_property(resources_dir, monkeypatch, use_env_var_for_config_file):
+    toml_path = os.path.join(resources_dir, "unsupported_value_type.toml")
+    if use_env_var_for_config_file:
+        monkeypatch.setenv("OPENASSETIO_DEFAULT_CONFIG", toml_path)
+    return toml_path
+
+
 class MockManagerImplementationFactory(ManagerImplementationFactoryInterface):
     """
     `ManagerImplementationFactoryInterface` that forwards calls to an
diff --git a/src/openassetio-python/tests/package/managerApi/test_managerinterface.py b/src/openassetio-python/tests/package/managerApi/test_managerinterface.py
index 2abbec530..964ef483c 100644
--- a/src/openassetio-python/tests/package/managerApi/test_managerinterface.py
+++ b/src/openassetio-python/tests/package/managerApi/test_managerinterface.py
@@ -25,7 +25,7 @@
 
 import pytest
 
-from openassetio import EntityReference, TraitsData, Context, BatchElementError
+from openassetio import EntityReference, TraitsData, Context, BatchElementError, errors
 from openassetio.managerApi import (
     ManagerInterface,
     ManagerStateBase,
@@ -107,8 +107,8 @@ def test_method_defined_in_cpp(self, method_introspector):
         assert not method_introspector.is_defined_in_python(ManagerInterface.createChildState)
         assert method_introspector.is_implemented_once(ManagerInterface, "createChildState")
 
-    def test_default_implementation_raises_RuntimeError(self, a_host_session):
-        with pytest.raises(RuntimeError):
+    def test_default_implementation_raises_NotImplementedException(self, a_host_session):
+        with pytest.raises(errors.NotImplementedException):
             ManagerInterface().createChildState(ManagerStateBase(), a_host_session)
 
     def test_when_none_is_supplied_then_TypeError_is_raised(self, a_host_session):
@@ -129,8 +129,8 @@ def test_when_none_is_supplied_then_TypeError_is_raised(self, a_host_session):
         with pytest.raises(TypeError):
             ManagerInterface().persistenceTokenForState(None, a_host_session)
 
-    def test_default_implementation_raises_RuntimeError(self, a_host_session):
-        with pytest.raises(RuntimeError):
+    def test_default_implementation_raises_NotImplementedException(self, a_host_session):
+        with pytest.raises(errors.NotImplementedException):
             ManagerInterface().persistenceTokenForState(ManagerStateBase(), a_host_session)
 
 
@@ -148,7 +148,7 @@ def test_when_none_is_supplied_then_TypeError_is_raised(self, a_host_session):
             ManagerInterface().createChildState(None, a_host_session)
 
     def test_default_implementation_raises_RuntimeError(self, a_host_session):
-        with pytest.raises(RuntimeError):
+        with pytest.raises(errors.NotImplementedException):
             ManagerInterface().stateFromPersistenceToken("", a_host_session)
 
 
diff --git a/src/openassetio-python/tests/package/pluginSystem/test_pythonpluginsystem.py b/src/openassetio-python/tests/package/pluginSystem/test_pythonpluginsystem.py
index 9a974e306..c6977535a 100644
--- a/src/openassetio-python/tests/package/pluginSystem/test_pythonpluginsystem.py
+++ b/src/openassetio-python/tests/package/pluginSystem/test_pythonpluginsystem.py
@@ -27,6 +27,7 @@
 
 import pytest
 
+from openassetio import errors
 from openassetio.log import ConsoleLogger
 from openassetio.pluginSystem import PythonPluginSystem
 
@@ -198,6 +199,15 @@ def test_when_plugins_broken_then_skipped_with_expected_errors(
         )
 
 
+class Test_PythonPluginSystem_plugin:
+    def test_when_plugin_not_found_then_raises_ConfigurationException(self, a_plugin_system):
+        with pytest.raises(
+            errors.InputValidationException,
+            match="PythonPluginSystem: No plug-in registered with the identifier 'nonexistent'",
+        ):
+            a_plugin_system.plugin("nonexistent")
+
+
 @pytest.fixture
 def a_plugin_system(a_logger):
     return PythonPluginSystem(a_logger)
diff --git a/src/openassetio-python/tests/package/test/manager/test_harness.py b/src/openassetio-python/tests/package/test/manager/test_harness.py
index e7a461cf8..57f79dfea 100644
--- a/src/openassetio-python/tests/package/test/manager/test_harness.py
+++ b/src/openassetio-python/tests/package/test/manager/test_harness.py
@@ -32,7 +32,7 @@
 import uuid
 import pytest
 
-from openassetio import constants, Context, EntityReference
+from openassetio import constants, Context, EntityReference, errors
 from openassetio.test.manager.harness import (
     executeSuite,
     fixturesFromPyFile,
@@ -49,19 +49,19 @@
 class Test_fixturesFromPyFile:
     def test_when_called_with_missing_path_then_raises_Exception(self):
         invalid_path = "/i/do/not/exist"
-        with pytest.raises(RuntimeError) as exc:
+        with pytest.raises(errors.InputValidationException) as exc:
             fixturesFromPyFile(invalid_path)
         assert str(exc.value) == f"Unable to parse '{invalid_path}'"
 
     def test_when_called_with_non_python_file_then_raises_exception(self, tmpdir):
         text_file = tempfile_with_contents(tmpdir, ".txt", "Hello!")
-        with pytest.raises(RuntimeError) as exc:
+        with pytest.raises(errors.InputValidationException) as exc:
             fixturesFromPyFile(text_file)
         assert str(exc.value) == f"Unable to parse '{text_file}'"
 
     def test_when_called_with_python_file_missing_fixtures_var_then_raises_exception(self, tmpdir):
         malformed = tempfile_with_contents(tmpdir, ".py", "cabbages = {}")
-        with pytest.raises(RuntimeError) as exc:
+        with pytest.raises(errors.ConfigurationException) as exc:
             fixturesFromPyFile(malformed)
         assert str(exc.value) == f"Missing top-level 'fixtures' variable in '{malformed}'"
 
@@ -86,13 +86,13 @@ def test_when_called_with_valid_path_then_returns_expected_fixture_dict(self, tm
 class Test_moduleFromFile:
     def test_when_called_with_missing_path_then_raises_Exception(self):
         invalid_path = "/i/do/not/exist"
-        with pytest.raises(RuntimeError) as exc:
+        with pytest.raises(errors.InputValidationException) as exc:
             moduleFromFile(invalid_path)
         assert str(exc.value) == f"Unable to parse '{invalid_path}'"
 
     def test_when_called_with_non_python_file_then_raises_exception(self, tmpdir):
         text_file = tempfile_with_contents(tmpdir, ".txt", "Hello!")
-        with pytest.raises(RuntimeError) as exc:
+        with pytest.raises(errors.InputValidationException) as exc:
             moduleFromFile(text_file)
         assert str(exc.value) == f"Unable to parse '{text_file}'"
 
diff --git a/src/openassetio-python/tests/package/test_imports.py b/src/openassetio-python/tests/package/test_imports.py
index 6f32cbfb7..c80494f46 100644
--- a/src/openassetio-python/tests/package/test_imports.py
+++ b/src/openassetio-python/tests/package/test_imports.py
@@ -56,9 +56,6 @@ def test_importing_Context_succeeds(self):
     def test_importing_EntityReference_succeeds(self):
         from openassetio import EntityReference
 
-    def test_importing_exceptions_succeeds(self):
-        from openassetio import exceptions
-
     def test_importing_log_succeeds(self):
         from openassetio import log
 
@@ -121,6 +118,23 @@ def test_importing_PythonPluginSystemPlugin_succeeds(self):
         from openassetio.pluginSystem import PythonPluginSystemPlugin
 
 
+class Test_errors_imports:
+    def test_importing_OpenAssetIOException_succeeds(self):
+        from openassetio.errors import OpenAssetIOException
+
+    def test_importing_ConfigurationException_succeeds(self):
+        from openassetio.errors import ConfigurationException
+
+    def test_importing_InputValidationException_succeeds(self):
+        from openassetio.errors import InputValidationException
+
+    def test_importing_NotImplementedException_succeeds(self):
+        from openassetio.errors import NotImplementedException
+
+    def test_importing_UnhandledException_succeeds(self):
+        from openassetio.errors import UnhandledException
+
+
 class Test_test_imports:
     def test_importing_manager_succeeds(self):
         from openassetio.test import manager