opensymphony 1.8.0

A Rust implementation of the OpenAI Symphony orchestration design
Documentation
use std::{fmt, time::Duration};

use crate::opensymphony_domain::TrackerErrorCategory;
use reqwest::StatusCode;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GraphqlError {
    pub message: String,
    pub code: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResponseMetadata {
    pub content_type: Option<String>,
    pub content_length: Option<String>,
    pub content_encoding: Option<String>,
}

impl fmt::Display for ResponseMetadata {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        let content_type = self.content_type.as_deref().unwrap_or("<missing>");
        let content_length = self.content_length.as_deref().unwrap_or("<missing>");
        let content_encoding = self.content_encoding.as_deref().unwrap_or("<missing>");
        write!(
            formatter,
            "content-type={content_type}, content-length={content_length}, content-encoding={content_encoding}"
        )
    }
}

#[derive(Debug, thiserror::Error)]
pub enum LinearError {
    #[error("invalid Linear client configuration: {0}")]
    InvalidConfiguration(String),
    #[error("Linear request failed: {0}")]
    Request(Box<reqwest::Error>),
    #[error(
        "Linear response body read failed for {operation} after HTTP {status} ({metadata}): {source}"
    )]
    ResponseBody {
        operation: String,
        status: StatusCode,
        metadata: Box<ResponseMetadata>,
        retry_after: Option<Duration>,
        #[source]
        source: Box<reqwest::Error>,
    },
    #[error("Linear API returned HTTP {status}: {body}")]
    HttpStatus {
        status: StatusCode,
        body: String,
        retry_after: Option<Duration>,
    },
    #[error("Linear GraphQL returned errors: {summary}")]
    Graphql {
        errors: Vec<GraphqlError>,
        summary: String,
        retry_after: Option<Duration>,
    },
    #[error("Linear omitted requested issue IDs from state refresh: {issue_ids:?}")]
    MissingIssueIds { issue_ids: Vec<String> },
    #[error("Linear API returned an invalid response: {0}")]
    InvalidResponse(String),
}

impl LinearError {
    pub fn from_graphql_errors(errors: Vec<GraphqlError>) -> Self {
        Self::from_graphql_errors_with_retry_after(errors, None)
    }

    pub fn from_graphql_errors_with_retry_after(
        errors: Vec<GraphqlError>,
        retry_after: Option<Duration>,
    ) -> Self {
        let summary = errors
            .iter()
            .map(|error| match &error.code {
                Some(code) => format!("{code}: {}", error.message),
                None => error.message.clone(),
            })
            .collect::<Vec<_>>()
            .join("; ");
        Self::Graphql {
            errors,
            summary,
            retry_after,
        }
    }

    pub fn category(&self) -> TrackerErrorCategory {
        match self {
            Self::MissingIssueIds { .. } => TrackerErrorCategory::NotFound,
            Self::InvalidConfiguration(_) | Self::InvalidResponse(_) => {
                TrackerErrorCategory::InvalidResponse
            }
            Self::ResponseBody { source, .. } if source.is_timeout() => {
                TrackerErrorCategory::Timeout
            }
            Self::ResponseBody { .. } => TrackerErrorCategory::Transport,
            Self::Request(error) if error.is_timeout() => TrackerErrorCategory::Timeout,
            Self::Request(_) => TrackerErrorCategory::Transport,
            Self::HttpStatus { status, .. } => http_status_category(*status),
            Self::Graphql { errors, .. } => graphql_category(errors),
        }
    }

    pub fn is_rate_limited(&self) -> bool {
        self.category() == TrackerErrorCategory::RateLimited
    }

    pub fn retry_after(&self) -> Option<Duration> {
        match self {
            Self::HttpStatus { retry_after, .. } => *retry_after,
            Self::Graphql { retry_after, .. } => *retry_after,
            Self::ResponseBody { retry_after, .. } => *retry_after,
            _ => None,
        }
    }
}

fn http_status_category(status: StatusCode) -> TrackerErrorCategory {
    match status {
        StatusCode::UNAUTHORIZED => TrackerErrorCategory::Auth,
        StatusCode::FORBIDDEN => TrackerErrorCategory::PermissionDenied,
        StatusCode::NOT_FOUND => TrackerErrorCategory::NotFound,
        StatusCode::TOO_MANY_REQUESTS => TrackerErrorCategory::RateLimited,
        status if status.is_server_error() => TrackerErrorCategory::Transport,
        _ => TrackerErrorCategory::InvalidResponse,
    }
}

fn graphql_category(errors: &[GraphqlError]) -> TrackerErrorCategory {
    for error in errors {
        if let Some(code) = &error.code {
            let code = code.to_ascii_lowercase();
            if code.contains("auth") {
                return TrackerErrorCategory::Auth;
            }
            if code.contains("forbidden") || code.contains("permission") {
                return TrackerErrorCategory::PermissionDenied;
            }
            if code.contains("rate") || code.contains("throttle") {
                return TrackerErrorCategory::RateLimited;
            }
            if code.contains("not_found") || code.contains("notfound") {
                return TrackerErrorCategory::NotFound;
            }
            if code.contains("invalid_state") {
                return TrackerErrorCategory::InvalidStateTransition;
            }
        }

        let message = error.message.to_ascii_lowercase();
        if message.contains("permission") || message.contains("forbidden") {
            return TrackerErrorCategory::PermissionDenied;
        }
        if message.contains("rate limit") || message.contains("too many requests") {
            return TrackerErrorCategory::RateLimited;
        }
        if message.contains("authentication") || message.contains("unauthorized") {
            return TrackerErrorCategory::Auth;
        }
        if message.contains("not found") {
            return TrackerErrorCategory::NotFound;
        }
        if message.contains("invalid state transition") {
            return TrackerErrorCategory::InvalidStateTransition;
        }
    }

    TrackerErrorCategory::InvalidResponse
}

#[cfg(test)]
mod tests {
    use std::time::Duration;

    use crate::opensymphony_domain::TrackerErrorCategory;
    use reqwest::StatusCode;

    use super::{GraphqlError, LinearError};

    #[test]
    fn http_statuses_map_to_tracker_categories() {
        let auth = LinearError::HttpStatus {
            status: StatusCode::UNAUTHORIZED,
            body: "unauthorized".to_string(),
            retry_after: None,
        };
        let permission_denied = LinearError::HttpStatus {
            status: StatusCode::FORBIDDEN,
            body: "forbidden".to_string(),
            retry_after: None,
        };
        let rate_limited = LinearError::HttpStatus {
            status: StatusCode::TOO_MANY_REQUESTS,
            body: "slow down".to_string(),
            retry_after: Some(Duration::from_secs(1)),
        };

        assert_eq!(auth.category(), TrackerErrorCategory::Auth);
        assert_eq!(
            permission_denied.category(),
            TrackerErrorCategory::PermissionDenied
        );
        assert_eq!(rate_limited.category(), TrackerErrorCategory::RateLimited);
    }

    #[test]
    fn graphql_errors_map_to_tracker_categories() {
        let forbidden = LinearError::from_graphql_errors(vec![GraphqlError {
            message: "viewer does not have permission".to_string(),
            code: Some("FORBIDDEN".to_string()),
        }]);
        let not_found = LinearError::from_graphql_errors(vec![GraphqlError {
            message: "issue not found".to_string(),
            code: Some("NOT_FOUND".to_string()),
        }]);
        let rate_limited = LinearError::from_graphql_errors_with_retry_after(
            vec![GraphqlError {
                message: "rate limit exceeded".to_string(),
                code: Some("RATELIMITED".to_string()),
            }],
            Some(Duration::from_secs(2)),
        );

        assert_eq!(forbidden.category(), TrackerErrorCategory::PermissionDenied);
        assert_eq!(not_found.category(), TrackerErrorCategory::NotFound);
        assert_eq!(rate_limited.category(), TrackerErrorCategory::RateLimited);
        assert_eq!(rate_limited.retry_after(), Some(Duration::from_secs(2)));
    }
}