synd-client 0.4.0

syndicationd API client
Documentation
use reqwest::StatusCode;
use synd_protocol::session::{
    CloseSessionErrorResponse, OpenSessionErrorResponse, RenewSessionErrorResponse,
};
use thiserror::Error;
use tokio_tungstenite::tungstenite;
use url::Url;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Retryability {
    Retryable,
    Permanent,
}

#[derive(Error, Debug)]
pub enum SyndApiError {
    #[error("unauthorized")]
    Unauthorized { url: Option<Url> },
    #[error(transparent)]
    BuildRequest(reqwest::Error),
    #[error(transparent)]
    SendRequest(reqwest::Error),
    #[error(transparent)]
    DecodeResponse(reqwest::Error),
    #[error("HTTP status client error ({status}) for url ({url})", url = url.as_ref().map(ToString::to_string).unwrap_or_default())]
    HttpStatus {
        status: StatusCode,
        url: Option<Url>,
    },
    #[error("graphql error: {errors:?}")]
    Graphql { errors: Vec<graphql_client::Error> },
    #[error("session open rejected: {0}")]
    OpenSession(OpenSessionErrorResponse),
    #[error("session renew rejected: {0}")]
    RenewSession(RenewSessionErrorResponse),
    #[error("session close rejected: {0}")]
    CloseSession(CloseSessionErrorResponse),
    #[error("credential is not configured")]
    MissingCredential,
    #[error("invalid authorization header")]
    InvalidHeader(#[from] reqwest::header::InvalidHeaderValue),
    #[error("invalid url")]
    InvalidUrl(#[from] url::ParseError),
    #[error("invalid root certificate: {message}")]
    InvalidRootCertificate { message: String },
    #[error("invalid TLS configuration: {message}")]
    TlsConfiguration { message: String },
    #[error("websocket error: {0}")]
    WebSocket(#[from] tungstenite::Error),
    #[error("json error: {0}")]
    Json(#[from] serde_json::Error),
    #[error("unexpected response: {context}")]
    UnexpectedResponse { context: &'static str },
    #[error("unsupported GraphQL subscription endpoint scheme: {scheme}")]
    UnsupportedWebSocketScheme { scheme: String },
    #[error("failed to set websocket scheme")]
    SetWebSocketScheme,
    #[error("GraphQL subscription protocol error: {message}")]
    SubscriptionProtocol { message: String },
    #[error("feed-event watch closed (code {code:?}): {reason}")]
    FeedEventWatchClosed { code: Option<u16>, reason: String },
}

impl SyndApiError {
    pub fn retryability(&self) -> Retryability {
        match self {
            Self::SendRequest(error) if Self::request_error_is_retryable(error) => {
                Retryability::Retryable
            }
            Self::DecodeResponse(error) if Self::response_error_is_retryable(error) => {
                Retryability::Retryable
            }
            Self::FeedEventWatchClosed { code, .. } if Self::close_code_is_retryable(*code) => {
                Retryability::Retryable
            }
            Self::HttpStatus { status, .. }
                if status.is_server_error() || matches!(status.as_u16(), 408 | 425 | 429) =>
            {
                Retryability::Retryable
            }
            Self::WebSocket(error) if Self::websocket_error_is_retryable(error) => {
                Retryability::Retryable
            }
            Self::Unauthorized { .. }
            | Self::BuildRequest(_)
            | Self::SendRequest(_)
            | Self::DecodeResponse(_)
            | Self::HttpStatus { .. }
            | Self::Graphql { .. }
            | Self::OpenSession(_)
            | Self::RenewSession(_)
            | Self::CloseSession(_)
            | Self::MissingCredential
            | Self::InvalidHeader(_)
            | Self::InvalidUrl(_)
            | Self::InvalidRootCertificate { .. }
            | Self::TlsConfiguration { .. }
            | Self::WebSocket(_)
            | Self::Json(_)
            | Self::UnexpectedResponse { .. }
            | Self::UnsupportedWebSocketScheme { .. }
            | Self::SetWebSocketScheme
            | Self::SubscriptionProtocol { .. }
            | Self::FeedEventWatchClosed { .. } => Retryability::Permanent,
        }
    }

    pub(super) fn from_send_error(error: reqwest::Error) -> Self {
        if error.is_builder() {
            Self::BuildRequest(error)
        } else {
            Self::SendRequest(error)
        }
    }

    pub(super) fn from_status_error(error: reqwest::Error) -> Self {
        match error.status() {
            Some(StatusCode::UNAUTHORIZED) => Self::Unauthorized {
                url: error.url().cloned(),
            },
            Some(status) => Self::HttpStatus {
                status,
                url: error.url().cloned(),
            },
            None => Self::from_send_error(error),
        }
    }

    fn websocket_error_is_retryable(error: &tungstenite::Error) -> bool {
        match error {
            tungstenite::Error::ConnectionClosed | tungstenite::Error::Io(_) => true,
            tungstenite::Error::Http(response) => {
                response.status().is_server_error()
                    || matches!(response.status().as_u16(), 408 | 425 | 429)
            }
            tungstenite::Error::AlreadyClosed
            | tungstenite::Error::Tls(_)
            | tungstenite::Error::Capacity(_)
            | tungstenite::Error::Protocol(_)
            | tungstenite::Error::WriteBufferFull(_)
            | tungstenite::Error::Utf8(_)
            | tungstenite::Error::AttackAttempt
            | tungstenite::Error::Url(_)
            | tungstenite::Error::HttpFormat(_) => false,
        }
    }

    fn request_error_is_retryable(error: &reqwest::Error) -> bool {
        !error.is_redirect()
            && (error.is_connect() || error.is_timeout() || error.is_body() || error.is_request())
    }

    fn response_error_is_retryable(error: &reqwest::Error) -> bool {
        error.is_connect() || error.is_timeout() || (error.is_body() && !error.is_decode())
    }

    fn close_code_is_retryable(code: Option<u16>) -> bool {
        match code {
            None | Some(1000 | 1001 | 1006 | 1011 | 1012 | 1013 | 1014 | 4408 | 4500 | 4504) => {
                true
            }
            Some(_) => false,
        }
    }
}