Search code examples
rustactix-web

How to log internal error details from actix service middleware


I have an actix_web service whose handlers consistently return Result<..., ServiceError>, where ServiceError distinguishes between internal errors and business errors. Internal errors are "unexpected" errors whose details shouldn't be exposed to the outside, and business errors come with a useful payload. ServiceError is defined like this:

enum ServiceError {
    // internal error caused by being unable to open a file, etc.
    Internal(anyhow::Error),
    // HTTP error explicitly returned by the handler
    Message(StatusCode, String),
    // ...additional variants to return JSON-formatted errors and such...
}

impl From<anyhow::Error> for ServiceError { // provided so ? just works
    fn from(err: anyhow::Error) -> Self {
        ServiceError::Internal(err)
    }
}

impl ResponseError for ServiceError {
    fn status_code(&self) -> StatusCode {
        match self {
            ServiceError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
            &ServiceError::Message(code, _) => code,
        }
    }

    fn error_response(&self) -> HttpResponse<BoxBody> {
        let mut builder = HttpResponseBuilder::new(self.status_code());
        match self {
            // details of the error are hidden from end user of the API
            ServiceError::Internal(_) => builder.body("internal server error")
            ServiceError::Message(_, msg) => builder.body(msg.clone()),
        }
    }
}

In the App setup I use wrap_fn() to consistently log both successful and error responses, so individual handlers don't need to do that. I'd like to provide more details in the log (by dumping the error value) in case it was an internal error. The issue is that wrap_fn() doesn't have access to the ServiceError returned by the handler, but only to actix's ServiceResponse crafted out of it. This makes it impossible to match ServiceError::Internal and log the details of the anyhow::Error that caused the internal error.

Does actix_web provide a mechanism for observing the actual error type returned by a handler for the purpose of loggging? Or at least for attaching some data that my middleware could observe and log?

As a workaround I'm now logging the error in <ServiceError as ResponseError>::error_response(), but that feels like a hack. Such code would seem to belong either in middleware or (if that's impossible) in the place where the routes are set up.


Solution

  • I discovered two ways to achieve this. Posting them as an answer to help others who might be looking for the same questions in the future.

    Use Debug

    Due to requirements of ResponseError, ServiceError is required to implement Debug and Display. These implementation are available from the middleware layer (which gives out a &dyn ResponseError) and can be used to retrieve the error.

    Example Debug and Display implementation for the type shown in the question:

    impl std::fmt::Display for ServiceError {
        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
            use ServiceError::*;
            match self {
                Internal(err) => write!(f, "{err:#}"),
                Message(code, msg) => write!(f, "http error {code}: {msg}"),
            }
        }
    }
    
    impl std::fmt::Debug for ServiceError {
        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
            write!(f, "{}", self) // reuse Display impl
        }
    }
    

    To use them from App, use wrap_fn() and debug-print the return value of resp.response().error() which will delegate to the Debug impl of your error type:

    App::new()
        .wrap_fn(|req, srv| {
            srv.call(req).instrument(span.clone()).map(move |resp| {
                if let Ok(resp) = &resp {
                    if let Some(err) = resp.response().error() {
                        // {:?} invokes ServiceError's Debug impl
                        tracing::error!("request handling error: {:?}", err.as_response_error());
                    }
                }
                resp
            })
        })
    

    Equivalent functionality can be achieved through middleware provided by external crates, such as tracing_actix_web::TracingLogger.

    Use extensions to transmit data to middleware

    A more general and powerful mechanism is to make use of extensions to transmit data from ServiceError to App. For example:

    pub struct FormattedInternalError(pub String);
    
    impl ResponseError for ServiceError {
        // ...fn status_code() unchanged...
    
        fn error_response(&self) -> HttpResponse<BoxBody> {
            let mut builder = HttpResponseBuilder::new(self.status_code());
            match self {
                ServiceError::Internal(_) => {
                    // transmit details internally
                    builder
                        .extensions_mut()
                        .insert(FormattedInternalError(format!("{err:?}")));
                    // ...but hide them externally
                    builder.body("internal server error")
                }
                ServiceError::Message(_, msg) => builder.body(msg.clone()),
            }
        }
    }
    

    App would access them from wrap_fn much like in the example above, but with the difference that it would no longer be limited to Debug and Display, it could transmit any information. In this case it would only log the error that corresponds to "internal" errors, not to business exceptions:

    App::new()
        .wrap_fn(|req, srv| {
            srv.call(req).instrument(span.clone()).map(move |resp| {
                if let Ok(resp) = &resp {
                    if let Some(FormattedInternalError(formatted)) =
                        resp.response().extensions().get::<FormattedInternalError>()
                    {
                        tracing::error!("request handling error: {formatted}");
                    }
                }
                resp
            })
        })