Error Reporting
In the previous chapter on error handling, we implemented AuthTokenValidator
to raise the error string "auth token has expired"
, when a given auth token has expired.
Even after we defined a custom error type ErrAuthTokenHasExpired
, it is still a dummy
struct that has a Debug
implementation that outputs the same string
"auth token has expired"
.
In real world applications, we know that it is good engineering practice to include
as much details to an error, so that developers and end users can more easily
diagnose the source of the problem.
On the other hand, it takes a lot of effort to properly design and show good error
messages. When doing initial development, we don't necessary want to spend too
much effort on formatting error messages, when we don't even know if the code
would survive the initial iteration.
To resolve the dilemma, developers are often forced to choose a comprehensive error library that can do everything from error handling to error reporting. Once the library is chosen, implementation code often becomes tightly coupled with the error library. If there is any detail missing in the error report, it may be challenging to include more details without diving deep into the impementation.
CGP offers better ways to resolve this dilemma, by allowing us to decouple the logic of error handling from actual error reporting. In this chapter, we will go into detail of how we can use CGP to improve the error report to show more information about an expired auth token.
Reporting Errors with Abstract Types
One challenge that CGP introduces is that with abstract types, it may be challenging
to produce good error report without knowledge about the underlying type.
We can workaround this in a naive way by using impl-side dependencies to require
the abstract types Context::AuthToken
and Context::Time
to implement Debug
,
and then format them as a string before raising it as an error:
#![allow(unused)] fn main() { extern crate cgp; use core::fmt::Debug; use cgp::prelude::*; #[cgp_component { name: TimeTypeComponent, provider: ProvideTimeType, }] pub trait HasTimeType { type Time; } #[cgp_component { name: AuthTokenTypeComponent, provider: ProvideAuthTokenType, }] pub trait HasAuthTokenType { 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 + for<'a> CanRaiseError<String>, Context::Time: Debug + Ord, Context::AuthToken: Debug, { 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( format!( "the auth token {:?} has expired at {:?}, which is earlier than the current time {:?}", auth_token, token_expiry, now, ))) } } } }
The example above now shows better error message. But our provider ValidateTokenIsNotExpired
is now
tightly coupled with how the token expiry error is reported. We are now forced to implement Debug
for any AuthToken
and Time
types that we want to use. It is also not possible to customize the
error report to instead use the Display
instance, without directly modifying the implementation
for ValidateTokenIsNotExpired
. Similarly, we cannot easily customize how the message content is
formatted, or add additional details to the report.
Source Error Types with Abstract Fields
To better report the error message, we would first re-introduce the ErrAuthTokenHasExpired
source
error type that we have used in earlier examples. But now, we would also add fields with
abstract types into the struct, so that it contains all values that may be essential for
generating a good error report:
#![allow(unused)] fn main() { extern crate cgp; use core::fmt::Debug; use cgp::prelude::*; #[cgp_component { name: TimeTypeComponent, provider: ProvideTimeType, }] pub trait HasTimeType { type Time; } #[cgp_component { name: AuthTokenTypeComponent, provider: ProvideAuthTokenType, }] pub trait HasAuthTokenType { type AuthToken; } pub struct ErrAuthTokenHasExpired<'a, Context> where Context: HasAuthTokenType + HasTimeType, { pub context: &'a Context, pub auth_token: &'a Context::AuthToken, pub current_time: &'a Context::Time, pub expiry_time: &'a Context::Time, } }
The ErrAuthTokenHasExpired
struct is now parameterized by a generic lifetime 'a
and a generic context Context
. Inside the struct, all fields are in the form of
reference &'a
, so that we don't perform any copy to construct the error value.
The struct has a where
clause to require Context
to implement HasAuthTokenType
and HasTimeType
, since we need to hold their values inside the struct.
In addition to auth_token
, current_time
, and expiry_time
, we also include
a context
field with a reference to the main context, so that additional error details
may be provided through Context
.
In addition to the struct, we also manually implement a Debug
instance as a
default way to format ErrAuthTokenHasExpired
as string:
#![allow(unused)] fn main() { extern crate cgp; use core::fmt::Debug; use cgp::prelude::*; #[cgp_component { name: TimeTypeComponent, provider: ProvideTimeType, }] pub trait HasTimeType { type Time; } #[cgp_component { name: AuthTokenTypeComponent, provider: ProvideAuthTokenType, }] pub trait HasAuthTokenType { type AuthToken; } pub struct ErrAuthTokenHasExpired<'a, Context> where Context: HasAuthTokenType + HasTimeType, { pub context: &'a Context, pub auth_token: &'a Context::AuthToken, pub current_time: &'a Context::Time, pub expiry_time: &'a Context::Time, } impl<'a, Context> Debug for ErrAuthTokenHasExpired<'a, Context> where Context: HasAuthTokenType + HasTimeType, Context::AuthToken: Debug, Context::Time: Debug, { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { write!( f, "the auth token {:?} has expired at {:?}, which is earlier than the current time {:?}", self.auth_token, self.expiry_time, self.current_time, ) } } }
Inside the Debug
instance for ErrAuthTokenHasExpired
, we make use of impl-side dependencies
to require Context::AuthToken
and Context::Time
to implement Debug
. We then use Debug
to format the values and show the error message.
Notice that even though ErrAuthTokenHasExpired
contains a context
field, it is not used
in the Debug
implementation. Also, since the Debug
constraint for Context::AuthToken
and
Context::Time
are only present in the Debug
implementation, it is possible for the concrete
types to not implement Debug
, if the application do not use Debug
with ErrAuthTokenHasExpired
.
This design is intentional, as we only provide the Debug
implementation as a convenience
for quickly formatting the error message without further customization.
On the other hand, a better error reporting strategy may be present elsewhere and provided
by the application.
The main purpose of this design is so that at the time ErrAuthTokenHasExpired
and
ValidateTokenIsNotExpired
are defined, we don't need to concern about where and how
this error reporting strategy is implemented.
Using the new ErrAuthTokenHasExpired
, we can now re-implement ValidateTokenIsNotExpired
as follows:
#![allow(unused)] fn main() { extern crate cgp; use core::fmt::Debug; use cgp::prelude::*; #[cgp_component { name: TimeTypeComponent, provider: ProvideTimeType, }] pub trait HasTimeType { type Time; } #[cgp_component { name: AuthTokenTypeComponent, provider: ProvideAuthTokenType, }] pub trait HasAuthTokenType { 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 ErrAuthTokenHasExpired<'a, Context> where Context: HasAuthTokenType + HasTimeType, { pub context: &'a Context, pub auth_token: &'a Context::AuthToken, pub current_time: &'a Context::Time, pub expiry_time: &'a Context::Time, } impl<'a, Context> Debug for ErrAuthTokenHasExpired<'a, Context> where Context: HasAuthTokenType + HasTimeType, Context::AuthToken: Debug, Context::Time: Debug, { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { write!( f, "the auth token {:?} has expired at {:?}, which is earlier than the current time {:?}", self.auth_token, self.expiry_time, self.current_time, ) } } pub struct ValidateTokenIsNotExpired; impl<Context> AuthTokenValidator<Context> for ValidateTokenIsNotExpired where Context: HasCurrentTime + CanFetchAuthTokenExpiry + for<'a> CanRaiseError<ErrAuthTokenHasExpired<'a, Context>>, 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 { context, auth_token, current_time: &now, expiry_time: &token_expiry, })) } } } }
In the new implementation, we include the constraint
for<'a> CanRaiseError<ErrAuthTokenHasExpired<'a, Context>>
with higher ranked trait bound,
so that we can raise ErrAuthTokenHasExpired
parameterized with any lifetime.
Notice that inside the where
constraints, we no longer require the Debug
bound on Context::AuthToken
and Context::Time
.
With this approach, we have made use of ErrAuthTokenHasExpired
to fully
decouple ValidateTokenIsNotExpired
provider from the problem of how to report
the token expiry error.
Error Report Raisers
In the previous chapter, we have learned about
how to define custom error raisers and then dispatch them using the UseDelegate
pattern. With that in mind, we can easily define error raisers for
ErrAuthTokenHasExpired
to format it in different ways.
One thing to note is that since ErrAuthTokenHasExpired
contains a lifetime
parameter with borrowed values, any error raiser that handles it would
likely have to make use of the borrowed value to construct an owned value
for Context::Error
.
The simplest way to raise ErrAuthTokenHasExpired
is to make use of its Debug
implementation to and raise it using DebugError
:
#![allow(unused)] fn main() { extern crate cgp; use cgp::core::error::{CanRaiseError, ErrorRaiser}; use core::fmt::Debug; pub struct DebugError; impl<Context, SourceError> ErrorRaiser<Context, SourceError> for DebugError where Context: CanRaiseError<String>, SourceError: Debug, { fn raise_error(e: SourceError) -> Context::Error { Context::raise_error(format!("{e:?}")) } } }
As we discussed in the previous chapter, DebugError
would implement ErrorRaiser
if ErrAuthTokenHasExpired
implements Debug
. But recall that the Debug
implementation
for ErrAuthTokenHasExpired
requires both Context::AuthToken
and Context::Time
to
implement Debug
. So in a way, the use of impl-side dependencies here is deeply nested,
but nevertheless still works thanks to Rust's trait system.
Now supposed that instead of using Debug
, we want to use the Display
instance of
Context::AuthToken
and Context::Time
to format the error. Even if we are in a crate
that do not own ErrAuthTokenHasExpired
, we can still implement a custom ErrorRaiser
instance as follows:
#![allow(unused)] fn main() { extern crate cgp; use core::fmt::Display; use cgp::prelude::*; use cgp::core::error::ErrorRaiser; #[cgp_component { name: TimeTypeComponent, provider: ProvideTimeType, }] pub trait HasTimeType { type Time; } #[cgp_component { name: AuthTokenTypeComponent, provider: ProvideAuthTokenType, }] pub trait HasAuthTokenType { type AuthToken; } pub struct ErrAuthTokenHasExpired<'a, Context> where Context: HasAuthTokenType + HasTimeType, { pub context: &'a Context, pub auth_token: &'a Context::AuthToken, pub current_time: &'a Context::Time, pub expiry_time: &'a Context::Time, } pub struct DisplayAuthTokenExpiredError; impl<'a, Context> ErrorRaiser<Context, ErrAuthTokenHasExpired<'a, Context>> for DisplayAuthTokenExpiredError where Context: HasAuthTokenType + HasTimeType + CanRaiseError<String>, Context::AuthToken: Display, Context::Time: Display, { fn raise_error(e: ErrAuthTokenHasExpired<'a, Context>) -> Context::Error { Context::raise_error(format!( "the auth token {} has expired at {}, which is earlier than the current time {}", e.auth_token, e.expiry_time, e.current_time, )) } } }
With this approach, we can now use DisplayAuthTokenExpiredError
if Context::AuthToken
and Context::Time
implement Display
. But even if they don't, we are still free to choose
alternative strategies for our application.
One possible way to improve the error message is to obfuscate the auth token, so that the
reader of the error message cannot know about the actual auth token. This may have already
been done, if the concrete AuthToken
type implements a custom Display
that does so.
But in case if it does not, we can still do something similar using a customized error raiser:
#![allow(unused)] fn main() { extern crate cgp; extern crate sha1; use core::fmt::Display; use cgp::prelude::*; use cgp::core::error::ErrorRaiser; use sha1::{Digest, Sha1}; #[cgp_component { name: TimeTypeComponent, provider: ProvideTimeType, }] pub trait HasTimeType { type Time; } #[cgp_component { name: AuthTokenTypeComponent, provider: ProvideAuthTokenType, }] pub trait HasAuthTokenType { type AuthToken; } pub struct ErrAuthTokenHasExpired<'a, Context> where Context: HasAuthTokenType + HasTimeType, { pub context: &'a Context, pub auth_token: &'a Context::AuthToken, pub current_time: &'a Context::Time, pub expiry_time: &'a Context::Time, } pub struct ShowAuthTokenExpiredError; impl<'a, Context> ErrorRaiser<Context, ErrAuthTokenHasExpired<'a, Context>> for ShowAuthTokenExpiredError where Context: HasAuthTokenType + HasTimeType + CanRaiseError<String>, Context::AuthToken: Display, Context::Time: Display, { fn raise_error(e: ErrAuthTokenHasExpired<'a, Context>) -> Context::Error { let auth_token_hash = Sha1::new_with_prefix(e.auth_token.to_string()).finalize(); Context::raise_error(format!( "the auth token {:x} has expired at {}, which is earlier than the current time {}", auth_token_hash, e.expiry_time, e.current_time, )) } } }
By decoupling the error reporting from the provider, we can now customize the error reporting
as we see fit, without needing to access or modify the original provider ValidateTokenIsNotExpired
.
Context-Specific Error Details
Previously, we included the context
field in ErrAuthTokenHasExpired
but never used it in
the error reporting. But with the ability to define custom error raisers, we can also
define one that extracts additional details from the context, so that it can be included
in the error message.
Supposed that we are using CanValidateAuthToken
in an application that serves sensitive documents.
When an expired auth token is used, we may want to also include the document ID being accessed,
so that we can identify the attack patterns of any potential attacker.
If the application context holds the document ID, we can now access it within the error raiser
as follows:
#![allow(unused)] fn main() { extern crate cgp; extern crate sha1; use core::fmt::Display; use cgp::prelude::*; use cgp::core::error::ErrorRaiser; use sha1::{Digest, Sha1}; #[cgp_component { name: TimeTypeComponent, provider: ProvideTimeType, }] pub trait HasTimeType { type Time; } #[cgp_component { name: AuthTokenTypeComponent, provider: ProvideAuthTokenType, }] pub trait HasAuthTokenType { type AuthToken; } pub struct ErrAuthTokenHasExpired<'a, Context> where Context: HasAuthTokenType + HasTimeType, { pub context: &'a Context, pub auth_token: &'a Context::AuthToken, pub current_time: &'a Context::Time, pub expiry_time: &'a Context::Time, } #[cgp_component { provider: DocumentIdGetter, }] pub trait HasDocumentId { fn document_id(&self) -> u64; } pub struct ShowAuthTokenExpiredError; impl<'a, Context> ErrorRaiser<Context, ErrAuthTokenHasExpired<'a, Context>> for ShowAuthTokenExpiredError where Context: HasAuthTokenType + HasTimeType + CanRaiseError<String> + HasDocumentId, Context::AuthToken: Display, Context::Time: Display, { fn raise_error(e: ErrAuthTokenHasExpired<'a, Context>) -> Context::Error { let document_id = e.context.document_id(); let auth_token_hash = Sha1::new_with_prefix(e.auth_token.to_string()).finalize(); Context::raise_error(format!( "failed to access highly sensitive document {} at time {}, using the auth token {:x} which was expired at {}", document_id, e.current_time, auth_token_hash, e.expiry_time, )) } } }
With this, even though the provider ValidateTokenIsNotExpired
did not know that Context
contains
a document ID, by including the context
value in ErrAuthTokenHasExpired
, we can
still implement a custom error raiser that produce a custom error message that includes the document ID.
Conclusion
In this chapter, we have learned about some advanced CGP techniques that can be used to decouple providers from the burden of producing good error reports. With that, we are able to define custom error raisers that produce highly detailed error reports, without needing to modify the original provider implementation. The use of source error types with abstract fields and borrowed values serves as a cheap interface to decouple the producer of an error (the provider) from the handler of an error (the error raiser).
Still, even with CGP, learning all the best practices of properly raising and handling errors can be overwhelming, especially for beginners. Furthermore, even if we can decouple and customize the handling of all possible error cases, extra effort is still needed for every customization, which can still takes a lot of time.
As a result, we do not encourage readers to try and define custom error structs for all possible errors. Instead, readers should start with simple error types like strings, and slowly add more structures to common errors that occur in the application. But readers should keep in mind the techniques introduced in this chapter, so that by the time we need to customize and produce good error reports for our applications, we know about how this can be done using CGP.