topcoat-router 0.9.0

A modular, batteries-included Rust web framework for server-rendered apps.
Documentation
use http::{HeaderValue, StatusCode, header::RETRY_AFTER};
use topcoat_core::{context::Cx, error::Result};

use crate::response::{IntoResponse, Response};

/// Builds a service-unavailable (HTTP 503) response carrying a `Retry-After`
/// hint, in seconds.
///
/// Return this when the service is temporarily unable to accept a request.
/// The `Retry-After` header tells the client how long to wait before trying
/// again.
///
/// # Examples
///
/// ```rust
/// use topcoat::{Result, router::error::service_unavailable};
/// # struct Permit;
/// # fn try_admit() -> Option<Permit> { Some(Permit) }
///
/// async fn handle() -> Result<&'static str> {
///     let Some(_permit) = try_admit() else {
///         return Err(service_unavailable(2).into());
///     };
///
///     Ok("served")
/// }
/// ```
#[must_use]
pub fn service_unavailable(retry_after_secs: u64) -> ServiceUnavailableError {
    ServiceUnavailableError::new(retry_after_secs)
}

/// A service-unavailable response carried as the `Err` variant of a handler
/// `Result`.
///
/// Construct one with [`service_unavailable`].
#[derive(Debug, Clone)]
pub struct ServiceUnavailableError {
    retry_after_secs: u64,
}

impl ServiceUnavailableError {
    fn new(retry_after_secs: u64) -> Self {
        Self { retry_after_secs }
    }

    /// The `Retry-After` value this response carries, in seconds.
    #[must_use]
    pub fn retry_after_secs(&self) -> u64 {
        self.retry_after_secs
    }
}

impl std::fmt::Display for ServiceUnavailableError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(
            f,
            "service unavailable (retry after {}s)",
            self.retry_after_secs
        )
    }
}

impl std::error::Error for ServiceUnavailableError {}

impl IntoResponse for ServiceUnavailableError {
    fn into_response(self, cx: &Cx) -> Result<Response> {
        let mut response =
            (StatusCode::SERVICE_UNAVAILABLE, "service unavailable").into_response(cx)?;
        // A `u64`'s decimal form is always a valid header value, so the header
        // is only skipped if that ever stops being true.
        if let Ok(value) = HeaderValue::from_str(&self.retry_after_secs.to_string()) {
            response.headers_mut().insert(RETRY_AFTER, value);
        }
        Ok(response)
    }
}

#[cfg(test)]
mod tests {
    use topcoat_core::context::Cx;

    use super::*;

    #[test]
    fn responds_503_with_a_retry_after_header() {
        let response = service_unavailable(2)
            .into_response(&Cx::default())
            .expect("the response builds");

        assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
        assert_eq!(
            response
                .headers()
                .get(RETRY_AFTER)
                .map(HeaderValue::as_bytes),
            Some(&b"2"[..])
        );
    }

    #[test]
    fn keeps_the_retry_after_it_was_built_with() {
        assert_eq!(service_unavailable(30).retry_after_secs(), 30);
    }

    #[test]
    fn reads_as_busy_rather_than_broken() {
        assert_eq!(
            service_unavailable(5).to_string(),
            "service unavailable (retry after 5s)"
        );
    }
}