upstream-rs 2.5.0

Fetch package updates directly from the source.
Documentation
use anyhow::{Result, bail};
use chrono::{DateTime, Utc};
use reqwest::{Response, StatusCode, header};

pub(crate) fn error_for_status(response: &Response, service: &str, url: &str) -> Result<()> {
    let status = response.status();
    if status.is_success() {
        return Ok(());
    }

    if let Some(message) = rate_limit_message(status, response.headers(), service, url) {
        bail!("{message}");
    }

    bail!(
        "{}",
        status_error_message(status, response.headers(), service, url)
    )
}

pub(crate) fn rate_limit_message(
    status: StatusCode,
    headers: &header::HeaderMap,
    service: &str,
    url: &str,
) -> Option<String> {
    if !is_rate_limited(status, headers) {
        return None;
    }

    let mut message = format!(
        "{service} request failed: {} while requesting {url}",
        friendly_status(status)
    );

    if let Some(retry_after) = header_value(headers, header::RETRY_AFTER.as_str()) {
        message.push_str(&format!(
            "; retry after {}",
            format_retry_after(retry_after)
        ));
    } else if let Some(reset) = header_value(headers, "x-ratelimit-reset") {
        message.push_str(&format!("; resets {}", format_rate_limit_reset(reset)));
    } else {
        message.push_str("; wait and try again later");
    }

    if let Some(limit) = header_value(headers, "x-ratelimit-limit") {
        message.push_str(&format!("; limit: {limit} requests"));
    }

    message.push_str("; configure an API token or retry after the reset");
    Some(message)
}

fn status_error_message(
    status: StatusCode,
    headers: &header::HeaderMap,
    service: &str,
    url: &str,
) -> String {
    let mut message = format!(
        "{service} request failed: {} while requesting {url}",
        friendly_status(status)
    );

    if let Some(retry_after) = header_value(headers, header::RETRY_AFTER.as_str()) {
        message.push_str(&format!(
            "; retry after {}",
            format_retry_after(retry_after)
        ));
    }

    if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN {
        message.push_str("; check the configured API token and repository access");
    } else if status == StatusCode::NOT_FOUND {
        message.push_str("; check the repository, release, or asset URL");
    }

    message
}

fn is_rate_limited(status: StatusCode, headers: &header::HeaderMap) -> bool {
    status == StatusCode::TOO_MANY_REQUESTS
        || (status == StatusCode::FORBIDDEN
            && header_value(headers, "x-ratelimit-remaining") == Some("0"))
        || (status == StatusCode::FORBIDDEN
            && header_value(headers, header::RETRY_AFTER.as_str()).is_some())
}

fn friendly_status(status: StatusCode) -> String {
    let label = match status {
        StatusCode::BAD_REQUEST => "bad request".to_string(),
        StatusCode::UNAUTHORIZED => "authentication failed".to_string(),
        StatusCode::FORBIDDEN => "access denied".to_string(),
        StatusCode::NOT_FOUND => "not found".to_string(),
        StatusCode::REQUEST_TIMEOUT => "request timed out".to_string(),
        StatusCode::CONFLICT => "conflict".to_string(),
        StatusCode::GONE => "gone".to_string(),
        StatusCode::PAYLOAD_TOO_LARGE => "response too large".to_string(),
        StatusCode::UNSUPPORTED_MEDIA_TYPE => "unsupported content type".to_string(),
        StatusCode::UNPROCESSABLE_ENTITY => "unprocessable response".to_string(),
        StatusCode::TOO_MANY_REQUESTS => "rate limited".to_string(),
        StatusCode::INTERNAL_SERVER_ERROR => "server error".to_string(),
        StatusCode::BAD_GATEWAY => "bad gateway".to_string(),
        StatusCode::SERVICE_UNAVAILABLE => "service unavailable".to_string(),
        StatusCode::GATEWAY_TIMEOUT => "gateway timed out".to_string(),
        _ => status
            .canonical_reason()
            .map(str::to_ascii_lowercase)
            .unwrap_or_else(|| "HTTP error".to_string()),
    };

    format!("{label} ({})", status.as_u16())
}

fn header_value<'a>(headers: &'a header::HeaderMap, name: &str) -> Option<&'a str> {
    headers.get(name)?.to_str().ok().map(str::trim)
}

fn format_retry_after(value: &str) -> String {
    if value.chars().all(|c| c.is_ascii_digit()) {
        return format!("{value} seconds");
    }

    value.to_string()
}

fn format_rate_limit_reset(value: &str) -> String {
    let Some(seconds) = value.parse::<i64>().ok() else {
        return format!("at {value}");
    };

    let Some(reset_at) = DateTime::<Utc>::from_timestamp(seconds, 0) else {
        return format!("at {value}");
    };

    format!("at {}", reset_at.format("%Y-%m-%d %H:%M:%S UTC"))
}

#[cfg(test)]
mod tests {
    use reqwest::{StatusCode, header};

    use super::{friendly_status, rate_limit_message, status_error_message};

    #[test]
    fn rate_limit_message_explains_429_retry_after() {
        let mut headers = header::HeaderMap::new();
        headers.insert(header::RETRY_AFTER, header::HeaderValue::from_static("120"));

        let message = rate_limit_message(
            StatusCode::TOO_MANY_REQUESTS,
            &headers,
            "GitHub API",
            "https://api.example.invalid",
        )
        .expect("rate limit message");

        assert!(message.contains("GitHub API request failed"));
        assert!(message.contains("rate limited (429)"));
        assert!(message.contains("https://api.example.invalid"));
        assert!(message.contains("retry after 120 seconds"));
        assert!(message.contains("configure an API token"));
        assert!(!message.contains('\n'));
    }

    #[test]
    fn rate_limit_message_explains_github_forbidden_quota() {
        let mut headers = header::HeaderMap::new();
        headers.insert(
            "x-ratelimit-remaining",
            header::HeaderValue::from_static("0"),
        );
        headers.insert(
            "x-ratelimit-reset",
            header::HeaderValue::from_static("1767225600"),
        );
        headers.insert("x-ratelimit-limit", header::HeaderValue::from_static("60"));

        let message = rate_limit_message(
            StatusCode::FORBIDDEN,
            &headers,
            "GitHub API",
            "https://api.example.invalid",
        )
        .expect("rate limit message");

        assert!(message.contains("access denied (403)"));
        assert!(message.contains("resets at 2026-01-01 00:00:00 UTC"));
        assert!(message.contains("limit: 60 requests"));
        assert!(!message.contains('\n'));
    }

    #[test]
    fn non_rate_limit_status_has_no_rate_limit_message() {
        let headers = header::HeaderMap::new();

        assert!(
            rate_limit_message(
                StatusCode::NOT_FOUND,
                &headers,
                "GitHub API",
                "https://api.example.invalid"
            )
            .is_none()
        );
    }

    #[test]
    fn status_error_message_formats_not_found_context() {
        let headers = header::HeaderMap::new();

        let message = status_error_message(
            StatusCode::NOT_FOUND,
            &headers,
            "GitHub API",
            "https://api.example.invalid/repos/owner/missing",
        );

        assert!(message.contains("GitHub API request failed"));
        assert!(message.contains("not found (404)"));
        assert!(message.contains("https://api.example.invalid/repos/owner/missing"));
        assert!(message.contains("check the repository"));
        assert!(!message.contains('\n'));
    }

    #[test]
    fn status_error_message_formats_auth_hint_and_retry_header() {
        let mut headers = header::HeaderMap::new();
        headers.insert(header::RETRY_AFTER, header::HeaderValue::from_static("30"));

        let message = status_error_message(
            StatusCode::UNAUTHORIZED,
            &headers,
            "GitLab API",
            "https://gitlab.example.invalid/api/v4/user",
        );

        assert!(message.contains("GitLab API request failed"));
        assert!(message.contains("authentication failed (401)"));
        assert!(message.contains("retry after 30 seconds"));
        assert!(message.contains("check the configured API token"));
        assert!(!message.contains('\n'));
    }

    #[test]
    fn friendly_status_names_common_http_errors() {
        assert_eq!(
            friendly_status(StatusCode::TOO_MANY_REQUESTS),
            "rate limited (429)"
        );
        assert_eq!(
            friendly_status(StatusCode::UNAUTHORIZED),
            "authentication failed (401)"
        );
        assert_eq!(friendly_status(StatusCode::NOT_FOUND), "not found (404)");
        assert_eq!(
            friendly_status(StatusCode::INTERNAL_SERVER_ERROR),
            "server error (500)"
        );
    }
}