Error Handling
Rust introduces a modern approach to error handling through the use of the Result
type, which explicitly represents errors. Unlike implicit exceptions commonly used in other mainstream languages, the Result
type offers several advantages. It clearly indicates when errors may occur and specifies the type of errors that might be encountered when calling a function. However, the Rust community has yet to reach a consensus on the ideal error type to use within a Result
.
Choosing an appropriate error type is challenging because different applications have distinct requirements. For instance, should the error include stack traces? Can it be compatible with no_std environments? How should the error message be presented? Should it include structured metadata for introspection or specialized logging? How can different errors be distinguished to determine whether an operation should be retried? How can error sources from various libraries be composed or flattened effectively? These and other concerns complicate the decision-making process.
Because of these cross-cutting concerns, discussions in the Rust community about finding a universally optimal error type are never ending. Currently, the ecosystem tends to favor libraries like anyhow
that store error values using some form of dynamic typing. While convenient, these approaches sacrifice some benefits of static typing, such as the ability to determine at compile time whether a function cannot produce certain errors.
CGP offers an alternative approach to error handling: using abstract error types within Result
alongside a context-generic mechanism for raising errors without requiring a specific error type. In this chapter, we will explore this new approach, demonstrating how it allows error handling to be tailored to an application's precise needs.
Abstract Error Type
In the previous chapter, we explored how to use associated types with CGP to define abstract types. Similarly to abstract types like Time
and AuthToken
, we can define an abstract Error
type as follows:
#![allow(unused)] fn main() { extern crate cgp; use core::fmt::Debug; use cgp::prelude::*; cgp_type!( Error: Debug ); }
The HasErrorType
trait is particularly significant because it serves as a standard type API for all CGP components that involve abstract errors. Its definition is intentionally minimal, consisting of a single associated type, Error
, constrained by Debug
by default. This Debug
constraint was chosen because many Rust APIs, such as Result::unwrap
, rely on error types implementing Debug
.
Given its ubiquity, the HasErrorType
trait is included as part of the cgp
crate and is available in the prelude. Therefore, we will use the version provided by cgp
rather than redefining it locally in subsequent examples.
Building on the example from the previous chapter, we can update authentication components to leverage the abstract error type defined by HasErrorType
:
#![allow(unused)] fn main() { extern crate cgp; use cgp::prelude::*; cgp_type!( Time ); cgp_type!( AuthToken ); #[cgp_component { provider: AuthTokenValidator, }] pub trait CanValidateAuthToken: HasAuthTokenType + HasErrorType { fn validate_auth_token(&self, auth_token: &Self::AuthToken) -> Result<(), Self::Error>; } #[cgp_component { provider: AuthTokenExpiryFetcher, }] pub trait CanFetchAuthTokenExpiry: HasAuthTokenType + HasTimeType + HasErrorType { fn fetch_auth_token_expiry( &self, auth_token: &Self::AuthToken, ) -> Result<Self::Time, Self::Error>; } #[cgp_component { provider: CurrentTimeGetter, }] pub trait HasCurrentTime: HasTimeType + HasErrorType { fn current_time(&self) -> Result<Self::Time, Self::Error>; } }
In these examples, each trait now includes HasErrorType
as a supertrait, and methods return Self::Error
in the Result
type instead of relying on a concrete type like anyhow::Error
. This abstraction allows greater flexibility and customization, enabling components to adapt their error handling to the specific needs of different contexts.
Raising Errors With From
After adopting abstract errors in our component interfaces, the next challenge is handling these abstract errors in context-generic providers. With CGP, this is achieved by leveraging impl-side dependencies and adding constraints to the Error
type, such as requiring it to implement From
. This allows for the conversion of a source error into an abstract error value.
For example, we can modify the ValidateTokenIsNotExpired
provider to convert a source error, &'static str
, into Context::Error
when an authentication token has expired:
#![allow(unused)] fn main() { extern crate cgp; use cgp::prelude::*; cgp_type!( Time ); cgp_type!( AuthToken ); #[cgp_component { provider: AuthTokenValidator, }] pub trait CanValidateAuthToken: HasAuthTokenType + HasErrorType { fn validate_auth_token(&self, auth_token: &Self::AuthToken) -> Result<(), Self::Error>; } #[cgp_component { provider: AuthTokenExpiryFetcher, }] pub trait CanFetchAuthTokenExpiry: HasAuthTokenType + HasTimeType + HasErrorType { fn fetch_auth_token_expiry( &self, auth_token: &Self::AuthToken, ) -> Result<Self::Time, Self::Error>; } #[cgp_component { provider: CurrentTimeGetter, }] pub trait HasCurrentTime: HasTimeType + HasErrorType { fn current_time(&self) -> Result<Self::Time, Self::Error>; } pub struct ValidateTokenIsNotExpired; impl<Context> AuthTokenValidator<Context> for ValidateTokenIsNotExpired where Context: HasCurrentTime + CanFetchAuthTokenExpiry + HasErrorType, Context::Time: Ord, Context::Error: From<&'static str> { fn validate_auth_token( context: &Context, auth_token: &Context::AuthToken, ) -> Result<(), Context::Error> { let now = context.current_time()?; let token_expiry = context.fetch_auth_token_expiry(auth_token)?; if token_expiry < now { Ok(()) } else { Err("auth token has expired".into()) } } } }
This example demonstrates how CGP simplifies "stringy" error handling in context-generic providers by delegating the conversion from strings to concrete error values to the application. While using string errors is generally not a best practice, it is useful during the prototyping phase when precise error handling strategies are not yet established.
CGP encourages an iterative approach to error handling. Developers can begin with string errors for rapid prototyping and transition to structured error handling as the application matures. For example, we can replace the string error with a custom error type like ErrAuthTokenHasExpired
:
#![allow(unused)] fn main() { extern crate cgp; use core::fmt::Display; use cgp::prelude::*; cgp_type!( Time ); cgp_type!( AuthToken ); #[cgp_component { provider: AuthTokenValidator, }] pub trait CanValidateAuthToken: HasAuthTokenType + HasErrorType { fn validate_auth_token(&self, auth_token: &Self::AuthToken) -> Result<(), Self::Error>; } #[cgp_component { provider: AuthTokenExpiryFetcher, }] pub trait CanFetchAuthTokenExpiry: HasAuthTokenType + HasTimeType + HasErrorType { fn fetch_auth_token_expiry( &self, auth_token: &Self::AuthToken, ) -> Result<Self::Time, Self::Error>; } #[cgp_component { provider: CurrentTimeGetter, }] pub trait HasCurrentTime: HasTimeType + HasErrorType { fn current_time(&self) -> Result<Self::Time, Self::Error>; } pub struct ValidateTokenIsNotExpired; #[derive(Debug)] pub struct ErrAuthTokenHasExpired; impl<Context> AuthTokenValidator<Context> for ValidateTokenIsNotExpired where Context: HasCurrentTime + CanFetchAuthTokenExpiry + HasErrorType, Context::Time: Ord, Context::Error: From<ErrAuthTokenHasExpired> { fn validate_auth_token( context: &Context, auth_token: &Context::AuthToken, ) -> Result<(), Context::Error> { let now = context.current_time()?; let token_expiry = context.fetch_auth_token_expiry(auth_token)?; if token_expiry < now { Ok(()) } else { Err(ErrAuthTokenHasExpired.into()) } } } impl Display for ErrAuthTokenHasExpired { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { write!(f, "auth token has expired") } } }
In this example, we introduced the ErrAuthTokenHasExpired
type to represent the specific error of an expired authentication token. The AuthTokenValidator
implementation requires Context::Error
to implement From<ErrAuthTokenHasExpired>
for conversion to the abstract error type. Additionally, ErrAuthTokenHasExpired
implements both Debug
and Display
, allowing applications to present and log the error meaningfully.
CGP facilitates defining provider-specific error types like ErrAuthTokenHasExpired
without burdening the provider with embedding these errors into the application's overall error handling strategy. With impl-side dependencies, constraints like Context::Error: From<ErrAuthTokenHasExpired>
apply only when the application uses a specific provider. If an application employs a different provider to implement AuthTokenValidator
, it does not need to handle the ErrAuthTokenHasExpired
error.
Raising Errors using CanRaiseError
In the previous section, we used the From
constraint in the ValidateTokenIsNotExpired
provider to raise errors such as &'static str
or ErrAuthTokenHasExpired
. While this approach is elegant, we quickly realize it doesn't work with common error types like anyhow::Error
. This is because anyhow::Error
only provides a blanket From implementation only for types that implement core::error::Error + Send + Sync + 'static
.
This restriction is a common pain point when using error libraries like anyhow
. The reason for this limitation is that without CGP, a type like anyhow::Error
cannot provide multiple blanket From
implementations without causing conflicts. As a result, using From
can leak abstractions, forcing custom error types like ErrAuthTokenHasExpired
to implement common traits like core::error::Error
. Another challenge is that ownership rules prevent supporting custom From
implementations for non-owned types like String
and &str
.
To address these issues, we recommend using a more flexible — though slightly more verbose—approach with CGP: the CanRaiseError
trait, rather than relying on From
for error conversion. Here's how we define it:
#![allow(unused)] fn main() { extern crate cgp; use cgp::prelude::*; #[cgp_component { provider: ErrorRaiser, }] pub trait CanRaiseError<SourceError>: HasErrorType { fn raise_error(e: SourceError) -> Self::Error; } }
The CanRaiseError
trait has a generic parameter SourceError
, representing the source error type that will be converted into the abstract error type HasErrorType::Error
. By making it a generic parameter, this allows a context to raise multiple source error types and convert them into the abstract error.
Since raising errors is common in most CGP code, the CanRaiseError
trait is included in the CGP prelude, so we don’t need to define it manually.
We can now update the ValidateTokenIsNotExpired
provider to use CanRaiseError
instead of From
for error handling, raising a source error like &'static str
:
#![allow(unused)] fn main() { extern crate cgp; use cgp::prelude::*; cgp_type!( Time ); cgp_type!( AuthToken ); #[cgp_component { provider: AuthTokenValidator, }] pub trait CanValidateAuthToken: HasAuthTokenType + HasErrorType { fn validate_auth_token(&self, auth_token: &Self::AuthToken) -> Result<(), Self::Error>; } #[cgp_component { provider: AuthTokenExpiryFetcher, }] pub trait CanFetchAuthTokenExpiry: HasAuthTokenType + HasTimeType + HasErrorType { fn fetch_auth_token_expiry( &self, auth_token: &Self::AuthToken, ) -> Result<Self::Time, Self::Error>; } #[cgp_component { provider: CurrentTimeGetter, }] pub trait HasCurrentTime: HasTimeType + HasErrorType { fn current_time(&self) -> Result<Self::Time, Self::Error>; } pub struct ValidateTokenIsNotExpired; impl<Context> AuthTokenValidator<Context> for ValidateTokenIsNotExpired where Context: HasCurrentTime + CanFetchAuthTokenExpiry + CanRaiseError<&'static str>, Context::Time: Ord, { fn validate_auth_token( context: &Context, auth_token: &Context::AuthToken, ) -> Result<(), Context::Error> { let now = context.current_time()?; let token_expiry = context.fetch_auth_token_expiry(auth_token)?; if token_expiry < now { Ok(()) } else { Err(Context::raise_error("auth token has expired")) } } } }
In this updated implementation, we replace the Context: HasErrorType
constraint with Context: CanRaiseError<&'static str>
. Since HasErrorType
is a supertrait of CanRaiseError
, we only need to include CanRaiseError
in the constraint to automatically include HasErrorType
. We also use the Context::raise_error
method to convert the string "auth token has expired"
into Context::Error
.
This approach avoids the limitations of From
and offers greater flexibility for error handling in CGP, especially when working with third-party error types like anyhow::Error
.
Context-Generic Error Raisers
By defining the CanRaiseError
trait using CGP, we overcome the limitations of From
and enable context-generic error raisers that work across various source error types. For instance, we can create a context-generic error raiser for anyhow::Error
as follows:
#![allow(unused)] fn main() { extern crate cgp; extern crate anyhow; use cgp::core::error::{ErrorRaiser, HasErrorType}; pub struct RaiseAnyhowError; impl<Context, SourceError> ErrorRaiser<Context, SourceError> for RaiseAnyhowError where Context: HasErrorType<Error = anyhow::Error>, SourceError: core::error::Error + Send + Sync + 'static, { fn raise_error(e: SourceError) -> anyhow::Error { e.into() } } }
Here, RaiseAnyhowError
is a provider that implements the ErrorRaiser
trait with generic Context
and SourceError
. The implementation is valid only if the Context
implements HasErrorType
and implements Context::Error
as anyhow::Error
. Additionally, the SourceError
must satisfy core::error::Error + Send + Sync + 'static
, which is necessary for the From
implementation provided by anyhow::Error
. Inside the method body, the source error is converted into anyhow::Error
using e.into()
since the required constraints are already satisfied.
For a more generalized approach, we can create a provider that works with any error type supporting From
:
#![allow(unused)] fn main() { extern crate cgp; use cgp::core::error::{ErrorRaiser, HasErrorType}; pub struct RaiseFrom; impl<Context, SourceError> ErrorRaiser<Context, SourceError> for RaiseFrom where Context: HasErrorType, Context::Error: From<SourceError>, { fn raise_error(e: SourceError) -> Context::Error { e.into() } } }
This implementation requires the Context
to implement HasErrorType
and the Context::Error
type to implement From<SourceError>
. With these constraints in place, this provider allows errors to be raised from any source type to Context::Error
using From
, without requiring explicit coupling in providers like ValidateTokenIsNotExpired
.
The introduction of CanRaiseError
might seem redundant when it ultimately relies on From
in some cases. However, the purpose of this indirection is to enable alternative mechanisms for converting errors when From
is insufficient or unavailable. For example, we can define an error raiser for anyhow::Error
that uses the Debug
trait instead of From
:
#![allow(unused)] fn main() { extern crate cgp; extern crate anyhow; use core::fmt::Debug; use anyhow::anyhow; use cgp::core::error::{ErrorRaiser, HasErrorType}; pub struct DebugAnyhowError; impl<Context, SourceError> ErrorRaiser<Context, SourceError> for DebugAnyhowError where Context: HasErrorType<Error = anyhow::Error>, SourceError: Debug, { fn raise_error(e: SourceError) -> anyhow::Error { anyhow!("{e:?}") } } }
In this implementation, the DebugAnyhowError
provider raises any source error into an anyhow::Error
, as long as the source error implements Debug
. The raise_error
method uses the anyhow!
macro and formats the source error using the Debug
trait. This approach allows a concrete context to use providers like ValidateTokenIsNotExpired
while relying on DebugAnyhowError
to raise source errors such as &'static str
or ErrAuthTokenHasExpired
, which only implement Debug
or Display
.
The cgp-error-anyhow
Crate
The CGP project provides the cgp-error-anyhow
crate, which includes the anyhow-specific providers discussed in this chapter. These constructs are offered as a separate crate rather than being part of the core cgp
crate to avoid adding anyhow
as a mandatory dependency.
In addition, CGP offers other error crates tailored to different error handling libraries. The cgp-error-eyre
crate supports eyre::Error
, while the cgp-error-std
crate works with Box<dyn core::error::Error>
.
As demonstrated in this chapter, CGP allows projects to easily switch between error handling implementations without being tightly coupled to a specific error type. For instance, if the application needs to run in a resource-constrained environment, replacing cgp-error-anyhow
with cgp-error-std
in the component wiring enables the application to use the simpler Box<dyn Error>
type for error handling.
Putting It Altogether
With the use of HasErrorType
, CanRaiseError
, and cgp-error-anyhow
, we can now refactor the full example
from the previous chapter, and make it generic over the error type:
#![allow(unused)] fn main() { extern crate cgp; extern crate cgp_error_anyhow; extern crate anyhow; extern crate datetime; pub mod main { pub mod traits { use cgp::prelude::*; cgp_type!( Time ); cgp_type!( AuthToken ); #[cgp_component { provider: AuthTokenValidator, }] pub trait CanValidateAuthToken: HasAuthTokenType + HasErrorType { fn validate_auth_token(&self, auth_token: &Self::AuthToken) -> Result<(), Self::Error>; } #[cgp_component { provider: AuthTokenExpiryFetcher, }] pub trait CanFetchAuthTokenExpiry: HasAuthTokenType + HasTimeType + HasErrorType { fn fetch_auth_token_expiry( &self, auth_token: &Self::AuthToken, ) -> Result<Self::Time, Self::Error>; } #[cgp_component { provider: CurrentTimeGetter, }] pub trait HasCurrentTime: HasTimeType + HasErrorType { fn current_time(&self) -> Result<Self::Time, Self::Error>; } } pub mod impls { use core::fmt::Debug; use anyhow::anyhow; use cgp::core::error::{ErrorRaiser, ProvideErrorType}; use cgp::prelude::{CanRaiseError, HasErrorType}; use datetime::LocalDateTime; use super::traits::*; pub struct ValidateTokenIsNotExpired; #[derive(Debug)] pub struct ErrAuthTokenHasExpired; impl<Context> AuthTokenValidator<Context> for ValidateTokenIsNotExpired where Context: HasCurrentTime + CanFetchAuthTokenExpiry + CanRaiseError<ErrAuthTokenHasExpired>, Context::Time: Ord, { fn validate_auth_token( context: &Context, auth_token: &Context::AuthToken, ) -> Result<(), Context::Error> { let now = context.current_time()?; let token_expiry = context.fetch_auth_token_expiry(auth_token)?; if token_expiry < now { Ok(()) } else { Err(Context::raise_error(ErrAuthTokenHasExpired)) } } } pub struct UseLocalDateTime; impl<Context> ProvideTimeType<Context> for UseLocalDateTime { type Time = LocalDateTime; } impl<Context> CurrentTimeGetter<Context> for UseLocalDateTime where Context: HasTimeType<Time = LocalDateTime> + HasErrorType, { fn current_time(_context: &Context) -> Result<LocalDateTime, Context::Error> { Ok(LocalDateTime::now()) } } } pub mod contexts { use std::collections::BTreeMap; use anyhow::anyhow; use cgp::core::error::{ErrorRaiserComponent, ErrorTypeComponent}; use cgp::prelude::*; use cgp_error_anyhow::{UseAnyhowError, DebugAnyhowError}; use datetime::LocalDateTime; use super::impls::*; use super::traits::*; pub struct MockApp { pub auth_tokens_store: BTreeMap<String, LocalDateTime>, } pub struct MockAppComponents; impl HasComponents for MockApp { type Components = MockAppComponents; } delegate_components! { MockAppComponents { ErrorTypeComponent: UseAnyhowError, ErrorRaiserComponent: DebugAnyhowError, [ TimeTypeComponent, CurrentTimeGetterComponent, ]: UseLocalDateTime, AuthTokenTypeComponent: UseType<String>, AuthTokenValidatorComponent: ValidateTokenIsNotExpired, } } impl AuthTokenExpiryFetcher<MockApp> for MockAppComponents { fn fetch_auth_token_expiry( context: &MockApp, auth_token: &String, ) -> Result<LocalDateTime, anyhow::Error> { context .auth_tokens_store .get(auth_token) .cloned() .ok_or_else(|| anyhow!("invalid auth token")) } } pub trait CanUseMockApp: CanValidateAuthToken {} impl CanUseMockApp for MockApp {} } } }
In the updated code, we refactored ValidateTokenIsNotExpired
to use CanRaiseError<ErrAuthTokenHasExpired>
, with ErrAuthTokenHasExpired
implementing only Debug
. Additionally, we use the provider UseAnyhowError
from cgp-error-anyhow
, which implements ProvideErrorType
by setting Error
to anyhow::Error
.
In the component wiring for MockAppComponents
, we wire up ErrorTypeComponent
with UseAnyhowError
and ErrorRaiserComponent
with DebugAnyhowError
. In the context-specific implementation AuthTokenExpiryFetcher<MockApp>
, we can now use anyhow::Error
directly, since Rust already knows that MockApp::Error
is the same type as anyhow::Error
.
Conclusion
In this chapter, we provided a high-level overview of how error handling in CGP differs significantly from traditional error handling done in Rust. By utilizing abstract error types with HasErrorType
, we can create providers that are generic over the concrete error type used by an application. The CanRaiseError
trait allows us to implement context-generic error raisers, overcoming the limitations of non-overlapping implementations and enabling us to work with source errors that only implement traits like Debug
.
However, error handling is a complex subject, and CGP abstractions such as HasErrorType
and CanRaiseError
are just the foundation for addressing this complexity. There are additional details related to error handling that we will explore in the upcoming chapters, preparing us to handle errors effectively in real-world applications.